feat(transfer): show elapsed time in task logs

This commit is contained in:
t8y2
2026-07-21 21:55:12 +08:00
parent 9a16b5958f
commit bbfbdf7ff5
11 changed files with 308 additions and 2 deletions
@@ -4,7 +4,7 @@ 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, DatabaseBackup, FileCode2, ArrowRightLeft } from "@lucide/vue";
import { useExportTracker, type ExportTask } from "@/composables/useExportTracker";
import { formatDataTransferDuration, useExportTracker, type ExportTask } from "@/composables/useExportTracker";
const { t } = useI18n();
const { tasks, activeCount, hasActive, clearFinished, cancelTask, removeTask } = useExportTracker();
@@ -84,7 +84,9 @@ const rowsText = (task: ExportTask) => {
})
: 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;
const finishedAt = task.finishedAt ?? Date.now();
const durationText = t("exportProgress.elapsed", { duration: formatDataTransferDuration(finishedAt - (task.startedAt ?? finishedAt)) });
return task.currentTable ? `${tableText} · ${task.currentTable} · ${rowText} · ${durationText}` : `${tableText} · ${durationText}`;
}
if (task.totalRows) return `${task.rowsExported.toLocaleString()} / ${task.totalRows.toLocaleString()}`;
return `${task.rowsExported.toLocaleString()} ${t("exportProgress.rowsShort")}`;
@@ -0,0 +1,94 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
vi.mock("@/components/ui/popover", async () => {
const { defineComponent, h } = await import("vue");
const passthrough = defineComponent({
setup(_props, { slots }) {
return () => h("div", slots.default?.());
},
});
return { Popover: passthrough, PopoverContent: passthrough, PopoverTrigger: passthrough };
});
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
setup(_props, { slots }) {
return () => h("button", slots.default?.());
},
}),
};
});
import ExportProgressPopover from "@/components/export/ExportProgressPopover.vue";
import { useExportTracker } from "@/composables/useExportTracker";
const mountedApps: App[] = [];
let now = 0;
function resetTracker() {
const tracker = useExportTracker();
for (const task of tracker.tasks.value) tracker.removeTask(task.exportId);
}
beforeEach(() => {
now = 0;
vi.spyOn(Date, "now").mockImplementation(() => now);
resetTracker();
i18n.global.locale.value = "en";
});
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
resetTracker();
vi.restoreAllMocks();
});
async function mountPopover() {
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup() {
return () => h(ExportProgressPopover);
},
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(container);
await nextTick();
}
describe("ExportProgressPopover data transfer duration", () => {
it("shows frozen transfer elapsed time without adding it to other task kinds", async () => {
const tracker = useExportTracker();
now = 1_000;
const transfer = tracker.addDataTransferTask("transfer", "users", 1);
now = 66_000;
tracker.updateDataTransferTask(transfer.exportId, {
transferId: transfer.exportId,
table: "users",
tableIndex: 1,
totalTables: 1,
rowsTransferred: 10,
totalRows: 10,
status: "done",
error: null,
terminal: true,
});
tracker.addTask("audit_log", "csv", "/tmp/audit_log.csv");
now = 120_000;
await mountPopover();
expect(document.body.textContent).toContain("Elapsed: 1m 5s");
expect(document.body.textContent?.match(/Elapsed:/g)).toHaveLength(1);
});
});
@@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TransferProgress, TransferRequest } from "@/lib/backend/api";
vi.mock("@/lib/backend/api", () => ({
startTransfer: vi.fn(),
cancelTransfer: vi.fn(),
cancelDatabaseExport: vi.fn(),
cancelSqlFileExecution: vi.fn(),
cancelTableExport: vi.fn(),
}));
import * as api from "@/lib/backend/api";
import { formatDataTransferDuration, useExportTracker } from "@/composables/useExportTracker";
let now = 0;
function transferRequest(transferId: string, tables = ["users"]): TransferRequest {
return {
transferId,
sourceConnectionId: "source",
sourceDatabase: "source_db",
sourceSchema: "public",
targetConnectionId: "target",
targetDatabase: "target_db",
targetSchema: "public",
tables,
createTable: true,
mode: "append",
targetTableNameCase: "preserve",
batchSize: 1000,
};
}
function transferProgress(transferId: string, status: TransferProgress["status"], terminal = true): TransferProgress {
return {
transferId,
table: "users",
tableIndex: 1,
totalTables: 1,
rowsTransferred: 10,
totalRows: 10,
status,
error: status === "error" ? "transfer failed" : null,
terminal,
};
}
function resetTracker() {
const tracker = useExportTracker();
for (const task of tracker.tasks.value) tracker.removeTask(task.exportId);
}
beforeEach(() => {
now = 0;
vi.spyOn(Date, "now").mockImplementation(() => now);
vi.clearAllMocks();
resetTracker();
});
afterEach(() => {
resetTracker();
vi.restoreAllMocks();
});
describe("data transfer task duration", () => {
it("freezes the first successful terminal duration", () => {
const tracker = useExportTracker();
now = 1_000;
const task = tracker.addDataTransferTask("success", "users", 1);
now = 4_500;
tracker.updateDataTransferTask(task.exportId, transferProgress(task.exportId, "done"));
now = 9_000;
tracker.updateDataTransferTask(task.exportId, transferProgress(task.exportId, "done"));
expect(task.startedAt).toBe(1_000);
expect(task.finishedAt).toBe(4_500);
expect(task.status).toBe("Done");
});
it.each([
["error", "Error"],
["cancelled", "Cancelled"],
] as const)("freezes %s terminal duration", (status, expectedStatus) => {
const tracker = useExportTracker();
now = 2_000;
const task = tracker.addDataTransferTask(status, "users", 1);
now = 7_250;
tracker.updateDataTransferTask(task.exportId, transferProgress(task.exportId, status));
expect(task.finishedAt! - task.startedAt!).toBe(5_250);
expect(task.status).toBe(expectedStatus);
});
it("tracks concurrent transfer durations independently", () => {
const tracker = useExportTracker();
now = 1_000;
const first = tracker.addDataTransferTask("first", "first", 1);
now = 2_000;
const second = tracker.addDataTransferTask("second", "second", 1);
now = 5_000;
tracker.updateDataTransferTask(first.exportId, transferProgress(first.exportId, "done"));
now = 8_000;
tracker.updateDataTransferTask(second.exportId, transferProgress(second.exportId, "cancelled"));
expect(first.finishedAt! - first.startedAt!).toBe(4_000);
expect(second.finishedAt! - second.startedAt!).toBe(6_000);
});
it("records an immediate start failure as a terminal duration", async () => {
vi.mocked(api.startTransfer).mockRejectedValueOnce(new Error("start failed"));
const tracker = useExportTracker();
now = 10_000;
const task = tracker.startDataTransferTask(transferRequest("start-failure"), "users");
now = 10_025;
await vi.waitFor(() => expect(task.status).toBe("Error"));
expect(task.finishedAt! - task.startedAt!).toBe(25);
expect(task.errorMessage).toBe("start failed");
});
it("freezes an overlapping transfer failure without starting another request", async () => {
let resolveFirst!: () => void;
vi.mocked(api.startTransfer).mockImplementationOnce(() => new Promise<void>((resolve) => (resolveFirst = resolve)));
const tracker = useExportTracker();
now = 100;
tracker.startDataTransferTask(transferRequest("active"), "active");
now = 130;
const overlapping = tracker.startDataTransferTask(transferRequest("overlap"), "overlap");
expect(overlapping.status).toBe("Error");
expect(overlapping.finishedAt! - overlapping.startedAt!).toBe(0);
expect(api.startTransfer).toHaveBeenCalledTimes(1);
resolveFirst();
await Promise.resolve();
});
it("keeps SQL-file backend elapsed time unchanged", () => {
const tracker = useExportTracker();
const task = tracker.addSqlFileTask("sql", "script.sql", "/tmp/script.sql");
tracker.updateSqlFileTask(task.exportId, {
executionId: task.exportId,
status: "done",
statementIndex: 1,
successCount: 1,
failureCount: 0,
affectedRows: 3,
elapsedMs: 1_234,
statementSummary: "SELECT 1",
error: null,
});
expect(task.elapsedMs).toBe(1_234);
expect(task.startedAt).toBeUndefined();
expect(task.finishedAt).toBeUndefined();
});
});
describe("formatDataTransferDuration", () => {
it("formats millisecond, second, minute, and hour boundaries", () => {
expect(formatDataTransferDuration(Number.NaN)).toBe("0 ms");
expect(formatDataTransferDuration(-1)).toBe("0 ms");
expect(formatDataTransferDuration(999)).toBe("999 ms");
expect(formatDataTransferDuration(1_000)).toBe("1.0 s");
expect(formatDataTransferDuration(59_999)).toBe("59.9 s");
expect(formatDataTransferDuration(60_000)).toBe("1m 0s");
expect(formatDataTransferDuration(60_999)).toBe("1m 0s");
expect(formatDataTransferDuration(3_599_999)).toBe("59m 59s");
expect(formatDataTransferDuration(3_600_000)).toBe("1h 0m 0s");
expect(formatDataTransferDuration(3_661_000)).toBe("1h 1m 1s");
});
});
@@ -22,6 +22,8 @@ export interface ExportTask {
failureCount?: number;
affectedRows?: number;
elapsedMs?: number;
startedAt?: number;
finishedAt?: number;
statementSummary?: string;
tableIndex?: number;
totalTables?: number;
@@ -55,6 +57,26 @@ function normalizeTransferStatus(status: api.TransferProgress["status"], termina
return "Running";
}
function finishDataTransferTask(task: ExportTask) {
// Preserve the first terminal timestamp so later completion events cannot change the displayed duration.
task.finishedAt ??= Date.now();
}
export function formatDataTransferDuration(elapsedMs: number): string {
const safeElapsedMs = Math.max(0, Number.isFinite(elapsedMs) ? Math.round(elapsedMs) : 0);
if (safeElapsedMs < 1000) return `${safeElapsedMs} ms`;
if (safeElapsedMs < 60_000) return `${(Math.floor(safeElapsedMs / 100) / 10).toFixed(1)} s`;
const totalSeconds = Math.floor(safeElapsedMs / 1000);
const seconds = totalSeconds % 60;
const totalMinutes = Math.floor(totalSeconds / 60);
if (totalMinutes < 60) return `${totalMinutes}m ${seconds}s`;
const hours = Math.floor(totalMinutes / 60);
return `${hours}h ${totalMinutes % 60}m ${seconds}s`;
}
function targetTableName(table: string, nameCase: api.TransferTableNameCase): string {
if (nameCase === "lower") return table.toLowerCase();
if (nameCase === "upper") return table.toUpperCase();
@@ -167,6 +189,7 @@ export function useExportTracker() {
tableIndex: 0,
totalTables,
currentTable: "",
startedAt: Date.now(),
});
taskMap.set(transferId, task);
return task;
@@ -182,6 +205,7 @@ export function useExportTracker() {
): ExportTask {
const existingTask = taskMap.get(request.transferId);
const task = existingTask ?? addDataTransferTask(request.transferId, label, request.tables.length);
task.startedAt ??= Date.now();
task.targetConnectionId = request.targetConnectionId;
task.targetDatabase = request.targetDatabase;
task.targetSchema = request.targetSchema;
@@ -193,6 +217,7 @@ export function useExportTracker() {
task.status = "Error";
const visibleTables = overlappingTables.slice(0, 5);
task.errorMessage = options.formatOverlapError?.(visibleTables) ?? `Another data transfer is already running for target table(s): ${visibleTables.join(", ")}`;
finishDataTransferTask(task);
return task;
}
@@ -272,6 +297,7 @@ export function useExportTracker() {
const nextStatus = normalizeTransferStatus(progress.status, progress.terminal);
const hadError = task.status === "Error";
task.status = hadError && nextStatus === "Done" ? "Error" : nextStatus;
if (isTerminalTransferProgress(progress)) finishDataTransferTask(task);
task.errorMessage = progress.error || task.errorMessage || null;
task.tableIndex = progress.tableIndex;
task.totalTables = progress.totalTables;
+1
View File
@@ -1203,6 +1203,7 @@ export default {
objectsCount: "{current} / {total} objects",
tablesCount: "{current} / {total} tables",
statementsCount: "{done} succeeded, {failed} failed",
elapsed: "Elapsed: {duration}",
},
welcome: {
title: "Database Workspace",
+1
View File
@@ -1144,6 +1144,7 @@ export default withEnglishFallback({
objectsCount: "{current} / {total} objetos",
tablesCount: "{current} / {total} tablas",
statementsCount: "{done} exitosas, {failed} fallidas",
elapsed: "Transcurrido: {duration}",
delete: "Eliminar",
},
welcome: {
+1
View File
@@ -1143,6 +1143,7 @@ export default withEnglishFallback({
objectsCount: "{current} / {total} oggetti",
tablesCount: "{current} / {total} tabelle",
statementsCount: "{done} riuscite, {failed} non riuscite",
elapsed: "Tempo trascorso: {duration}",
},
welcome: {
title: "Area di Lavoro Database",
+1
View File
@@ -1144,6 +1144,7 @@ export default withEnglishFallback({
objectsCount: "{current} / {total} オブジェクト",
tablesCount: "{current} / {total} テーブル",
statementsCount: "{done}成功、{failed}失敗",
elapsed: "経過時間: {duration}",
},
welcome: {
title: "データベースワークスペース",
+1
View File
@@ -1145,6 +1145,7 @@ export default withEnglishFallback({
objectsCount: "{current} / {total} objetos",
tablesCount: "{current} / {total} tabelas",
statementsCount: "{done} com sucesso, {failed} com falha",
elapsed: "Decorrido: {duration}",
},
welcome: {
title: "Área de Trabalho do Banco de Dados",
+1
View File
@@ -1203,6 +1203,7 @@ export default withEnglishFallback({
objectsCount: "{current} / {total} 个对象",
tablesCount: "{current} / {total} 张表",
statementsCount: "成功 {done},失败 {failed}",
elapsed: "耗时:{duration}",
},
welcome: {
title: "数据库工作台",
+1
View File
@@ -1145,6 +1145,7 @@ export default withEnglishFallback({
objectsCount: "{current} / {total} 個物件",
tablesCount: "{current} / {total} 張資料表",
statementsCount: "{done} 成功,{failed} 失敗",
elapsed: "耗時:{duration}",
},
welcome: {
title: "資料庫工作檯",