feat(export-tracker): 增强导出任务跟踪功能 (#1958)

- 添加了对数据库导出、SQL 文件导出和数据传输任务的支持
- 引入了新的状态和错误处理机制
- 实现了任务的重叠检查,防止同时进行相同目标表的数据传输
- 更新了 API 调用以支持新的任务类型
- 增加了相应的国际化文本
- 添加了单元测试以确保新功能的正确性
This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-06-26 18:50:25 +08:00
1 parent 79c2cd8bdc
commit 10aff74072
15 files changed
+838 -208

No files matched your search

@@ -17,9 +17,11 @@ import { isTauriRuntime } from "@/lib/tauriRuntime";
import { useToast } from "@/composables/useToast";
import { Input } from "@/components/ui/input";
import { Download, Square, CheckSquare, Search, X } from "@lucide/vue";
import { useExportTracker } from "@/composables/useExportTracker";
const { t } = useI18n();
const { toast } = useToast();
const { addDatabaseExportTask, updateDatabaseExportTask } = useExportTracker();
const open = defineModel<boolean>("open", { default: false });
const store = useConnectionStore();
@@ -203,9 +205,12 @@ async function startExport() {
batchSize: 1000,
};
addDatabaseExportTask(exportId.value, database.value || "database", filePath);
try {
await api.exportDatabaseSql(request, (progress) => {
exportProgress.value = { ...progress };
updateDatabaseExportTask(progress.exportId, progress);
if (progress.status === "Done") {
exportDone.value = true;
isExporting.value = false;
@@ -220,6 +225,18 @@ async function startExport() {
});
} catch (e: any) {
exportError.value = e?.message || String(e);
const lastProgress = exportProgress.value as api.ExportProgress | null;
const fallbackProgress: api.ExportProgress = {
exportId: exportId.value,
currentObject: database.value || "database",
objectIndex: lastProgress?.objectIndex ?? 0,
totalObjects: lastProgress?.totalObjects ?? 0,
rowsExported: lastProgress?.rowsExported ?? 0,
totalRows: lastProgress?.totalRows ?? null,
status: "Error",
error: exportError.value,
};
updateDatabaseExportTask(exportId.value, fallbackProgress);
isExporting.value = false;
}
}
@@ -489,6 +506,9 @@ watch(
</Button>
</template>
<template v-else-if="isExporting">
<Button variant="outline" size="sm" @click="open = false">
{{ t("databaseExport.runInBackground") }}
</Button>
<Button variant="destructive" size="sm" @click="cancelExport">
<X class="w-3.5 h-3.5 mr-1.5" />
{{ t("transfer.cancel") }}
@@ -3,8 +3,8 @@ import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Loader2, CheckCircle2, XCircle, AlertCircle, X, FileDown } from "@lucide/vue";
import { useExportTracker } from "@/composables/useExportTracker";
import { Loader2, CheckCircle2, XCircle, AlertCircle, X, FileDown, DatabaseBackup, FileCode2, ArrowRightLeft } from "@lucide/vue";
import { useExportTracker, type ExportTask } from "@/composables/useExportTracker";
const { t } = useI18n();
const { tasks, activeCount, hasActive, clearFinished, cancelTask, removeTask } = useExportTracker();
@@ -26,21 +26,77 @@ const isActive = (status: string) => status === "Running" || status === "Writing
const isFinished = (status: string) => status === "Done" || status === "Error" || status === "Cancelled";
const finishedCount = computed(() => tasks.value.filter((t) => isFinished(t.status)).length);
const failedCount = computed(() => tasks.value.filter((t) => t.status === "Error").length);
const triggerTitle = computed(() => (failedCount.value > 0 ? t("exportProgress.failedTooltip", { count: failedCount.value }) : t("exportProgress.tooltip")));
const progressPercent = (totalRows: number | null, rowsExported: number) => {
if (!totalRows || totalRows <= 0) return 0;
return Math.min(100, Math.round((rowsExported / totalRows) * 100));
};
const rowsText = (task: { totalRows: number | null; rowsExported: number }) => {
if (task.totalRows) {
return `${task.rowsExported.toLocaleString()} / ${task.totalRows.toLocaleString()}`;
const progressValue = (task: ExportTask) => {
if (task.kind === "database-export") {
if (!task.totalObjects || task.totalObjects <= 0) return 0;
return Math.min(100, Math.round(((task.objectIndex ?? 0) / task.totalObjects) * 100));
}
if (task.kind === "sql-file") {
const total = task.totalRows ?? task.statementIndex ?? 0;
if (task.status === "Done") return 100;
if (total <= 0) return 0;
return Math.min(95, Math.round((task.rowsExported / total) * 100));
}
if (task.kind === "data-transfer") {
if (!task.totalTables || task.totalTables <= 0) return 0;
if (task.status === "Done") return 100;
return Math.min(95, Math.round(((task.tableIndex ?? 0) / task.totalTables) * 100));
}
return progressPercent(task.totalRows, task.rowsExported);
};
const taskTitle = (task: ExportTask) => {
if (task.kind === "database-export") return t("exportProgress.databaseExportTitle", { name: task.tableName });
if (task.kind === "sql-file") return t("exportProgress.sqlFileTitle", { name: task.tableName });
if (task.kind === "data-transfer") return t("exportProgress.dataTransferTitle", { name: task.tableName });
return `${task.tableName}.${task.format}`;
};
const rowsText = (task: ExportTask) => {
if (task.kind === "database-export") {
if (task.totalObjects) {
return t("exportProgress.objectsCount", {
current: (task.objectIndex ?? 0).toLocaleString(),
total: task.totalObjects.toLocaleString(),
});
}
return t("exportProgress.rowsExported", { count: task.rowsExported.toLocaleString() });
}
if (task.kind === "sql-file") {
return t("exportProgress.statementsCount", {
done: (task.successCount ?? 0).toLocaleString(),
failed: (task.failureCount ?? 0).toLocaleString(),
});
}
if (task.kind === "data-transfer") {
const tableText = task.totalTables
? t("exportProgress.tablesCount", {
current: (task.tableIndex ?? 0).toLocaleString(),
total: task.totalTables.toLocaleString(),
})
: task.currentTable || "";
const rowText = task.totalRows ? `${task.rowsExported.toLocaleString()} / ${task.totalRows.toLocaleString()} ${t("exportProgress.rowsShort")}` : `${task.rowsExported.toLocaleString()} ${t("exportProgress.rowsShort")}`;
return task.currentTable ? `${tableText} · ${task.currentTable} · ${rowText}` : tableText;
}
if (task.totalRows) return `${task.rowsExported.toLocaleString()} / ${task.totalRows.toLocaleString()}`;
return `${task.rowsExported.toLocaleString()} ${t("exportProgress.rowsShort")}`;
};
const statusIcon = (status: string) => {
switch (status) {
const statusIcon = (task: ExportTask) => {
if (isActive(task.status)) {
if (task.kind === "database-export") return DatabaseBackup;
if (task.kind === "sql-file") return FileCode2;
if (task.kind === "data-transfer") return ArrowRightLeft;
}
switch (task.status) {
case "Running":
case "Writing":
return Loader2;
@@ -79,30 +135,31 @@ function toggleShowAll() {
<template>
<Popover v-if="tasks.length > 0" v-model:open="open">
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="relative h-8 w-8" :title="t('exportProgress.tooltip')" :class="{ 'bg-accent text-primary': hasActive }">
<Button variant="ghost" size="icon" class="relative h-8 w-8" :title="triggerTitle" :class="{ 'bg-destructive/10 text-destructive hover:bg-destructive/15': failedCount > 0, 'bg-accent text-primary': failedCount === 0 && hasActive }">
<FileDown class="h-4 w-4" />
<span v-if="hasActive" class="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium leading-none text-primary-foreground">
<span v-if="failedCount > 0" class="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold leading-none text-destructive-foreground"> ! </span>
<span v-else-if="hasActive" class="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium leading-none text-primary-foreground">
{{ activeCount > 9 ? "9+" : activeCount }}
</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" class="w-80 p-0 gap-0 overflow-hidden" :side-offset="8">
<div class="border-b bg-muted/40 px-3 py-2">
<PopoverContent align="start" class="w-[min(92vw,30rem)] p-0 gap-0 overflow-hidden" :side-offset="8">
<div class="border-b bg-muted/40 px-4 py-3">
<div class="text-sm font-semibold">{{ t("exportProgress.popoverTitle") }}</div>
</div>
<div class="max-h-80 overflow-y-auto">
<div v-for="task in visibleTasks" :key="task.exportId" class="flex items-center gap-2 border-b px-3 py-2.5 text-xs last:border-b-0">
<div class="flex-1 min-w-0 flex flex-col gap-1">
<div class="max-h-96 overflow-y-auto">
<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.status)" :class="[statusColor(task.status), isActive(task.status) ? 'animate-spin' : '']" class="h-3.5 w-3.5 shrink-0" />
<span class="truncate font-medium">{{ task.tableName }}.{{ task.format }}</span>
<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" />
<span class="truncate font-medium">{{ taskTitle(task) }}</span>
</div>
<!-- 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" class="h-full bg-primary rounded-full transition-[width] duration-300" :style="{ width: `${progressPercent(task.totalRows, task.rowsExported)}%` }" />
<div v-if="task.totalRows || (task.kind === 'database-export' && task.totalObjects) || (task.kind === 'data-transfer' && task.totalTables)" class="h-full bg-primary rounded-full transition-[width] duration-300" :style="{ width: `${progressValue(task)}%` }" />
<div v-else class="h-full w-full overflow-hidden rounded-full">
<div class="export-progress-indeterminate h-full rounded-full bg-primary" />
</div>
@@ -111,16 +168,16 @@ function toggleShowAll() {
<div class="h-full bg-green-500 rounded-full" style="width: 100%" />
</div>
<div class="flex items-center justify-between text-muted-foreground">
<span class="tabular-nums">{{ rowsText(task) }}</span>
<span v-if="task.status === 'Error' && task.errorMessage" class="truncate ml-2 text-destructive" :title="task.errorMessage">
<div class="min-w-0 text-muted-foreground">
<span class="break-words tabular-nums">{{ rowsText(task) }}</span>
<span v-if="task.status === 'Error' && task.errorMessage" class="mt-1 block whitespace-normal break-words text-destructive" :title="task.errorMessage">
{{ task.errorMessage }}
</span>
</div>
</div>
<!-- Actions: stop/cancel for active, delete for finished -->
<div class="flex shrink-0 self-center">
<div class="flex shrink-0 pt-4">
<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>
@@ -126,7 +126,7 @@ watch(
@confirm="emit('dangerConfirm')"
/>
<SqlParameterDialog v-if="showSqlParameterDialog" :open="showSqlParameterDialog" :sql="sqlParameterSourceSql" :parameters="sqlParameterNames" @update:open="emit('update:showSqlParameterDialog', $event)" @execute="emit('sqlParametersConfirm', $event)" />
<DataTransferDialog v-if="dialogs.showTransferDialog.value" v-model:open="dialogs.showTransferDialog.value" :prefill-connection-id="dialogs.transferPrefillConnectionId.value" :prefill-database="dialogs.transferPrefillDatabase.value" />
<DataTransferDialog v-model:open="dialogs.showTransferDialog.value" :prefill-connection-id="dialogs.transferPrefillConnectionId.value" :prefill-database="dialogs.transferPrefillDatabase.value" />
<SchemaDiffDialog v-if="dialogs.showSchemaDiffDialog.value" v-model:open="dialogs.showSchemaDiffDialog.value" :prefill-connection-id="dialogs.schemaDiffPrefillConnectionId.value" :prefill-database="dialogs.schemaDiffPrefillDatabase.value" :prefill-schema="dialogs.schemaDiffPrefillSchema.value" />
<DataCompareDialog
v-if="dialogs.showDataCompareDialog.value"
@@ -136,7 +136,7 @@ watch(
:prefill-schema="dialogs.dataComparePrefillSchema.value"
:prefill-table="dialogs.dataComparePrefillTable.value"
/>
<SqlFileExecutionDialog v-if="dialogs.showSqlFileDialog.value" v-model:open="dialogs.showSqlFileDialog.value" :prefill-connection-id="dialogs.sqlFilePrefillConnectionId.value" :prefill-database="dialogs.sqlFilePrefillDatabase.value" />
<SqlFileExecutionDialog v-model:open="dialogs.showSqlFileDialog.value" :prefill-connection-id="dialogs.sqlFilePrefillConnectionId.value" :prefill-database="dialogs.sqlFilePrefillDatabase.value" />
<SchemaDiagramDialog
v-if="dialogs.showDiagramDialog.value"
v-model:open="dialogs.showDiagramDialog.value"
@@ -14,11 +14,13 @@ import { useToast } from "@/composables/useToast";
import { useConnectionStore } from "@/stores/connectionStore";
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
import { cancelSqlFileExecution, executeSqlFile, listenSqlFileProgress, listDatabases, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus } from "@/lib/api";
import { useExportTracker } from "@/composables/useExportTracker";
import { Check, CheckSquare, FileCode, FolderOpen, Loader2, Play, Square, X } from "@lucide/vue";
const { t } = useI18n();
const { toast } = useToast();
const { highlight } = useSqlHighlighter();
const { addSqlFileTask, updateSqlFileTask } = useExportTracker();
const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{
@@ -282,6 +284,7 @@ async function startExecution() {
terminalStatus.value = "running";
terminalError.value = "";
progress.value = null;
addSqlFileTask(id, preview.value.fileName, preview.value.filePath);
let unlisten: (() => void) | undefined;
try {
@@ -296,6 +299,7 @@ async function startExecution() {
progress.value = next;
terminalStatus.value = next.status;
terminalError.value = next.error ?? terminalError.value;
updateSqlFileTask(id, next);
if (isTerminalStatus(next.status)) {
running.value = false;
cancelling.value = false;
@@ -320,6 +324,18 @@ async function startExecution() {
});
if (!isTerminalStatus(terminalStatus.value)) {
terminalStatus.value = cancelRequested.value ? "cancelled" : "done";
const lastProgress = progress.value as SqlFileProgress | null;
updateSqlFileTask(id, {
executionId: id,
status: terminalStatus.value,
statementIndex: lastProgress?.statementIndex ?? 0,
successCount: lastProgress?.successCount ?? 0,
failureCount: lastProgress?.failureCount ?? 0,
affectedRows: lastProgress?.affectedRows ?? 0,
elapsedMs: lastProgress?.elapsedMs ?? 0,
statementSummary: lastProgress?.statementSummary ?? "",
error: lastProgress?.error ?? null,
});
if (terminalStatus.value === "done") {
await refreshTargetAfterImport();
}
@@ -327,6 +343,18 @@ async function startExecution() {
} catch (e: any) {
terminalStatus.value = cancelRequested.value ? "cancelled" : "error";
terminalError.value = e?.message || String(e);
const lastProgress = progress.value as SqlFileProgress | null;
updateSqlFileTask(id, {
executionId: id,
status: terminalStatus.value,
statementIndex: lastProgress?.statementIndex ?? 0,
successCount: lastProgress?.successCount ?? 0,
failureCount: lastProgress?.failureCount ?? 0,
affectedRows: lastProgress?.affectedRows ?? 0,
elapsedMs: lastProgress?.elapsedMs ?? 0,
statementSummary: lastProgress?.statementSummary ?? "",
error: terminalError.value,
});
if (!cancelRequested.value) {
toast(terminalError.value, 5000);
}
@@ -356,7 +384,6 @@ async function cancelExecution() {
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && running.value) return;
open.value = nextOpen;
}
@@ -373,6 +400,7 @@ watch(
open,
(value) => {
if (!value) return;
if (running.value) return;
resetState();
if (connectionId.value) {
loadDatabasesForConnection(connectionId.value);
@@ -543,6 +571,9 @@ watch(
<DialogFooter>
<template v-if="running">
<Button variant="outline" size="sm" @click="open = false">
{{ t("sqlFile.runInBackground") }}
</Button>
<Button variant="destructive" size="sm" :disabled="cancelling" @click="cancelExecution">
<Loader2 v-if="cancelling" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
<X v-else class="w-3.5 h-3.5 mr-1.5" />
@@ -10,14 +10,15 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { useConnectionStore } from "@/stores/connectionStore";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import * as api from "@/lib/api";
import type { TransferProgress, TransferMode, TransferTableNameCase } from "@/lib/api";
import type { TransferMode, TransferTableNameCase } from "@/lib/api";
import type { DatabaseType } from "@/types/database";
import { isSchemaAware, supportsTransfer } from "@/lib/databaseCapabilities";
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
import { nextTransferTerminalState } from "@/lib/transferProgressState";
import { ArrowRightLeft, Check, X, Loader2, Square, CheckSquare } from "@lucide/vue";
import { useExportTracker } from "@/composables/useExportTracker";
import { ArrowRightLeft, Loader2, Square, CheckSquare } from "@lucide/vue";
const { t } = useI18n();
const { startDataTransferTask } = useExportTracker();
const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{
@@ -52,15 +53,7 @@ const createTable = ref(true);
const transferMode = ref<TransferMode>("append");
const targetTableNameCase = ref<TransferTableNameCase>("preserve");
const batchSize = ref(1000);
// Transfer state
const isTransferring = ref(false);
const transferProgress = ref<Map<string, TransferProgress>>(new Map());
const currentTable = ref("");
const overallDone = ref(false);
const overallError = ref(false);
const overallCancelled = ref(false);
const transferId = ref("");
const isSubmitting = ref(false);
const filteredTables = computed(() => {
const q = tableSearch.value.toLowerCase();
@@ -252,33 +245,27 @@ function resetState() {
transferMode.value = "append";
targetTableNameCase.value = "preserve";
batchSize.value = 1000;
isTransferring.value = false;
transferProgress.value.clear();
currentTable.value = "";
overallDone.value = false;
overallError.value = false;
overallCancelled.value = false;
isSubmitting.value = false;
}
async function startTransfer() {
isTransferring.value = true;
overallDone.value = false;
overallError.value = false;
overallCancelled.value = false;
transferProgress.value.clear();
transferId.value = uuid();
if (!canStart.value || isSubmitting.value) return;
isSubmitting.value = true;
const effectiveSourceSchema = sourceSchema.value || sourceDatabase.value;
const effectiveTargetSchema = targetSchema.value || targetDatabase.value;
const sourceDatabaseName = sourceDatabase.value;
const targetConnection = targetConnectionId.value;
const targetDatabaseName = targetDatabase.value;
const shouldRefreshTargetTree = createTable.value;
const request: api.TransferRequest = {
transferId: transferId.value,
transferId: uuid(),
sourceConnectionId: sourceConnectionId.value,
sourceDatabase: sourceDatabase.value,
sourceDatabase: sourceDatabaseName,
sourceSchema: effectiveSourceSchema,
targetConnectionId: targetConnectionId.value,
targetDatabase: targetDatabase.value,
targetConnectionId: targetConnection,
targetDatabase: targetDatabaseName,
targetSchema: effectiveTargetSchema,
tables: [...selectedTables.value],
createTable: createTable.value,
@@ -287,38 +274,16 @@ async function startTransfer() {
batchSize: batchSize.value,
};
try {
await api.startTransfer(request, (progress) => {
if (progress.table) {
transferProgress.value.set(progress.table, progress);
transferProgress.value = new Map(transferProgress.value);
currentTable.value = progress.table;
startDataTransferTask(request, `${sourceDatabaseName} → ${targetDatabaseName}`, {
formatOverlapError: (tables) => t("transfer.targetTableBusy", { tables: tables.join(", ") }),
onDone: async () => {
if (shouldRefreshTargetTree) {
await store.refreshObjectListTreeNode(targetConnection, targetDatabaseName, effectiveTargetSchema);
}
const nextState = nextTransferTerminalState(
{
done: overallDone.value,
cancelled: overallCancelled.value,
error: overallError.value,
},
progress,
);
overallDone.value = nextState.done;
overallCancelled.value = nextState.cancelled;
overallError.value = nextState.error;
});
if (createTable.value && !overallError.value && !overallCancelled.value) {
await store.refreshObjectListTreeNode(targetConnectionId.value, targetDatabase.value, targetSchema.value);
}
} catch (e: any) {
overallError.value = true;
}
}
async function cancelTransfer() {
if (transferId.value) {
await api.cancelTransfer(transferId.value);
}
},
});
open.value = false;
resetState();
}
function getConnectionName(id: string) {
@@ -328,29 +293,6 @@ function getConnectionName(id: string) {
function getConnectionType(id: string): DatabaseType {
return store.connections.find((c) => c.id === id)?.db_type ?? "mysql";
}
const processedStatuses = new Set<TransferProgress["status"]>(["tableDone", "done", "error", "cancelled"]);
function formatRowCount(count: number) {
return count.toLocaleString();
}
function formatTableRows(progress: TransferProgress) {
if (typeof progress.totalRows === "number") {
return `${formatRowCount(progress.rowsTransferred)} / ${formatRowCount(progress.totalRows)}`;
}
return formatRowCount(progress.rowsTransferred);
}
const completedTables = computed(() => [...transferProgress.value.values()].filter((p) => processedStatuses.has(p.status)).length);
const failedTables = computed(() => [...transferProgress.value.values()].filter((p) => p.status === "error").length);
const totalTransferred = computed(() => [...transferProgress.value.values()].reduce((sum, p) => sum + p.rowsTransferred, 0));
const knownTotalRows = computed(() => [...transferProgress.value.values()].reduce((sum, p) => sum + (typeof p.totalRows === "number" ? p.totalRows : 0), 0));
const overallRowsLabel = computed(() => (knownTotalRows.value > 0 ? `${formatRowCount(totalTransferred.value)} / ${formatRowCount(knownTotalRows.value)}` : formatRowCount(totalTransferred.value)));
</script>
<template>
@@ -364,8 +306,7 @@ const overallRowsLabel = computed(() => (knownTotalRows.value > 0 ? `${formatRow
</DialogHeader>
<div class="flex-1 min-h-0 overflow-auto">
<!-- Config View -->
<div v-if="!isTransferring" class="grid gap-4 py-3">
<div class="grid gap-4 py-3">
<!-- Source Section -->
<div class="space-y-3">
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
@@ -546,86 +487,17 @@ const overallRowsLabel = computed(() => (knownTotalRows.value > 0 ? `${formatRow
</div>
</div>
</div>
<!-- Progress View -->
<div v-else class="py-3 space-y-3">
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>
{{ t("transfer.overallProgress") }}: {{ completedTables }} / {{ selectedTables.size }} {{ t("transfer.tables").toLowerCase() }} · {{ overallRowsLabel }}
{{ t("grid.rows", { count: "" }).trim() }}
</span>
<span v-if="overallDone && !failedTables" class="text-green-600 font-medium">{{ t("transfer.completed") }}</span>
<span v-else-if="overallDone && failedTables" class="text-amber-600 font-medium">
{{ t("transfer.completedWithErrors", { count: failedTables }) }}
</span>
<span v-else-if="overallCancelled" class="text-yellow-600 font-medium">{{ t("transfer.cancelled") }}</span>
<span v-else-if="overallError" class="text-destructive font-medium">{{ t("transfer.failed") }}</span>
</div>
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
<div
class="h-full rounded-full transition-[width] duration-300"
:class="overallError ? 'bg-destructive' : overallCancelled ? 'bg-yellow-500' : overallDone && failedTables ? 'bg-amber-500' : 'bg-primary'"
:style="{
width: `${selectedTables.size ? (completedTables / selectedTables.size) * 100 : 0}%`,
}"
/>
</div>
<div class="border rounded-md max-h-[280px] overflow-y-auto">
<div v-for="table in [...selectedTables]" :key="table" class="flex items-center justify-between px-2.5 py-1.5 text-xs border-b last:border-b-0">
<span class="truncate">{{ table }}</span>
<div class="flex items-center gap-1.5 shrink-0 text-muted-foreground">
<template v-if="transferProgress.get(table)">
<template v-if="transferProgress.get(table)!.status === 'running'">
<Loader2 class="w-3 h-3 animate-spin text-primary" />
<span>{{ formatTableRows(transferProgress.get(table)!) }}</span>
</template>
<template v-else-if="transferProgress.get(table)!.status === 'tableDone' || transferProgress.get(table)!.status === 'done'">
<Check class="w-3 h-3 text-green-500" />
<span>{{ formatTableRows(transferProgress.get(table)!) }}</span>
</template>
<template v-else-if="transferProgress.get(table)!.status === 'error'">
<X class="w-3 h-3 text-destructive" />
<span>{{ formatTableRows(transferProgress.get(table)!) }}</span>
<span class="max-w-[520px] whitespace-normal break-words text-destructive" :title="transferProgress.get(table)!.error ?? ''">
{{ transferProgress.get(table)!.error }}
</span>
</template>
<template v-else-if="transferProgress.get(table)!.status === 'cancelled'">
<X class="w-3 h-3 text-yellow-500" />
<span>{{ t("transfer.cancelled") }}</span>
</template>
</template>
<span v-else class="text-muted-foreground/40">—</span>
</div>
</div>
</div>
<!-- Status message -->
</div>
</div>
<DialogFooter>
<template v-if="!isTransferring">
<Button variant="outline" size="sm" @click="open = false">
{{ t("transfer.cancel") }}
</Button>
<Button size="sm" :disabled="!canStart" @click="startTransfer">
<ArrowRightLeft class="w-3.5 h-3.5 mr-1.5" />
{{ t("transfer.start") }}
</Button>
</template>
<template v-else-if="overallDone || overallCancelled || overallError">
<Button size="sm" @click="open = false">
{{ t("common.close") }}
</Button>
</template>
<template v-else>
<Button variant="destructive" size="sm" @click="cancelTransfer">
{{ t("transfer.cancel") }}
</Button>
</template>
<Button variant="outline" size="sm" @click="open = false">
{{ t("transfer.cancel") }}
</Button>
<Button size="sm" :disabled="!canStart || isSubmitting" @click="startTransfer">
<Loader2 v-if="isSubmitting" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
<ArrowRightLeft v-else class="w-3.5 h-3.5 mr-1.5" />
{{ t("transfer.start") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>