feat(sql-file): show byte-based import progress

Closes #9440
This commit is contained in:
t8y2 committed 2026-09-17 18:37:56 +08:00
1 parent fb25a66d7a
commit be251d596e
14 files changed
+497 -43

No files matched your search

@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
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 SqlFileProgressIndicator from "@/components/sql-file/SqlFileProgressIndicator.vue";
import { translateBackendError } from "@/i18n/backend-errors";
import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
@@ -65,12 +66,6 @@ const progressValue = (task: ExportTask) => {
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;
@@ -358,7 +353,8 @@ function openTask(task: ExportTask): void {
</div>
<!-- Progress bar -->
<div v-if="isActive(task.status)" class="w-full bg-muted rounded-full h-1.5 overflow-hidden">
<SqlFileProgressIndicator v-if="task.kind === 'sql-file'" :status="task.status" :bytes-read="task.bytesRead" :total-bytes="task.totalBytes" :phase="task.sqlFilePhase" />
<div v-else-if="isActive(task.status)" class="w-full bg-muted rounded-full h-1.5 overflow-hidden">
<div
v-if="
task.totalRows ||
@@ -384,6 +380,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 === 'sql-file' && task.elapsedMs !== undefined" class="ml-1 tabular-nums">{{ t("exportProgress.elapsed", { duration: formatDataTransferDuration(task.elapsedMs) }) }}</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="hasUnlistedTaskError(task)" class="mt-1 block whitespace-normal break-words text-destructive" :title="translateBackendError(t, task.errorMessage!)">
@@ -94,6 +94,61 @@ describe("failure detail copy text", () => {
});
});
describe("SQL file byte progress", () => {
it("keeps legacy progress indeterminate instead of using successful statements as the total", async () => {
const tracker = useExportTracker();
const task = tracker.addSqlFileTask("legacy-sql-progress", "large.sql", "/tmp/large.sql");
tracker.updateSqlFileTask(task.exportId, {
executionId: task.exportId,
status: "running",
statementIndex: 772205,
successCount: 772205,
failureCount: 0,
affectedRows: 0,
elapsedMs: 1000,
statementSummary: "INSERT INTO users VALUES (1)",
});
await mountPopover();
expect(document.querySelector('[data-testid="sql-file-progress"] [role="progressbar"]')?.hasAttribute("aria-valuenow")).toBe(false);
expect(task.totalRows).toBeNull();
});
it.each(["done", "error", "cancelled"] as const)("shows byte progress and preserves it on %s", async (status) => {
const tracker = useExportTracker();
const task = tracker.addSqlFileTask(`byte-progress-${status}`, "large.sql", "/tmp/large.sql");
const progress = {
executionId: task.exportId,
status: "running" as const,
statementIndex: 772205,
successCount: 772205,
failureCount: 0,
affectedRows: 0,
elapsedMs: 1000,
statementSummary: "INSERT INTO users VALUES (1)",
bytesRead: 1024,
totalBytes: 4096,
phase: "executing" as const,
};
tracker.updateSqlFileTask(task.exportId, progress);
await mountPopover();
const indicator = document.querySelector('[data-testid="sql-file-progress"]')!;
expect(indicator.textContent).toContain("Executing SQL");
expect(indicator.textContent).toContain("Read 1.0 KB / 4.0 KB");
expect(indicator.querySelector('[role="progressbar"]')?.getAttribute("aria-valuenow")).toBe("25");
tracker.updateSqlFileTask(task.exportId, { ...progress, status: "statementDone", fileIndex: 1, statementIndex: 2, successCount: 2, bytesRead: 2048 });
await nextTick();
expect(indicator.querySelector('[role="progressbar"]')?.getAttribute("aria-valuenow")).toBe("50");
tracker.updateSqlFileTask(task.exportId, { ...progress, status, bytesRead: undefined, totalBytes: undefined, phase: undefined });
await nextTick();
expect(indicator.querySelector('[role="progressbar"]')?.getAttribute("aria-valuenow")).toBe(status === "done" ? "100" : "50");
expect(task.bytesRead).toBe(2048);
expect(task.totalBytes).toBe(4096);
expect(task.elapsedMs).toBe(1000);
});
});
describe("ExportProgressPopover task duration", () => {
it("distinguishes manual exports from scheduled backups and uses a compact failure dot", async () => {
const tracker = useExportTracker();
@@ -223,6 +223,21 @@ afterEach(() => {
});
describe("SqlFileExecutionDialog retries", () => {
it("shows byte-based progress while SQL executes and only completes on a terminal event", async () => {
await mountReadyDialog();
const executionGate = deferred();
mocks.executeSqlFiles.mockImplementationOnce(async (request: { executionId: string }) => {
mocks.progressHandler?.(progress(request.executionId, "running", { statementIndex: 500, successCount: 499, bytesRead: 1024, totalBytes: 4096, phase: "executing" }));
await executionGate.promise;
mocks.progressHandler?.(progress(request.executionId, "done", { bytesRead: 4096, totalBytes: 4096 }));
});
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(root!.querySelector('[role="progressbar"]')?.getAttribute("aria-valuenow")).toBe("25"));
expect(root!.textContent).toContain("sqlFile.progressPhase.executing");
executionGate.resolve();
await vi.waitFor(() => expect(root!.querySelector('[role="progressbar"]')?.getAttribute("aria-valuenow")).toBe("100"));
});
it("does not auto-select the first connection for an unassociated external file", async () => {
root = document.createElement("div");
document.body.append(root);
@@ -21,6 +21,7 @@ import { fetchSqlFileTargetOptions } from "@/composables/useDatabaseOptions";
import { requiresSqlFileTargetDatabaseSelection, supportsConnectionLevelDatabaseBootstrap } from "@/lib/connection/connectionLevelDatabaseBootstrap";
import { cancelSqlFileExecution, executeSqlFiles, inspectSqlFileTables, listenSqlFileProgress, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus, type SqlFileTable } from "@/lib/backend/api";
import { buildDisplayFileNames, tooltipText as computeTooltipText } from "./sqlFilePreviewLabel";
import SqlFileProgressIndicator from "./SqlFileProgressIndicator.vue";
import { useExportTracker, type ExportTask } from "@/composables/useExportTracker";
import { translateBackendError } from "@/i18n/backend-errors";
import { Check, CheckSquare, ChevronRight, FileCode, FolderOpen, Loader2, Play, Square, X } from "@lucide/vue";
@@ -222,14 +223,6 @@ const statusIcon = computed(() => {
return FileCode;
});
const progressPercent = computed(() => {
if (!progress.value) return 0;
if (terminalStatus.value === "done") return 100;
const attempted = progress.value.successCount + progress.value.failureCount;
const current = Math.max(progress.value.statementIndex, attempted);
if (current <= 0) return running.value ? 8 : 0;
return Math.min(95, Math.max(8, Math.round((attempted / current) * 100)));
});
const sqlFileFailures = computed(() => activeExecutionTask.value?.sqlFileFailures ?? []);
const sqlFileFailureCount = computed(() => sqlFileFailures.value.length + (activeExecutionTask.value?.sqlFileFailuresOmitted ?? 0));
const unlistedTerminalError = computed(() => {
@@ -826,9 +819,7 @@ watch(
</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="terminalStatus === 'error' ? 'bg-destructive' : terminalStatus === 'cancelled' ? 'bg-yellow-500' : 'bg-primary'" :style="{ width: `${progressPercent}%` }" />
</div>
<SqlFileProgressIndicator :status="terminalStatus" :bytes-read="progress?.bytesRead" :total-bytes="progress?.totalBytes" :phase="progress?.phase" />
<div v-if="running && previews.length > 1 && currentFileIndex >= 0" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<FileCode class="w-3.5 h-3.5 shrink-0" />
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import type { SqlFileProgress } from "@/lib/backend/tauri";
import { formatSqlFileBytes, sqlFileProgressPercent } from "@/lib/sql/sqlFileProgress";
const props = defineProps<{
status: string;
bytesRead?: number;
totalBytes?: number;
phase?: SqlFileProgress["phase"];
}>();
const { t } = useI18n();
const normalizedStatus = computed(() => props.status.toLowerCase());
const active = computed(() => !["done", "error", "cancelled", "idle"].includes(normalizedStatus.value));
const percent = computed(() => sqlFileProgressPercent(props.status, props.bytesRead, props.totalBytes));
const phaseLabel = computed(() => {
if (!active.value) return t("sqlFile.byteProgress");
const phase = props.phase ?? (normalizedStatus.value === "started" ? "preparing" : "executing");
return t(`sqlFile.progressPhase.${phase}`);
});
const byteLabel = computed(() => {
if (props.bytesRead === undefined || !Number.isFinite(props.bytesRead)) return "";
const read = formatSqlFileBytes(props.bytesRead);
return props.totalBytes === undefined || !Number.isFinite(props.totalBytes) ? t("sqlFile.bytesRead", { read }) : t("sqlFile.bytesReadOfTotal", { read, total: formatSqlFileBytes(props.totalBytes) });
});
</script>
<template>
<div class="min-w-0 space-y-1.5 text-xs" data-testid="sql-file-progress" :title="t('sqlFile.byteProgressHint')">
<div class="flex items-center justify-between gap-2 text-muted-foreground">
<span class="truncate">{{ phaseLabel }}</span>
<span v-if="percent !== null" class="shrink-0 tabular-nums">{{ percent }}%</span>
</div>
<div role="progressbar" :aria-label="t('sqlFile.byteProgress')" :aria-valuenow="percent ?? undefined" :aria-valuemin="0" :aria-valuemax="100" class="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div v-if="percent === null && active" class="sql-file-progress-indeterminate h-full rounded-full bg-primary" />
<div v-else class="h-full rounded-full transition-[width] duration-300" :class="normalizedStatus === 'error' ? 'bg-destructive' : normalizedStatus === 'cancelled' ? 'bg-yellow-500' : normalizedStatus === 'done' ? 'bg-green-500' : 'bg-primary'" :style="{ width: `${percent ?? 0}%` }" />
</div>
<div v-if="byteLabel" class="break-words text-muted-foreground tabular-nums">{{ byteLabel }}</div>
</div>
</template>
<style scoped>
.sql-file-progress-indeterminate {
width: 40%;
animation: sql-file-progress-slide 1.15s ease-in-out infinite;
}
@keyframes sql-file-progress-slide {
from {
transform: translateX(-100%);
}
to {
transform: translateX(250%);
}
}
</style>
@@ -48,6 +48,9 @@ export interface ExportTask {
failureCount?: number;
affectedRows?: number;
elapsedMs?: number;
bytesRead?: number;
totalBytes?: number;
sqlFilePhase?: api.SqlFileProgress["phase"];
startedAt?: number;
finishedAt?: number;
statementSummary?: string;
@@ -661,9 +664,12 @@ export function useExportTracker() {
task.failureCount = progress.failureCount;
task.affectedRows = progress.affectedRows;
task.elapsedMs = progress.elapsedMs;
task.bytesRead = progress.bytesRead ?? task.bytesRead;
task.totalBytes = progress.totalBytes ?? task.totalBytes;
task.sqlFilePhase = progress.phase ?? task.sqlFilePhase;
task.statementSummary = progress.statementSummary;
task.rowsExported = progress.successCount + progress.failureCount;
task.totalRows = Math.max(progress.statementIndex, progress.successCount + progress.failureCount) || null;
task.totalRows = null;
}
function updateDataTransferTask(transferId: string, progress: api.TransferProgress) {
+9
View File
@@ -6474,6 +6474,15 @@ export default {
cancelled: "Cancelled",
},
fileProgress: "File {current} of {total}",
byteProgress: "File progress",
byteProgressHint: "Based on file bytes read, not remaining execution time. Reaches 100% only when execution finishes. Compressed files use compressed bytes.",
bytesRead: "Read {read}",
bytesReadOfTotal: "Read {read} / {total}",
progressPhase: {
preparing: "Preparing SQL file",
reading: "Reading SQL file",
executing: "Executing SQL",
},
fileColumn: "File",
totalFiles: "{count} files",
},
+9
View File
@@ -6449,6 +6449,15 @@ export default withEnglishFallback({
cancelled: "已取消",
},
fileProgress: "第 {current}/{total} 个文件",
byteProgress: "文件进度",
byteProgressHint: "按文件读取字节数计算,不代表剩余执行时间;执行结束后才显示 100%。压缩文件按压缩后的字节数计算。",
bytesRead: "已读取 {read}",
bytesReadOfTotal: "已读取 {read} / {total}",
progressPhase: {
preparing: "正在准备 SQL 文件",
reading: "正在读取 SQL 文件",
executing: "正在执行 SQL",
},
fileColumn: "文件",
totalFiles: "{count} 个文件",
},
+9
View File
@@ -5410,6 +5410,15 @@ export default withEnglishFallback({
cancelled: "已取消",
},
fileProgress: "第 {current}/{total} 個檔案",
byteProgress: "檔案進度",
byteProgressHint: "按檔案讀取位元組數計算,不代表剩餘執行時間;執行結束後才顯示 100%。壓縮檔案按壓縮後的位元組數計算。",
bytesRead: "已讀取 {read}",
bytesReadOfTotal: "已讀取 {read} / {total}",
progressPhase: {
preparing: "正在準備 SQL 檔案",
reading: "正在讀取 SQL 檔案",
executing: "正在執行 SQL",
},
fileColumn: "檔案",
totalFiles: "{count} 個檔案",
runInBackground: "背景執行",
+3
View File
@@ -4819,6 +4819,9 @@ export interface SqlFileProgress {
elapsedMs: number;
statementSummary: string;
error?: string | null;
bytesRead?: number;
totalBytes?: number;
phase?: "preparing" | "reading" | "executing";
fileIndex?: number;
fileName?: string;
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { formatSqlFileBytes, sqlFileProgressPercent } from "./sqlFileProgress";
describe("SQL file byte progress", () => {
it.each(["running", "statementDone", "statementFailed", "error", "cancelled", "Running", "Error", "Cancelled"])("uses bytes without reporting completion for %s", (status) => {
expect(sqlFileProgressPercent(status, 250, 1000)).toBe(25);
expect(sqlFileProgressPercent(status, 1000, 1000)).toBe(99);
expect(sqlFileProgressPercent(status, 1200, 1000)).toBe(99);
});
it.each(["done", "Done"])("completes empty files and legacy events only after %s", (status) => {
expect(sqlFileProgressPercent(status, 0, 0)).toBe(100);
expect(sqlFileProgressPercent(status)).toBe(100);
});
it("does not invent a percentage for missing or invalid totals", () => {
expect(sqlFileProgressPercent("running")).toBeNull();
expect(sqlFileProgressPercent("running", 50)).toBeNull();
expect(sqlFileProgressPercent("running", undefined, 100)).toBeNull();
expect(sqlFileProgressPercent("running", 0, 0)).toBeNull();
expect(sqlFileProgressPercent("running", -1, 100)).toBeNull();
expect(sqlFileProgressPercent("running", 1, Number.NaN)).toBeNull();
expect(sqlFileProgressPercent("running", Number.POSITIVE_INFINITY, 100)).toBeNull();
expect(sqlFileProgressPercent("running", 0, 100)).toBe(0);
});
it("formats byte counts for small and large files", () => {
expect(formatSqlFileBytes(0)).toBe("0 B");
expect(formatSqlFileBytes(512)).toBe("512 B");
expect(formatSqlFileBytes(1536)).toBe("1.5 KB");
expect(formatSqlFileBytes(5 * 1024 ** 3)).toBe("5.0 GB");
expect(formatSqlFileBytes(1024 ** 4)).toBe("1.0 TB");
});
});
@@ -0,0 +1,16 @@
export function sqlFileProgressPercent(status: string, bytesRead?: number, totalBytes?: number): number | null {
if (status.toLowerCase() === "done") return 100;
if (bytesRead === undefined || totalBytes === undefined || !Number.isFinite(bytesRead) || !Number.isFinite(totalBytes) || bytesRead < 0 || totalBytes <= 0) return null;
return Math.min(99, Math.floor((bytesRead / totalBytes) * 100));
}
export function formatSqlFileBytes(bytes: number): string {
const units = ["B", "KB", "MB", "GB", "TB"];
let value = Math.max(0, bytes);
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${unitIndex === 0 ? value : value.toFixed(1)} ${units[unitIndex]}`;
}