diff --git a/apps/desktop/src/components/export/ExportProgressPopover.vue b/apps/desktop/src/components/export/ExportProgressPopover.vue index b6073b79e0..7b5f71fdf9 100644 --- a/apps/desktop/src/components/export/ExportProgressPopover.vue +++ b/apps/desktop/src/components/export/ExportProgressPopover.vue @@ -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")}`; diff --git a/apps/desktop/src/components/export/__tests__/ExportProgressPopover.spec.ts b/apps/desktop/src/components/export/__tests__/ExportProgressPopover.spec.ts new file mode 100644 index 0000000000..c15b1065af --- /dev/null +++ b/apps/desktop/src/components/export/__tests__/ExportProgressPopover.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useExportTracker.spec.ts b/apps/desktop/src/composables/__tests__/useExportTracker.spec.ts new file mode 100644 index 0000000000..4c8c9deb64 --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useExportTracker.spec.ts @@ -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((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"); + }); +}); diff --git a/apps/desktop/src/composables/useExportTracker.ts b/apps/desktop/src/composables/useExportTracker.ts index da0b7e1799..6e48e06491 100644 --- a/apps/desktop/src/composables/useExportTracker.ts +++ b/apps/desktop/src/composables/useExportTracker.ts @@ -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; diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index dee4df77b6..78b0a030dd 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index c67285c796..cdc13c19a5 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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: { diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 9db331ee14..b57b1a577c 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 4fffab020b..f6dc0a8a21 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1144,6 +1144,7 @@ export default withEnglishFallback({ objectsCount: "{current} / {total} オブジェクト", tablesCount: "{current} / {total} テーブル", statementsCount: "{done}成功、{failed}失敗", + elapsed: "経過時間: {duration}", }, welcome: { title: "データベースワークスペース", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 96b3b681db..fb93b71fae 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index b7754d4266..17389f1486 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1203,6 +1203,7 @@ export default withEnglishFallback({ objectsCount: "{current} / {total} 个对象", tablesCount: "{current} / {total} 张表", statementsCount: "成功 {done},失败 {failed}", + elapsed: "耗时:{duration}", }, welcome: { title: "数据库工作台", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 4074a77aa9..b2e55fff3e 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1145,6 +1145,7 @@ export default withEnglishFallback({ objectsCount: "{current} / {total} 個物件", tablesCount: "{current} / {total} 張資料表", statementsCount: "{done} 成功,{failed} 失敗", + elapsed: "耗時:{duration}", }, welcome: { title: "資料庫工作檯",