mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-22 22:12:06 +00:00
feat(transfer): show elapsed time in task logs
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1144,6 +1144,7 @@ export default withEnglishFallback({
|
||||
objectsCount: "{current} / {total} オブジェクト",
|
||||
tablesCount: "{current} / {total} テーブル",
|
||||
statementsCount: "{done}成功、{failed}失敗",
|
||||
elapsed: "経過時間: {duration}",
|
||||
},
|
||||
welcome: {
|
||||
title: "データベースワークスペース",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1203,6 +1203,7 @@ export default withEnglishFallback({
|
||||
objectsCount: "{current} / {total} 个对象",
|
||||
tablesCount: "{current} / {total} 张表",
|
||||
statementsCount: "成功 {done},失败 {failed}",
|
||||
elapsed: "耗时:{duration}",
|
||||
},
|
||||
welcome: {
|
||||
title: "数据库工作台",
|
||||
|
||||
@@ -1145,6 +1145,7 @@ export default withEnglishFallback({
|
||||
objectsCount: "{current} / {total} 個物件",
|
||||
tablesCount: "{current} / {total} 張資料表",
|
||||
statementsCount: "{done} 成功,{failed} 失敗",
|
||||
elapsed: "耗時:{duration}",
|
||||
},
|
||||
welcome: {
|
||||
title: "資料庫工作檯",
|
||||
|
||||
Reference in New Issue
Block a user