feat(export): copy background task failure details

This commit is contained in:
Guoyu Su authored and GitHub committed 2026-09-04 20:42:12 +08:00
1 parent 686c347782
commit 08917ba54a
12 files changed
+308 -5

No files matched your search

@@ -3,11 +3,13 @@ import { computed, onBeforeUnmount, onMounted, 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, DatabaseBackup, FileCode2, ArrowRightLeft, Layers3, ChevronRight, FolderOpen } from "@lucide/vue";
import { Loader2, Check, CheckCircle2, XCircle, AlertCircle, X, FileDown, DatabaseBackup, FileCode2, ArrowRightLeft, Layers3, ChevronRight, FolderOpen, Copy } from "@lucide/vue";
import { formatDataTransferDuration, useExportTracker, type ExportTask } from "@/composables/useExportTracker";
import { dataTransferFailureCopyText, sqlFileFailureCopyText } from "@/components/export/failureDetailCopyText";
import { translateBackendError } from "@/i18n/backend-errors";
import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { copyToClipboard } from "@/lib/common/clipboard";
import * as api from "@/lib/backend/api";
const { t } = useI18n();
@@ -17,17 +19,22 @@ const open = ref(false);
const showAll = ref(false);
const expandedFailureTaskIds = ref<string[]>([]);
const revealingTaskIds = ref<string[]>([]);
const copiedFailureDetailKey = ref("");
const currentTime = ref(Date.now());
const MAX_VISIBLE = 5;
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
let copiedFailureDetailTimer: ReturnType<typeof setTimeout> | undefined;
let unmounted = false;
onMounted(() => {
elapsedTimer = setInterval(() => {
currentTime.value = Date.now();
}, 1000);
});
onBeforeUnmount(() => {
unmounted = true;
if (elapsedTimer) clearInterval(elapsedTimer);
if (copiedFailureDetailTimer) clearTimeout(copiedFailureDetailTimer);
});
const reversedTasks = computed(() => {
@@ -243,6 +250,31 @@ function hasUnlistedTaskError(task: ExportTask) {
return true;
}
function dataTransferFailureDetailKey(taskId: string, table: string): string {
return `data-transfer:${taskId}:${table}`;
}
function sqlFileFailureDetailKey(taskId: string, fileIndex: number | undefined, statementIndex: number): string {
return `sql-file:${taskId}:${fileIndex ?? -1}:${statementIndex}`;
}
async function copyFailureDetail(text: string, key: string): Promise<void> {
try {
await copyToClipboard(text);
if (unmounted) return;
copiedFailureDetailKey.value = key;
if (copiedFailureDetailTimer) clearTimeout(copiedFailureDetailTimer);
copiedFailureDetailTimer = setTimeout(() => {
if (copiedFailureDetailKey.value === key) copiedFailureDetailKey.value = "";
copiedFailureDetailTimer = undefined;
}, 2000);
toast(t("exportProgress.failureDetailCopied"));
} catch (error: unknown) {
if (unmounted) return;
toast(t("exportProgress.failureDetailCopyFailed", { message: translateBackendError(t, error) }), 5000);
}
}
function openTask(task: ExportTask): void {
task.onOpen?.();
}
@@ -307,7 +339,19 @@ function openTask(task: ExportTask): void {
<div v-if="failureDetailsExpanded(task.exportId)" class="mt-1.5 max-h-44 overflow-y-auto rounded border border-destructive/20 bg-destructive/5">
<template v-if="task.kind === 'data-transfer'">
<div v-for="failure in task.transferFailures" :key="failure.table" class="border-b border-destructive/15 px-2.5 py-2 last:border-b-0">
<div class="break-all font-mono font-medium text-foreground">{{ failure.table }}</div>
<div class="flex min-w-0 items-start justify-between gap-2">
<div class="min-w-0 break-all font-mono font-medium text-foreground">{{ failure.table }}</div>
<button
type="button"
class="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-muted"
:title="copiedFailureDetailKey === dataTransferFailureDetailKey(task.exportId, failure.table) ? t('exportProgress.failureDetailCopied') : t('exportProgress.copyFailureDetail')"
:aria-label="copiedFailureDetailKey === dataTransferFailureDetailKey(task.exportId, failure.table) ? t('exportProgress.failureDetailCopied') : t('exportProgress.copyFailureDetail')"
@click.stop="copyFailureDetail(dataTransferFailureCopyText(failure), dataTransferFailureDetailKey(task.exportId, failure.table))"
>
<Check v-if="copiedFailureDetailKey === dataTransferFailureDetailKey(task.exportId, failure.table)" class="h-3.5 w-3.5 text-green-500" aria-hidden="true" />
<Copy v-else class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" aria-hidden="true" />
</button>
</div>
<div class="mt-0.5 select-text whitespace-pre-wrap break-words text-destructive">{{ failure.error }}</div>
<div v-if="failure.truncated" class="mt-0.5 text-muted-foreground">{{ t("exportProgress.failureDetailTruncated") }}</div>
</div>
@@ -315,9 +359,21 @@ function openTask(task: ExportTask): void {
</template>
<template v-else>
<div v-for="failure in task.sqlFileFailures" :key="`${failure.fileIndex ?? -1}:${failure.statementIndex}`" class="border-b border-destructive/15 px-2.5 py-2 last:border-b-0">
<div class="flex min-w-0 items-center gap-1.5 font-medium text-foreground">
<span class="shrink-0">#{{ failure.statementIndex }}</span>
<span v-if="failure.fileName" class="truncate text-muted-foreground" :title="failure.fileName">{{ failure.fileName }}</span>
<div class="flex min-w-0 items-start justify-between gap-2">
<div class="flex min-w-0 items-center gap-1.5 font-medium text-foreground">
<span class="shrink-0">#{{ failure.statementIndex }}</span>
<span v-if="failure.fileName" class="truncate text-muted-foreground" :title="failure.fileName">{{ failure.fileName }}</span>
</div>
<button
type="button"
class="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-muted"
:title="copiedFailureDetailKey === sqlFileFailureDetailKey(task.exportId, failure.fileIndex, failure.statementIndex) ? t('exportProgress.failureDetailCopied') : t('exportProgress.copyFailureDetail')"
:aria-label="copiedFailureDetailKey === sqlFileFailureDetailKey(task.exportId, failure.fileIndex, failure.statementIndex) ? t('exportProgress.failureDetailCopied') : t('exportProgress.copyFailureDetail')"
@click.stop="copyFailureDetail(sqlFileFailureCopyText(failure, translateBackendError(t, failure.error)), sqlFileFailureDetailKey(task.exportId, failure.fileIndex, failure.statementIndex))"
>
<Check v-if="copiedFailureDetailKey === sqlFileFailureDetailKey(task.exportId, failure.fileIndex, failure.statementIndex)" class="h-3.5 w-3.5 text-green-500" aria-hidden="true" />
<Copy v-else class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" aria-hidden="true" />
</button>
</div>
<div v-if="failure.statementSummary" class="mt-0.5 select-text whitespace-pre-wrap break-words font-mono text-foreground">{{ failure.statementSummary }}</div>
<div class="mt-0.5 select-text whitespace-pre-wrap break-words text-destructive">{{ translateBackendError(t, failure.error) }}</div>
@@ -33,10 +33,16 @@ vi.mock("@/lib/backend/api", async (importOriginal) => {
});
import ExportProgressPopover from "@/components/export/ExportProgressPopover.vue";
import { dataTransferFailureCopyText, sqlFileFailureCopyText } from "@/components/export/failureDetailCopyText";
import { useExportTracker } from "@/composables/useExportTracker";
import { useToast } from "@/composables/useToast";
import { copyToClipboard } from "@/lib/common/clipboard";
import * as api from "@/lib/backend/api";
vi.mock("@/lib/common/clipboard", () => ({
copyToClipboard: vi.fn(),
}));
const mountedApps: App[] = [];
let now = 0;
@@ -50,12 +56,15 @@ beforeEach(() => {
vi.spyOn(Date, "now").mockImplementation(() => now);
resetTracker();
i18n.global.locale.value = "en";
vi.mocked(copyToClipboard).mockReset();
vi.mocked(copyToClipboard).mockResolvedValue(undefined);
});
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
resetTracker();
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -75,6 +84,16 @@ async function mountPopover() {
await nextTick();
}
describe("failure detail copy text", () => {
it("formats a data-transfer failure from its authoritative fields", () => {
expect(dataTransferFailureCopyText({ table: "users", error: "permission denied" })).toBe("users\npermission denied");
});
it("includes available SQL context without adding lines for missing fields", () => {
expect(sqlFileFailureCopyText({ statementIndex: 2, fileName: "batch.sql", statementSummary: "", error: "raw error" }, "syntax error")).toBe("#2 batch.sql\nsyntax error");
});
});
describe("ExportProgressPopover task duration", () => {
it("distinguishes manual exports from scheduled backups and uses a compact failure dot", async () => {
const tracker = useExportTracker();
@@ -215,6 +234,7 @@ describe("ExportProgressPopover task duration", () => {
});
it("expands complete per-table transfer failure details", async () => {
vi.useFakeTimers();
const tracker = useExportTracker();
const task = tracker.addDataTransferTask("failed-transfer", "source to target", 2);
tracker.updateDataTransferTask(task.exportId, {
@@ -252,6 +272,62 @@ describe("ExportProgressPopover task duration", () => {
expect(detailsButton?.getAttribute("aria-expanded")).toBe("true");
expect(document.body.textContent).toContain("users");
expect(document.body.textContent).toContain("permission denied for relation users");
const copyButton = document.body.querySelector<HTMLButtonElement>('button[title="Copy failure details"]');
expect(copyButton?.getAttribute("aria-label")).toBe("Copy failure details");
copyButton?.click();
await Promise.resolve();
await nextTick();
expect(copyToClipboard).toHaveBeenCalledWith("users\npermission denied for relation users");
expect(useToast().message.value).toBe("Failure details copied");
expect(copyButton?.title).toBe("Failure details copied");
expect(copyButton?.getAttribute("aria-label")).toBe("Failure details copied");
expect(copyButton?.querySelector("svg")?.classList.contains("text-green-500")).toBe(true);
vi.advanceTimersByTime(2000);
await nextTick();
expect(copyButton?.title).toBe("Copy failure details");
expect(copyButton?.getAttribute("aria-label")).toBe("Copy failure details");
expect(copyButton?.querySelector("svg")?.classList.contains("text-green-500")).toBe(false);
});
it("moves copied feedback between same-named failures from different tasks", async () => {
const tracker = useExportTracker();
for (const [taskId, error] of [
["first-transfer", "first error"],
["second-transfer", "second error"],
] as const) {
const task = tracker.addDataTransferTask(taskId, "source to target", 1);
tracker.updateDataTransferTask(task.exportId, {
transferId: task.exportId,
table: "users",
tableIndex: 0,
totalTables: 1,
rowsTransferred: 0,
totalRows: null,
status: "error",
error,
terminal: false,
});
}
await mountPopover();
for (const detailsButton of document.body.querySelectorAll<HTMLButtonElement>('button[aria-expanded="false"]')) detailsButton.click();
await nextTick();
const copyButtons = document.body.querySelectorAll<HTMLButtonElement>('button[title="Copy failure details"]');
expect(copyButtons).toHaveLength(2);
copyButtons[0]?.click();
await vi.waitFor(() => expect(copyButtons[0]?.title).toBe("Failure details copied"));
expect(copyButtons[1]?.title).toBe("Copy failure details");
copyButtons[1]?.click();
await vi.waitFor(() => expect(copyButtons[1]?.title).toBe("Failure details copied"));
expect(copyButtons[0]?.title).toBe("Copy failure details");
});
it("shows the total failure count and omitted-detail notice when the bounded list is full", async () => {
@@ -319,6 +395,140 @@ describe("ExportProgressPopover task duration", () => {
expect(document.body.textContent).toContain("#7");
expect(document.body.textContent).toContain("ALTER TABLE users ADD missing_type");
expect(document.body.textContent).toContain("type missing_type does not exist");
const copyButton = document.body.querySelector<HTMLButtonElement>('button[title="Copy failure details"]');
copyButton?.click();
await vi.waitFor(() => {
expect(copyToClipboard).toHaveBeenCalledWith("#7\nALTER TABLE users ADD missing_type\ntype missing_type does not exist");
expect(copyButton?.title).toBe("Failure details copied");
expect(copyButton?.getAttribute("aria-label")).toBe("Failure details copied");
expect(copyButton?.querySelector("svg")?.classList.contains("text-green-500")).toBe(true);
});
});
it("includes the SQL file name and omits a missing statement summary without an empty line", async () => {
const tracker = useExportTracker();
const task = tracker.addSqlFileTask("sql-file-name-copy", "batch.sql", "/tmp/batch.sql");
tracker.updateSqlFileTask(
task.exportId,
{
executionId: task.exportId,
status: "statementFailed",
statementIndex: 2,
successCount: 1,
failureCount: 1,
affectedRows: 1,
elapsedMs: 10,
statementSummary: "",
error: "syntax error",
},
{ fileIndex: 0, fileName: "batch.sql" },
);
await mountPopover();
document.body.querySelector<HTMLButtonElement>('button[aria-expanded="false"]')?.click();
await nextTick();
document.body.querySelector<HTMLButtonElement>('button[title="Copy failure details"]')?.click();
await vi.waitFor(() => {
expect(copyToClipboard).toHaveBeenCalledWith("#2 batch.sql\nsyntax error");
});
});
it("shows a localized error when copying failure details fails", async () => {
vi.mocked(copyToClipboard).mockRejectedValueOnce(new Error("clipboard unavailable"));
const tracker = useExportTracker();
const task = tracker.addDataTransferTask("copy-failed-transfer", "source to target", 1);
tracker.updateDataTransferTask(task.exportId, {
transferId: task.exportId,
table: "audit_log",
tableIndex: 0,
totalTables: 1,
rowsTransferred: 0,
totalRows: null,
status: "error",
error: "permission denied",
terminal: false,
});
await mountPopover();
document.body.querySelector<HTMLButtonElement>('button[aria-expanded="false"]')?.click();
await nextTick();
document.body.querySelector<HTMLButtonElement>('button[title="Copy failure details"]')?.click();
await vi.waitFor(() => {
expect(useToast().message.value).toBe("Failed to copy failure details: clipboard unavailable");
});
expect(document.body.querySelector('button[title="Failure details copied"]')).toBeNull();
});
it("clears the copied feedback timer when the component unmounts", async () => {
vi.useFakeTimers();
const tracker = useExportTracker();
const task = tracker.addDataTransferTask("unmount-copy-feedback", "source to target", 1);
tracker.updateDataTransferTask(task.exportId, {
transferId: task.exportId,
table: "users",
tableIndex: 0,
totalTables: 1,
rowsTransferred: 0,
totalRows: null,
status: "error",
error: "permission denied",
terminal: false,
});
await mountPopover();
document.body.querySelector<HTMLButtonElement>('button[aria-expanded="false"]')?.click();
await nextTick();
document.body.querySelector<HTMLButtonElement>('button[title="Copy failure details"]')?.click();
await Promise.resolve();
await nextTick();
const timerCountBeforeUnmount = vi.getTimerCount();
expect(timerCountBeforeUnmount).toBeGreaterThanOrEqual(2);
mountedApps.pop()?.unmount();
expect(vi.getTimerCount()).toBe(timerCountBeforeUnmount - 2);
});
it("does not start copied feedback after unmount while the clipboard write is pending", async () => {
vi.useFakeTimers();
let resolveCopy: (() => void) | undefined;
vi.mocked(copyToClipboard).mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveCopy = resolve;
}),
);
const tracker = useExportTracker();
const task = tracker.addDataTransferTask("pending-unmount-copy-feedback", "source to target", 1);
tracker.updateDataTransferTask(task.exportId, {
transferId: task.exportId,
table: "users",
tableIndex: 0,
totalTables: 1,
rowsTransferred: 0,
totalRows: null,
status: "error",
error: "permission denied",
terminal: false,
});
await mountPopover();
document.body.querySelector<HTMLButtonElement>('button[aria-expanded="false"]')?.click();
await nextTick();
document.body.querySelector<HTMLButtonElement>('button[title="Copy failure details"]')?.click();
expect(vi.getTimerCount()).toBe(1);
mountedApps.pop()?.unmount();
expect(vi.getTimerCount()).toBe(0);
resolveCopy?.();
await Promise.resolve();
await nextTick();
expect(vi.getTimerCount()).toBe(0);
});
});
@@ -0,0 +1,10 @@
import type { DataTransferFailure, SqlFileFailure } from "@/composables/useExportTracker";
export function dataTransferFailureCopyText(failure: DataTransferFailure): string {
return `${failure.table}\n${failure.error}`;
}
export function sqlFileFailureCopyText(failure: SqlFileFailure, translatedError: string): string {
const heading = `#${failure.statementIndex}${failure.fileName ? ` ${failure.fileName}` : ""}`;
return [heading, failure.statementSummary || undefined, translatedError].filter((line): line is string => line !== undefined).join("\n");
}
+3
View File
@@ -2252,6 +2252,9 @@ export default {
hideFailureDetails: "Hide failure details",
failureDetailTruncated: "Error details were truncated",
failureDetailsOmitted: "{count} additional failure detail(s) not shown",
copyFailureDetail: "Copy failure details",
failureDetailCopied: "Failure details copied",
failureDetailCopyFailed: "Failed to copy failure details: {message}",
},
welcome: {
title: "Database Workspace",
+3
View File
@@ -2178,6 +2178,9 @@ export default withEnglishFallback({
hideFailureDetails: "Ocultar detalles de fallos",
failureDetailTruncated: "Los detalles del error se han truncado",
failureDetailsOmitted: "No se muestran {count} detalles de fallos adicionales",
copyFailureDetail: "Copiar detalles del fallo",
failureDetailCopied: "Detalles del fallo copiados",
failureDetailCopyFailed: "No se pudieron copiar los detalles del fallo: {message}",
},
welcome: {
title: "Espacio de trabajo de base de datos",
+3
View File
@@ -2176,6 +2176,9 @@ export default withEnglishFallback({
hideFailureDetails: "Nascondi dettagli errori",
failureDetailTruncated: "I dettagli dell'errore sono stati troncati",
failureDetailsOmitted: "Altri {count} dettagli di errore non sono visualizzati",
copyFailureDetail: "Copia dettagli dell'errore",
failureDetailCopied: "Dettagli dell'errore copiati",
failureDetailCopyFailed: "Impossibile copiare i dettagli dell'errore: {message}",
},
welcome: {
title: "Area di Lavoro Database",
+3
View File
@@ -2200,6 +2200,9 @@ export default withEnglishFallback({
hideFailureDetails: "失敗の詳細を閉じる",
failureDetailTruncated: "エラー詳細は省略されました",
failureDetailsOmitted: "ほか {count} 件の失敗詳細は表示されていません",
copyFailureDetail: "失敗の詳細をコピー",
failureDetailCopied: "失敗の詳細をコピーしました",
failureDetailCopyFailed: "失敗の詳細をコピーできませんでした:{message}",
multiDbExecutionTitle: "複数データベースで実行:{name}",
multiDbTargets: "ターゲット {completed}/{total}、成功 {success}、失敗 {failed}",
openTask: "タスクを開く",
+3
View File
@@ -2074,6 +2074,9 @@ export default withEnglishFallback({
hideFailureDetails: "실패 상세 숨기기",
failureDetailTruncated: "오류 상세가 잘렸습니다",
failureDetailsOmitted: "추가 실패 상세 {count}개가 표시되지 않았습니다",
copyFailureDetail: "실패 상세 복사",
failureDetailCopied: "실패 상세를 복사했습니다",
failureDetailCopyFailed: "실패 상세를 복사하지 못했습니다: {message}",
multiDbExecutionTitle: "다중 데이터베이스 실행: {name}",
multiDbTargets: "대상 {completed}/{total}, 성공 {success}, 실패 {failed}",
openTask: "작업 열기",
+3
View File
@@ -2175,6 +2175,9 @@ export default withEnglishFallback({
hideFailureDetails: "Ocultar detalhes das falhas",
failureDetailTruncated: "Os detalhes do erro foram truncados",
failureDetailsOmitted: "{count} detalhes adicionais de falha não exibidos",
copyFailureDetail: "Copiar detalhes da falha",
failureDetailCopied: "Detalhes da falha copiados",
failureDetailCopyFailed: "Falha ao copiar os detalhes da falha: {message}",
multiDbExecutionTitle: "Execução em vários bancos de dados: {name}",
multiDbTargets: "Destinos {completed}/{total}, sucesso {success}, falha {failed}",
openTask: "Abrir tarefa",
+3
View File
@@ -2252,6 +2252,9 @@ export default withEnglishFallback({
hideFailureDetails: "Hata ayrıntılarını gizle",
failureDetailTruncated: "Hata ayrıntıları kısaltıldı",
failureDetailsOmitted: "Gösterilmeyen {count} ek hata ayrıntısı var",
copyFailureDetail: "Hata ayrıntılarını kopyala",
failureDetailCopied: "Hata ayrıntıları kopyalandı",
failureDetailCopyFailed: "Hata ayrıntıları kopyalanamadı: {message}",
},
welcome: {
title: "Veritabanı Çalışma Alanı",
+3
View File
@@ -2172,6 +2172,9 @@ export default withEnglishFallback({
hideFailureDetails: "收起失败详情",
failureDetailTruncated: "错误详情已截断",
failureDetailsOmitted: "另有 {count} 条失败详情未显示",
copyFailureDetail: "复制失败详情",
failureDetailCopied: "失败详情已复制",
failureDetailCopyFailed: "复制失败详情失败:{message}",
multiDbExecutionTitle: "多库执行:{name}",
multiDbTargets: "目标 {completed}/{total},成功 {success},失败 {failed}",
openTask: "打开任务",
+3
View File
@@ -2173,6 +2173,9 @@ export default withEnglishFallback({
hideFailureDetails: "收合失敗詳情",
failureDetailTruncated: "錯誤詳情已截斷",
failureDetailsOmitted: "另有 {count} 條失敗詳情未顯示",
copyFailureDetail: "複製失敗詳情",
failureDetailCopied: "失敗詳情已複製",
failureDetailCopyFailed: "複製失敗詳情失敗:{message}",
multiDbExecutionTitle: "多資料庫執行:{name}",
multiDbTargets: "目標 {completed}/{total},成功 {success},失敗 {failed}",
openTask: "開啟工作",