feat(sql-file): show per-file execution statistics

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-07-30 18:41:56 +08:00
1 parent 51315e549f
commit 6c3c6864f3
14 files changed
+496 -26

No files matched your search

@@ -0,0 +1,208 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
addSqlFileTask: vi.fn(),
cancelSqlFileExecution: vi.fn(),
ensureConnected: vi.fn(),
executeSqlFiles: vi.fn(),
fetchSqlFileTargetOptions: vi.fn(),
listenSqlFileProgress: vi.fn(),
openFileDialog: vi.fn(),
previewSqlFile: vi.fn(),
progressHandler: undefined as undefined | ((progress: Record<string, unknown>) => void),
refreshDatabaseTreeNode: vi.fn(),
requestConfirmation: vi.fn(),
toast: vi.fn(),
unlisten: vi.fn(),
updateSqlFileTask: vi.fn(),
uuid: vi.fn(),
}));
function passthrough(tag: string) {
return defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h(tag, attrs, slots.default?.());
},
});
}
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/lib/common/utils", () => ({ uuid: mocks.uuid }));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.openFileDialog }));
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: (sql: string) => sql }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/composables/useExportTracker", () => ({
useExportTracker: () => ({ addSqlFileTask: mocks.addSqlFileTask, updateSqlFileTask: mocks.updateSqlFileTask }),
}));
vi.mock("@/composables/useDatabaseOptions", () => ({ fetchSqlFileTargetOptions: mocks.fetchSqlFileTargetOptions }));
vi.mock("@/lib/connection/connectionLevelDatabaseBootstrap", () => ({ requiresSqlFileTargetDatabaseSelection: () => false }));
vi.mock("@/lib/database/productionSafety", () => ({ productionContextForDatabase: () => ({ active: false, databases: [] }) }));
vi.mock("@/stores/productionSafetyStore", () => ({
useProductionSafetyStore: () => ({ requestConfirmation: mocks.requestConfirmation }),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
connections: [{ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql" }],
ensureConnected: mocks.ensureConnected,
getConfig: () => ({ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql", database: "" }),
refreshDatabaseTreeNode: mocks.refreshDatabaseTreeNode,
}),
}));
vi.mock("@/lib/backend/api", () => ({
cancelSqlFileExecution: mocks.cancelSqlFileExecution,
executeSqlFiles: mocks.executeSqlFiles,
listenSqlFileProgress: mocks.listenSqlFileProgress,
previewSqlFile: mocks.previewSqlFile,
}));
vi.mock("@lucide/vue", () => {
const Icon = passthrough("span");
return { Check: Icon, CheckSquare: Icon, FileCode: Icon, FolderOpen: Icon, Loader2: Icon, Play: Icon, Square: Icon, X: Icon };
});
vi.mock("@/components/ui/dialog", () => ({
Dialog: passthrough("div"),
DialogFooter: passthrough("div"),
DialogHeader: passthrough("div"),
DialogScrollContent: passthrough("div"),
DialogTitle: passthrough("div"),
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: passthrough("div"),
TooltipContent: passthrough("div"),
TooltipTrigger: passthrough("div"),
}));
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
vi.mock("@/components/ui/input", () => ({ Input: passthrough("input") }));
vi.mock("@/components/ui/label", () => ({ Label: passthrough("label") }));
vi.mock("@/components/ui/select", () => ({
Select: passthrough("div"),
SelectContent: passthrough("div"),
SelectItem: passthrough("div"),
SelectTrigger: passthrough("div"),
SelectValue: passthrough("span"),
}));
vi.mock("@/components/icons/DatabaseIcon.vue", () => ({ default: passthrough("span") }));
vi.mock("@/components/connection/ConnectionGroupBadge.vue", () => ({ default: passthrough("span") }));
import SqlFileExecutionDialog from "./SqlFileExecutionDialog.vue";
let app: ReturnType<typeof createApp> | undefined;
let root: HTMLDivElement | undefined;
function progress(executionId: string, status: string, overrides: Record<string, unknown> = {}) {
return {
executionId,
status,
statementIndex: 0,
successCount: 0,
failureCount: 0,
affectedRows: 0,
elapsedMs: 10,
statementSummary: "",
...overrides,
};
}
function findButton(label: string): HTMLButtonElement {
const button = Array.from(root!.querySelectorAll("button")).find((item) => item.textContent?.trim().endsWith(label));
expect(button, `button ${label}`).toBeDefined();
return button!;
}
async function mountReadyDialog() {
root = document.createElement("div");
document.body.append(root);
app = createApp(SqlFileExecutionDialog, { open: true });
app.mount(root);
await vi.waitFor(() => expect(mocks.fetchSqlFileTargetOptions).toHaveBeenCalled());
findButton("sqlFile.browse").click();
await vi.waitFor(() => expect(mocks.previewSqlFile).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
}
async function completeFirstExecution() {
mocks.executeSqlFiles.mockImplementationOnce(async (request: { executionId: string }) => {
mocks.progressHandler?.(progress(request.executionId, "running", { fileIndex: 0, fileName: "first.sql" }));
mocks.progressHandler?.(progress(request.executionId, "statementDone", { fileIndex: 0, fileName: "first.sql", statementIndex: 1, successCount: 1 }));
mocks.progressHandler?.(progress(request.executionId, "running", { fileIndex: 1, fileName: "second.sql", statementIndex: 1, successCount: 1 }));
mocks.progressHandler?.(progress(request.executionId, "statementDone", { fileIndex: 1, fileName: "second.sql", statementIndex: 1, successCount: 1, affectedRows: 2 }));
mocks.progressHandler?.(progress(request.executionId, "done", { statementIndex: 2, successCount: 2, affectedRows: 2 }));
});
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(root!.querySelector("table")).not.toBeNull());
expect(root!.querySelector("table")!.textContent).toContain("first.sql");
expect(root!.querySelector("table")!.textContent).toContain("second.sql");
}
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
beforeEach(() => {
vi.clearAllMocks();
mocks.progressHandler = undefined;
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.fetchSqlFileTargetOptions.mockResolvedValue([]);
mocks.openFileDialog.mockResolvedValue(["/tmp/first.sql", "/tmp/second.sql"]);
mocks.previewSqlFile.mockImplementation(async (filePath: string) => ({
fileName: filePath.split("/").pop()!,
filePath,
sizeBytes: 9,
preview: "select 1;",
canExecuteWithoutSelectedDatabase: true,
}));
mocks.listenSqlFileProgress.mockImplementation((handler: (event: Record<string, unknown>) => void) => {
mocks.progressHandler = handler;
return mocks.unlisten;
});
mocks.cancelSqlFileExecution.mockResolvedValue(true);
mocks.refreshDatabaseTreeNode.mockResolvedValue(undefined);
mocks.requestConfirmation.mockResolvedValue(true);
mocks.uuid.mockReturnValueOnce("run-1").mockReturnValueOnce("run-2");
});
afterEach(() => {
app?.unmount();
root?.remove();
app = undefined;
root = undefined;
});
describe("SqlFileExecutionDialog retries", () => {
it("does not restore a completed run's file summary after an early retry failure", async () => {
await mountReadyDialog();
await completeFirstExecution();
mocks.ensureConnected.mockRejectedValueOnce(new Error("retry connection failed"));
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(root!.textContent).toContain("retry connection failed"));
expect(root!.querySelector("table")).toBeNull();
});
it("does not restore a completed run's file summary after an early retry cancellation", async () => {
await mountReadyDialog();
await completeFirstExecution();
const connectionGate = deferred();
mocks.ensureConnected.mockImplementationOnce(() => connectionGate.promise);
findButton("sqlFile.execute").click();
await nextTick();
findButton("sqlFile.cancel").click();
connectionGate.resolve();
await vi.waitFor(() => expect(root!.textContent).toContain("sqlFile.status.cancelled"));
expect(root!.querySelector("table")).toBeNull();
expect(mocks.executeSqlFiles).toHaveBeenCalledTimes(1);
});
});
@@ -95,6 +95,24 @@ const terminalError = ref("");
const refreshedTarget = ref(false);
const MAX_WEB_SQL_FILE_BYTES = 200 * 1024 * 1024;
// Per-file results accumulated from backend file-boundary events during
// multi-file execution. Populated only when previews.length > 1.
interface PerFileSummary {
fileName: string;
statementIndex: number;
successCount: number;
failureCount: number;
affectedRows: number;
}
const perFileResults = ref<PerFileSummary[]>([]);
const currentFileIndex = ref(-1);
const currentFileName = ref("");
function resetPerFileState() {
perFileResults.value = [];
currentFileIndex.value = -1;
currentFileName.value = "";
}
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"].includes(c.db_type)));
const selectedConnection = computed(() => sqlConnections.value.find((c) => c.id === connectionId.value));
@@ -201,6 +219,7 @@ function resetExecution() {
terminalStatus.value = "idle";
terminalError.value = "";
refreshedTarget.value = false;
resetPerFileState();
}
function resetState() {
@@ -353,6 +372,8 @@ async function startExecution() {
if (!confirmed) return;
}
resetPerFileState();
refreshedTarget.value = false;
const batchId = uuid();
executionId.value = batchId;
running.value = true;
@@ -387,6 +408,35 @@ async function startExecution() {
terminalStatus.value = next.status;
terminalError.value = next.error ?? terminalError.value;
updateSqlFileTask(batchId, next);
// Detect per-file boundary events from the backend (populated only
// during multi-file execution). The backend emits a file-start
// event (status=Running, fileIndex set) and a file-done event
// (status=StatementDone, fileIndex set, counters are diff-based).
// Resolve user-visible names via displayFileNames (by fileIndex →
// previews) so Web mode never shows server temp UUID paths.
if (previews.value.length > 1) {
const fi = next.fileIndex;
if (fi != null) {
const preview = previews.value[fi];
const displayName = preview ? (displayFileNames.value.get(preview.filePath) ?? next.fileName ?? "") : (next.fileName ?? "");
if (next.status === "running") {
currentFileIndex.value = fi;
currentFileName.value = displayName;
} else if (next.status === "statementDone") {
perFileResults.value[fi] = {
fileName: displayName,
statementIndex: next.statementIndex,
successCount: next.successCount,
failureCount: next.failureCount,
affectedRows: next.affectedRows,
};
currentFileIndex.value = fi;
currentFileName.value = displayName;
}
}
}
if (isTerminalProgress(next.status)) {
resolveTerminalProgress(next);
}
@@ -639,31 +689,80 @@ watch(
<div class="h-full rounded-full transition-[width] duration-300" :class="terminalStatus === 'error' ? 'bg-destructive' : terminalStatus === 'cancelled' ? 'bg-yellow-500' : 'bg-primary'" :style="{ width: `${progressPercent}%` }" />
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
<div class="border rounded-md px-2 py-1.5 min-w-0">
<div class="text-muted-foreground truncate">{{ t("sqlFile.statement") }}</div>
<div class="font-medium truncate">{{ progress?.statementIndex ?? 0 }}</div>
</div>
<div class="border rounded-md px-2 py-1.5 min-w-0">
<div class="text-muted-foreground truncate">{{ t("sqlFile.succeeded") }}</div>
<div class="font-medium text-green-600 truncate">
{{ progress?.successCount ?? 0 }}
</div>
</div>
<div class="border rounded-md px-2 py-1.5 min-w-0">
<div class="text-muted-foreground truncate">{{ t("sqlFile.failed") }}</div>
<div class="font-medium text-destructive truncate">
{{ progress?.failureCount ?? 0 }}
</div>
</div>
<div class="border rounded-md px-2 py-1.5 min-w-0">
<div class="text-muted-foreground truncate">{{ t("sqlFile.affectedRows") }}</div>
<div class="font-medium truncate">
{{ (progress?.affectedRows ?? 0).toLocaleString() }}
</div>
</div>
<div v-if="running && previews.length > 1 && currentFileIndex >= 0" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<FileCode class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">{{ t("sqlFile.fileProgress", { current: currentFileIndex + 1, total: previews.length }) }} — {{ currentFileName }}</span>
</div>
<template v-if="!running && previews.length > 1 && perFileResults.length > 0">
<div class="max-h-[min(22vh,200px)] min-w-0 overflow-y-auto rounded-md border text-xs">
<!-- Keep aggregate columns readable when a file name is long. -->
<table class="w-full table-fixed">
<colgroup>
<col />
<col class="w-[4.5rem]" />
<col class="w-[4.5rem]" />
<col class="w-[4.5rem]" />
<col class="w-[5.5rem]" />
</colgroup>
<thead class="sticky top-0 z-10 border-b border-border bg-muted text-foreground shadow-[0_1px_4px_rgb(0_0_0_/_0.06)]">
<tr>
<th class="px-2.5 py-2 text-left font-semibold">{{ t("sqlFile.fileColumn") }}</th>
<th class="px-2 py-2 text-right font-semibold whitespace-nowrap">{{ t("sqlFile.statement") }}</th>
<th class="px-2 py-2 text-right font-semibold whitespace-nowrap">{{ t("sqlFile.succeeded") }}</th>
<th class="px-2 py-2 text-right font-semibold whitespace-nowrap">{{ t("sqlFile.failed") }}</th>
<th class="px-2.5 py-2 text-right font-semibold whitespace-nowrap">{{ t("sqlFile.affectedRows") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in perFileResults" :key="index" class="border-t">
<td class="px-2.5 py-1.5 truncate" :title="item.fileName">{{ item.fileName }}</td>
<td class="px-2 py-1.5 text-right tabular-nums">{{ item.statementIndex }}</td>
<td class="px-2 py-1.5 text-right tabular-nums text-green-600">{{ item.successCount }}</td>
<td class="px-2 py-1.5 text-right tabular-nums" :class="{ 'text-destructive': item.failureCount > 0 }">{{ item.failureCount }}</td>
<td class="px-2.5 py-1.5 text-right tabular-nums">{{ item.affectedRows.toLocaleString() }}</td>
</tr>
</tbody>
<tfoot class="sticky bottom-0 z-10 border-t-2 border-primary/35 bg-muted font-semibold text-foreground shadow-[0_-2px_6px_rgb(0_0_0_/_0.08)]">
<tr>
<th scope="row" class="px-2.5 py-2 text-left">{{ t("sqlFile.totalFiles", { count: perFileResults.length }) }}</th>
<td class="px-2 py-2 text-right tabular-nums font-bold">{{ progress?.statementIndex ?? 0 }}</td>
<td class="px-2 py-2 text-right tabular-nums font-bold text-green-600">{{ progress?.successCount ?? 0 }}</td>
<td class="px-2 py-2 text-right tabular-nums font-bold" :class="{ 'text-destructive': (progress?.failureCount ?? 0) > 0 }">{{ progress?.failureCount ?? 0 }}</td>
<td class="px-2.5 py-2 text-right tabular-nums font-bold">{{ (progress?.affectedRows ?? 0).toLocaleString() }}</td>
</tr>
</tfoot>
</table>
</div>
</template>
<template v-else>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
<div class="border rounded-md px-2 py-1.5 min-w-0">
<div class="text-muted-foreground truncate">{{ t("sqlFile.statement") }}</div>
<div class="font-medium truncate">{{ progress?.statementIndex ?? 0 }}</div>
</div>
<div class="border rounded-md px-2 py-1.5 min-w-0">
<div class="text-muted-foreground truncate">{{ t("sqlFile.succeeded") }}</div>
<div class="font-medium text-green-600 truncate">
{{ progress?.successCount ?? 0 }}
</div>
</div>
<div class="border rounded-md px-2 py-1.5 min-w-0">
<div class="text-muted-foreground truncate">{{ t("sqlFile.failed") }}</div>
<div class="font-medium text-destructive truncate">
{{ progress?.failureCount ?? 0 }}
</div>
</div>
<div class="border rounded-md px-2 py-1.5 min-w-0">
<div class="text-muted-foreground truncate">{{ t("sqlFile.affectedRows") }}</div>
<div class="font-medium truncate">
{{ (progress?.affectedRows ?? 0).toLocaleString() }}
</div>
</div>
</div>
</template>
<div v-if="progress?.statementSummary" class="space-y-1">
<Label class="text-xs">{{ t("sqlFile.currentStatement") }}</Label>
<div class="max-h-20 max-w-full overflow-auto rounded-md border bg-muted/15 p-2 text-xs font-mono whitespace-pre">