feat(export): copy background task failure details
This commit is contained in:
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");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2200,6 +2200,9 @@ export default withEnglishFallback({
|
||||
hideFailureDetails: "失敗の詳細を閉じる",
|
||||
failureDetailTruncated: "エラー詳細は省略されました",
|
||||
failureDetailsOmitted: "ほか {count} 件の失敗詳細は表示されていません",
|
||||
copyFailureDetail: "失敗の詳細をコピー",
|
||||
failureDetailCopied: "失敗の詳細をコピーしました",
|
||||
failureDetailCopyFailed: "失敗の詳細をコピーできませんでした:{message}",
|
||||
multiDbExecutionTitle: "複数データベースで実行:{name}",
|
||||
multiDbTargets: "ターゲット {completed}/{total}、成功 {success}、失敗 {failed}",
|
||||
openTask: "タスクを開く",
|
||||
|
||||
@@ -2074,6 +2074,9 @@ export default withEnglishFallback({
|
||||
hideFailureDetails: "실패 상세 숨기기",
|
||||
failureDetailTruncated: "오류 상세가 잘렸습니다",
|
||||
failureDetailsOmitted: "추가 실패 상세 {count}개가 표시되지 않았습니다",
|
||||
copyFailureDetail: "실패 상세 복사",
|
||||
failureDetailCopied: "실패 상세를 복사했습니다",
|
||||
failureDetailCopyFailed: "실패 상세를 복사하지 못했습니다: {message}",
|
||||
multiDbExecutionTitle: "다중 데이터베이스 실행: {name}",
|
||||
multiDbTargets: "대상 {completed}/{total}, 성공 {success}, 실패 {failed}",
|
||||
openTask: "작업 열기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -2172,6 +2172,9 @@ export default withEnglishFallback({
|
||||
hideFailureDetails: "收起失败详情",
|
||||
failureDetailTruncated: "错误详情已截断",
|
||||
failureDetailsOmitted: "另有 {count} 条失败详情未显示",
|
||||
copyFailureDetail: "复制失败详情",
|
||||
failureDetailCopied: "失败详情已复制",
|
||||
failureDetailCopyFailed: "复制失败详情失败:{message}",
|
||||
multiDbExecutionTitle: "多库执行:{name}",
|
||||
multiDbTargets: "目标 {completed}/{total},成功 {success},失败 {failed}",
|
||||
openTask: "打开任务",
|
||||
|
||||
@@ -2173,6 +2173,9 @@ export default withEnglishFallback({
|
||||
hideFailureDetails: "收合失敗詳情",
|
||||
failureDetailTruncated: "錯誤詳情已截斷",
|
||||
failureDetailsOmitted: "另有 {count} 條失敗詳情未顯示",
|
||||
copyFailureDetail: "複製失敗詳情",
|
||||
failureDetailCopied: "失敗詳情已複製",
|
||||
failureDetailCopyFailed: "複製失敗詳情失敗:{message}",
|
||||
multiDbExecutionTitle: "多資料庫執行:{name}",
|
||||
multiDbTargets: "目標 {completed}/{total},成功 {success},失敗 {failed}",
|
||||
openTask: "開啟工作",
|
||||
|
||||
Reference in new issue
Block a user