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");
}