feat(compare): keep schema and data compares running in background

This commit is contained in:
jearhe authored and GitHub committed 2026-09-07 14:04:39 +08:00
1 parent 29c6d1fbed
commit dc7b905220
15 files changed
+1643 -535

No files matched your search

@@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Loader2, Check, CheckCircle2, XCircle, AlertCircle, X, FileDown, DatabaseBackup, FileCode2, ArrowRightLeft, Layers3, ChevronRight, FolderOpen, Copy } from "@lucide/vue";
import { Loader2, Check, CheckCircle2, XCircle, AlertCircle, X, FileDown, DatabaseBackup, FileCode2, ArrowRightLeft, Layers3, GitCompareArrows, ChevronRight, FolderOpen, Copy } from "@lucide/vue";
import { formatDataTransferDuration, useExportTracker, type ExportTask } from "@/composables/useExportTracker";
import { dataTransferFailureCopyText, sqlFileFailureCopyText } from "@/components/export/failureDetailCopyText";
import { translateBackendError } from "@/i18n/backend-errors";
@@ -81,6 +81,11 @@ const progressValue = (task: ExportTask) => {
if (!task.multiDbTotal) return 0;
return Math.min(95, Math.round(((task.multiDbCompleted ?? 0) / task.multiDbTotal) * 100));
}
if (task.kind === "schema-diff" || task.kind === "data-compare") {
if (task.status === "Done" || task.status === "Error" || task.status === "Cancelled") return 100;
if (!task.compareTotal) return 0;
return Math.min(95, Math.round(((task.compareCurrent ?? 0) / task.compareTotal) * 100));
}
return progressPercent(task.totalRows, task.rowsExported);
};
@@ -105,10 +110,48 @@ const taskTitle = (task: ExportTask) => {
if (task.kind === "sql-file") return t("exportProgress.sqlFileTitle", { name: task.tableName });
if (task.kind === "data-transfer") return t("exportProgress.dataTransferTitle", { name: task.tableName });
if (task.kind === "multi-db-execution") return t("exportProgress.multiDbExecutionTitle", { name: task.tableName });
if (task.kind === "schema-diff") return t("exportProgress.schemaDiffTitle", { name: task.tableName });
if (task.kind === "data-compare") return t("exportProgress.dataCompareTitle", { name: task.tableName });
return taskFileName(task) || `${task.tableName}.${task.format}`;
};
const comparePhaseText = (phase?: string) => {
if (!phase) return "";
const phaseKey: Record<string, string> = {
"loading-table-lists": "exportProgress.comparePhase.loadingTableLists",
"loading-source-details": "exportProgress.comparePhase.loadingSourceDetails",
"loading-target-details": "exportProgress.comparePhase.loadingTargetDetails",
"loading-extra-objects": "exportProgress.comparePhase.loadingExtraObjects",
comparing: "exportProgress.comparePhase.comparing",
generating: "exportProgress.comparePhase.generating",
complete: "exportProgress.comparePhase.complete",
};
return t(phaseKey[phase] ?? phase);
};
const rowsText = (task: ExportTask) => {
if (task.kind === "schema-diff") {
if (isActive(task.status)) {
const progress = task.compareTotal ? `${task.compareCurrent ?? 0}/${task.compareTotal}` : "";
return [comparePhaseText(task.comparePhase), task.compareCurrentObject, progress].filter(Boolean).join(" · ");
}
return t("exportProgress.schemaDiffSummary", { count: task.compareResultCount ?? 0 });
}
if (task.kind === "data-compare") {
if (isActive(task.status)) {
const progress = task.compareTotal ? `${task.compareCurrent ?? 0}/${task.compareTotal}` : "";
return [comparePhaseText(task.comparePhase), progress, task.compareCurrentObject].filter(Boolean).join(" · ");
}
return t("exportProgress.dataCompareSummary", {
tables: task.compareTotal ?? 0,
same: task.compareSameCount ?? 0,
different: task.compareDifferentCount ?? 0,
failed: task.compareFailedCount ?? 0,
added: task.compareAddedCount ?? 0,
removed: task.compareRemovedCount ?? 0,
modified: task.compareModifiedCount ?? 0,
});
}
if (task.kind === "database-export") {
if (task.overallPercent !== undefined) return `${task.overallPercent}%`;
if (task.totalObjects) {
@@ -149,7 +192,14 @@ const rowsText = (task: ExportTask) => {
return `${task.rowsExported.toLocaleString()} ${t("exportProgress.rowsShort")}`;
};
const taskStatusText = (task: ExportTask) => (task.status === "Cancelling" ? t("databaseBackup.cancelling") : "");
const taskStatusText = (task: ExportTask) => {
if (task.status === "Cancelling") return t("databaseBackup.cancelling");
if (task.kind !== "schema-diff" && task.kind !== "data-compare") return "";
if (task.status === "Running") return t("exportProgress.compareRunning");
if (task.status === "Done") return t("exportProgress.compareDone");
if (task.status === "Error") return t("exportProgress.compareError");
return t("exportProgress.compareCancelled");
};
const elapsedText = (task: ExportTask) => {
if (task.startedAt === undefined) return "";
@@ -175,6 +225,7 @@ const statusIcon = (task: ExportTask) => {
if (task.kind === "sql-file") return FileCode2;
if (task.kind === "data-transfer") return ArrowRightLeft;
if (task.kind === "multi-db-execution") return Layers3;
if (task.kind === "schema-diff" || task.kind === "data-compare") return GitCompareArrows;
}
switch (task.status) {
case "Running":
@@ -276,6 +327,7 @@ async function copyFailureDetail(text: string, key: string): Promise<void> {
}
function openTask(task: ExportTask): void {
open.value = false;
task.onOpen?.();
}
</script>
@@ -308,7 +360,13 @@ function openTask(task: ExportTask): void {
<!-- Progress bar -->
<div v-if="isActive(task.status)" class="w-full bg-muted rounded-full h-1.5 overflow-hidden">
<div
v-if="task.totalRows || (task.kind === 'database-export' && (task.totalObjects || task.overallPercent !== undefined)) || (task.kind === 'data-transfer' && task.totalTables) || (task.kind === 'multi-db-execution' && task.multiDbTotal)"
v-if="
task.totalRows ||
(task.kind === 'database-export' && (task.totalObjects || task.overallPercent !== undefined)) ||
(task.kind === 'data-transfer' && task.totalTables) ||
(task.kind === 'multi-db-execution' && task.multiDbTotal) ||
((task.kind === 'schema-diff' || task.kind === 'data-compare') && task.compareTotal)
"
class="h-full bg-primary rounded-full transition-[width] duration-300"
:style="{ width: `${progressValue(task)}%` }"
/>
@@ -388,14 +446,14 @@ function openTask(task: ExportTask): void {
<!-- Actions: reveal folder for finished exports, stop/cancel for active, delete for finished -->
<div class="flex shrink-0 pt-4">
<button v-if="task.kind === 'multi-db-execution' && task.onOpen" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted" :title="t('exportProgress.openTask')" @click.stop="openTask(task)">
<button v-if="task.onOpen" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted" :title="t('exportProgress.openTask')" @click.stop="openTask(task)">
<ChevronRight class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
</button>
<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)"
v-if="isActive(task.status) && task.canCancel !== false"
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')"
@@ -404,7 +462,7 @@ function openTask(task: ExportTask): void {
<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)">
<button v-else-if="!isActive(task.status)" 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" />
</button>
</div>