feat(sql-file): show per-file execution statistics
This commit is contained in:
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">
|
||||
|
||||
Reference in new issue
Block a user