feat(explain): add plan canvas with actual execution plans

This commit is contained in:
Fernando Possebon authored and GitHub committed 2026-08-03 03:06:36 +08:00
1 parent cc7b2b17c5
commit ed1e36912c
28 files changed
+2281 -43

No files matched your search

@@ -0,0 +1,327 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { ArrowDownWideNarrow, CircleHelp, GitMerge, KeyRound, Layers, ListTree, Maximize2, MonitorCheck, Pencil, Sigma, Split, Table2, ZoomIn, ZoomOut } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import type { ExplainPlanNode } from "@/lib/diagram/explainPlan";
import type { PlanCanvasCategory, PlanCanvasNode } from "@/lib/diagram/planCanvas";
import { buildPlanCanvas, edgeStrokeWidth, formatPlanRows, heatLevel, PLAN_CANVAS_GAP_X, PLAN_CANVAS_NODE_H, PLAN_CANVAS_NODE_W } from "@/lib/diagram/planCanvas";
const props = defineProps<{
nodes: ExplainPlanNode[];
}>();
const { t } = useI18n();
const CATEGORY_COLORS: Record<PlanCanvasCategory, string> = {
result: "#a78bfa",
sort: "#38bdf8",
join: "#f472b6",
tscan: "#fb923c",
iscan: "#34d399",
lookup: "#2dd4bf",
mat: "#94a3b8",
agg: "#c084fc",
xchg: "#facc15",
mod: "#f87171",
other: "var(--muted-foreground)",
};
const CATEGORY_ICONS = {
result: MonitorCheck,
sort: ArrowDownWideNarrow,
join: GitMerge,
tscan: Table2,
iscan: ListTree,
lookup: KeyRound,
mat: Layers,
agg: Sigma,
xchg: Split,
mod: Pencil,
other: CircleHelp,
} as const;
const CATEGORY_LABEL_KEYS: Record<PlanCanvasCategory, string> = {
result: "explain.catResult",
sort: "explain.catSort",
join: "explain.catJoin",
tscan: "explain.catTscan",
iscan: "explain.catIscan",
lookup: "explain.catLookup",
mat: "explain.catMat",
agg: "explain.catAgg",
xchg: "explain.catXchg",
mod: "explain.catMod",
other: "explain.catOther",
};
const HEAT_COLORS = {
none: "var(--muted-foreground)",
cool: "#22c55e",
warm: "#f59e0b",
hot: "#ef4444",
} as const;
const HEAT_BADGE_CLASSES = {
none: "bg-muted text-muted-foreground",
cool: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
warm: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
hot: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
} as const;
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 1.6;
const FIT_ZOOM = 1.3;
const layout = computed(() => buildPlanCanvas(props.nodes));
const viewport = ref<HTMLElement>();
const zoom = ref(1);
const selectedIndex = ref(-1);
const hottest = computed(() => {
let best: PlanCanvasNode | undefined;
for (const node of layout.value.nodes) {
if (best === undefined || (node.costShare ?? -1) > (best.costShare ?? -1)) best = node;
}
return best;
});
const selected = computed(() => layout.value.nodes[selectedIndex.value] ?? hottest.value);
const edges = computed(() =>
layout.value.edges.map((edge) => {
const startX = edge.from.x + PLAN_CANVAS_NODE_W;
const startY = edge.from.y + PLAN_CANVAS_NODE_H / 2;
const endY = edge.to.y + PLAN_CANVAS_NODE_H / 2;
const elbowX = edge.to.x - PLAN_CANVAS_GAP_X / 2;
return {
key: `${edge.from.node.id}->${edge.to.node.id}`,
path: `M ${startX} ${startY} H ${elbowX} V ${endY} H ${edge.to.x}`,
width: edgeStrokeWidth(edge.rows),
label: formatPlanRows(edge.rows),
labelX: edge.to.x - 8,
labelY: endY - 8,
};
}),
);
interface DetailEntry {
label?: string;
value: string;
}
const selectedDetails = computed<DetailEntry[]>(() =>
(selected.value?.node.details ?? [])
.map((detail): DetailEntry => {
const separatorIndex = detail.indexOf(":");
if (separatorIndex === -1) return { value: detail.trim() };
const label = detail.slice(0, separatorIndex).trim();
return { label: label || undefined, value: detail.slice(separatorIndex + 1).trim() };
})
.filter((detail) => detail.label || detail.value),
);
function categoryColor(category: PlanCanvasCategory): string {
return CATEGORY_COLORS[category];
}
function iconTint(category: PlanCanvasCategory): string {
return `color-mix(in srgb, ${categoryColor(category)} 18%, transparent)`;
}
function costPercent(share?: number): string {
return share === undefined ? "" : `${(share * 100).toFixed(1)}%`;
}
/** Flags a node whose measured rows are at least 2x off the estimate. */
function rowMismatch(node: PlanCanvasNode): string | undefined {
if (node.actualRows === undefined || !node.rows) return undefined;
const ratio = node.actualRows / node.rows;
return ratio >= 2 || ratio <= 0.5 ? `×${ratio.toFixed(1)}` : undefined;
}
function select(index: number) {
selectedIndex.value = index;
}
function setZoom(value: number) {
zoom.value = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(value.toFixed(2))));
}
function zoomIn() {
setZoom(zoom.value + 0.15);
}
function zoomOut() {
setZoom(zoom.value - 0.15);
}
function fit() {
const element = viewport.value;
const { width, height } = layout.value;
if (!element || !width || !height || !element.clientWidth || !element.clientHeight) return;
setZoom(Math.min(element.clientWidth / width, element.clientHeight / height, FIT_ZOOM));
}
let panOrigin: { x: number; y: number; left: number; top: number } | undefined;
function stopPan() {
panOrigin = undefined;
window.removeEventListener("mousemove", onPanMove);
window.removeEventListener("mouseup", stopPan);
}
function onPanMove(event: MouseEvent) {
const element = viewport.value;
if (!panOrigin || !element) return;
element.scrollLeft = panOrigin.left - (event.clientX - panOrigin.x);
element.scrollTop = panOrigin.top - (event.clientY - panOrigin.y);
}
function startPan(event: MouseEvent) {
const element = viewport.value;
if (!element || (event.target as HTMLElement | null)?.closest("[data-plan-node]")) return;
panOrigin = { x: event.clientX, y: event.clientY, left: element.scrollLeft, top: element.scrollTop };
window.addEventListener("mousemove", onPanMove);
window.addEventListener("mouseup", stopPan);
}
onBeforeUnmount(stopPan);
watch(
() => props.nodes,
() => {
selectedIndex.value = -1;
void nextTick(fit);
},
{ immediate: true },
);
</script>
<template>
<div class="flex h-full min-h-0 min-w-0">
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<div class="flex h-8 shrink-0 items-center gap-1 border-b px-2">
<span class="flex-1" />
<Button variant="ghost" size="xs" class="h-6 px-1.5" :aria-label="t('explain.zoomOut')" :title="t('explain.zoomOut')" @click="zoomOut">
<ZoomOut class="h-3.5 w-3.5" aria-hidden="true" />
</Button>
<Button variant="ghost" size="xs" class="h-6 px-1.5" :aria-label="t('explain.zoomFit')" :title="t('explain.zoomFit')" @click="fit">
<Maximize2 class="h-3.5 w-3.5" aria-hidden="true" />
</Button>
<Button variant="ghost" size="xs" class="h-6 px-1.5" :aria-label="t('explain.zoomIn')" :title="t('explain.zoomIn')" @click="zoomIn">
<ZoomIn class="h-3.5 w-3.5" aria-hidden="true" />
</Button>
<span class="ml-1 w-10 text-right font-mono text-[11px] tabular-nums text-muted-foreground">{{ Math.round(zoom * 100) }}%</span>
</div>
<div
ref="viewport"
class="relative min-h-0 flex-1 cursor-grab overflow-auto active:cursor-grabbing"
:style="{
backgroundImage: 'linear-gradient(color-mix(in srgb, var(--border) 70%, transparent) 1px, transparent 1px), linear-gradient(90deg, color-mix(in srgb, var(--border) 70%, transparent) 1px, transparent 1px)',
backgroundSize: '28px 28px, 28px 28px',
}"
@mousedown="startPan"
>
<div :style="{ width: `${layout.width * zoom}px`, height: `${layout.height * zoom}px` }">
<div class="relative origin-top-left" :style="{ width: `${layout.width}px`, height: `${layout.height}px`, transform: `scale(${zoom})` }">
<svg data-plan-edges class="pointer-events-none absolute inset-0 overflow-visible text-muted-foreground/70" :width="layout.width" :height="layout.height" aria-hidden="true">
<path v-for="edge in edges" :key="edge.key" :d="edge.path" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-linecap="round" :stroke-width="edge.width" />
<text v-for="edge in edges" :key="`${edge.key}-label`" :x="edge.labelX" :y="edge.labelY" text-anchor="end" class="fill-muted-foreground font-mono text-[10px]">{{ edge.label }}</text>
</svg>
<button
v-for="(item, index) in layout.nodes"
:key="`${item.node.id}-${index}`"
type="button"
data-plan-node
:aria-pressed="selected === item"
class="absolute flex flex-col overflow-hidden rounded-lg border bg-card py-1.5 pr-2.5 pl-3 text-left transition-shadow hover:shadow-md"
:class="selected === item ? 'border-ring ring-2 ring-ring/30' : ''"
:style="{ left: `${item.x}px`, top: `${item.y}px`, width: `${PLAN_CANVAS_NODE_W}px`, height: `${PLAN_CANVAS_NODE_H}px` }"
@click="select(index)"
>
<span class="absolute inset-y-0 left-0 w-[3px] rounded-l-lg" :style="{ background: HEAT_COLORS[heatLevel(item.costShare)] }" aria-hidden="true" />
<span class="flex min-w-0 items-center gap-1.5">
<span class="flex h-5 w-5 shrink-0 items-center justify-center rounded" :style="{ background: iconTint(item.category), color: categoryColor(item.category) }">
<component :is="CATEGORY_ICONS[item.category]" class="h-3 w-3" aria-hidden="true" />
</span>
<span class="min-w-0 flex-1 truncate text-xs font-semibold">{{ item.node.nodeType || item.node.title }}</span>
<span v-if="item.costShare !== undefined" class="shrink-0 rounded px-1 font-mono text-[10px] font-semibold tabular-nums" :class="HEAT_BADGE_CLASSES[heatLevel(item.costShare)]">
{{ costPercent(item.costShare) }}
</span>
</span>
<span class="mt-1 truncate font-mono text-[10px] text-muted-foreground">
{{ item.node.relation || " " }}
<span v-if="item.node.index" :style="{ color: CATEGORY_COLORS.iscan }">[{{ item.node.index }}]</span>
</span>
<span class="mt-1 flex items-center gap-1.5 font-mono text-[10px] text-muted-foreground">
<span class="truncate">{{ t("explain.estRows") }} {{ formatPlanRows(item.rows) }}</span>
<span v-if="rowMismatch(item)" class="ml-auto shrink-0 font-semibold text-amber-600 dark:text-amber-400">{{ rowMismatch(item) }}</span>
</span>
</button>
</div>
</div>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-t px-3 py-1 text-[11px] text-muted-foreground">
<span>{{ t("explain.legendHeat") }}</span>
<span class="inline-flex items-center gap-1"><span class="h-2 w-2 rounded-[2px]" :style="{ background: HEAT_COLORS.cool }" />&lt; 5%</span>
<span class="inline-flex items-center gap-1"><span class="h-2 w-2 rounded-[2px]" :style="{ background: HEAT_COLORS.warm }" />5–20%</span>
<span class="inline-flex items-center gap-1"><span class="h-2 w-2 rounded-[2px]" :style="{ background: HEAT_COLORS.hot }" />&gt; 20%</span>
<span class="ml-2">{{ t("explain.legendEdge") }}</span>
</div>
</div>
<aside class="hidden w-72 shrink-0 flex-col overflow-auto border-l md:flex">
<template v-if="selected">
<div class="border-b p-3">
<div class="flex items-center gap-2">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded" :style="{ background: iconTint(selected.category), color: categoryColor(selected.category) }">
<component :is="CATEGORY_ICONS[selected.category]" class="h-3.5 w-3.5" aria-hidden="true" />
</span>
<div class="min-w-0">
<div class="truncate text-sm font-semibold">{{ selected.node.nodeType || selected.node.title }}</div>
<div class="text-[11px] font-medium" :style="{ color: categoryColor(selected.category) }">{{ t(CATEGORY_LABEL_KEYS[selected.category]) }}</div>
</div>
</div>
<div v-if="selected.node.relation || selected.node.index" class="mt-2 truncate font-mono text-[11px] text-muted-foreground">
{{ selected.node.relation }}<template v-if="selected.node.index"> [{{ selected.node.index }}]</template>
</div>
</div>
<div class="grid grid-cols-2 border-b text-xs">
<div class="border-r border-b p-2">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">{{ t("explain.costShare") }}</div>
<div class="font-mono font-semibold tabular-nums" :style="{ color: HEAT_COLORS[heatLevel(selected.costShare)] }">{{ costPercent(selected.costShare) || "—" }}</div>
</div>
<div class="border-b p-2">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">{{ t("explain.cost") }}</div>
<div class="truncate font-mono font-semibold tabular-nums">{{ selected.node.cost || "—" }}</div>
</div>
<div class="border-r p-2">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">{{ t("explain.estRows") }}</div>
<div class="font-mono font-semibold tabular-nums">{{ formatPlanRows(selected.rows) }}</div>
</div>
<div class="p-2">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">{{ t("explain.actualRows") }}</div>
<div class="font-mono font-semibold tabular-nums" :class="rowMismatch(selected) ? 'text-amber-600 dark:text-amber-400' : ''">{{ formatPlanRows(selected.actualRows) }}</div>
</div>
</div>
<div v-if="selectedDetails.length" class="min-h-0 flex-1 overflow-auto p-3">
<div class="mb-2 text-[10px] uppercase tracking-wide text-muted-foreground">{{ t("explain.details") }}</div>
<div v-for="(detail, index) in selectedDetails" :key="index" class="space-y-1 border-b py-2 first:pt-0 last:border-b-0 last:pb-0">
<div v-if="detail.label" class="text-[11px] font-medium">{{ detail.label }}</div>
<div class="select-text whitespace-pre-wrap break-words font-mono text-[11px] leading-5 text-muted-foreground">{{ detail.value }}</div>
</div>
</div>
</template>
<div v-else class="flex flex-1 items-center justify-center p-3 text-xs text-muted-foreground">{{ t("explain.inspectorEmpty") }}</div>
</aside>
</div>
</template>
@@ -1,12 +1,14 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { AlertCircle, Braces, GitBranch, Table2, FileText } from "@lucide/vue";
import { AlertCircle, Braces, GitBranch, Table2, FileText, Workflow } from "@lucide/vue";
import type { ParsedExplainPlan, ExplainPlanNode } from "@/lib/diagram/explainPlan";
import { flattenExplainPlanNodes } from "@/lib/diagram/explainPlan";
import { extractActualRows } from "@/lib/diagram/planCanvas";
import { Button } from "@/components/ui/button";
import type { QueryResult } from "@/types/database";
import ExplainPlanNodeTree from "./ExplainPlanNodeTree.vue";
import ExplainPlanDiagram from "./ExplainPlanDiagram.vue";
const props = defineProps<{
plan?: ParsedExplainPlan;
@@ -19,13 +21,13 @@ const props = defineProps<{
}>();
const { t } = useI18n();
const activeView = ref<"tree" | "summary" | "raw" | "table">("tree");
const activeView = ref<"canvas" | "tree" | "summary" | "raw" | "table">("canvas");
const hasTableView = computed(() => !!props.tableResult || !!props.tableError);
watch(
[hasTableView, () => !!props.tableResult, () => !!props.plan, () => props.loading],
([available, hasTableResult, hasPlan, loading]) => {
if (!available && activeView.value === "table") activeView.value = "tree";
if (!available && activeView.value === "table") activeView.value = "canvas";
// Wait for JSON EXPLAIN to finish so regular MySQL still defaults to its visual tree.
if (!loading && hasTableResult && !hasPlan) activeView.value = "table";
},
@@ -53,6 +55,14 @@ const rawContent = computed(() => {
const isRawString = computed(() => typeof props.plan?.raw === "string");
const rawFormatLabel = computed(() => (props.plan?.databaseType === "sqlserver" ? "XML" : isRawString.value ? "TEXT" : "JSON"));
const nodeCount = computed(() => (props.plan ? flattenExplainPlanNodes(props.plan.nodes).length : 0));
// Measured rows exist only when the plan was produced by a mode that ran the query:
// EXPLAIN ANALYZE on Postgres, SET STATISTICS XML on SQL Server.
const measuredRowsLabel = computed(() => {
const databaseType = props.plan?.databaseType;
if (databaseType !== "postgres" && databaseType !== "sqlserver") return undefined;
if (!flattenExplainPlanNodes(props.plan!.nodes).some((node) => extractActualRows(node) !== undefined)) return undefined;
return databaseType === "sqlserver" ? "ACTUAL" : "ANALYZE";
});
function tableCellText(value: unknown): string {
if (value === null) return "NULL";
@@ -71,8 +81,13 @@ function tableCellText(value: unknown): string {
{{ plan?.databaseType.toUpperCase() || "MYSQL" }}<template v-if="plan"> · {{ t("explain.nodeCount", { count: nodeCount }) }}</template>
</span>
<span v-if="plan?.databaseType === 'dameng' && isRawString && rawContent.includes('->')" class="ml-1 inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 font-semibold text-green-700 dark:bg-green-900/30 dark:text-green-300" style="font-size: 10px">A-TRACE</span>
<span v-if="measuredRowsLabel" class="ml-1 inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 font-semibold text-green-700 dark:bg-green-900/30 dark:text-green-300" style="font-size: 10px">{{ measuredRowsLabel }}</span>
<span class="flex-1" />
<div v-if="plan || hasTableView" class="inline-flex rounded-md border bg-muted/40 p-0.5">
<Button v-if="plan" size="sm" :variant="activeView === 'canvas' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" @click="activeView = 'canvas'">
<Workflow class="h-3.5 w-3.5" />
{{ t("explain.canvas") }}
</Button>
<Button v-if="plan" size="sm" :variant="activeView === 'tree' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" @click="activeView = 'tree'">
<GitBranch class="h-3.5 w-3.5" />
{{ t("explain.tree") }}
@@ -136,6 +151,10 @@ function tableCellText(value: unknown): string {
{{ t("explain.empty") }}
</div>
<div v-else-if="activeView === 'canvas'" class="flex-1 min-h-0">
<ExplainPlanDiagram :nodes="plan.nodes" />
</div>
<div v-else class="flex-1 min-h-0 overflow-auto">
<div v-if="activeView === 'tree'" class="mx-auto max-w-5xl space-y-px p-2">
<ExplainPlanNodeTree v-for="node in plan.nodes" :key="node.id" :node="node" />
@@ -0,0 +1,238 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import i18n from "@/i18n";
import type { ExplainPlanNode } from "@/lib/diagram/explainPlan";
import { parseExplainResult } from "@/lib/diagram/explainPlan";
import type { QueryResult } from "@/types/database";
import ExplainPlanDiagram from "@/components/explain/ExplainPlanDiagram.vue";
function planNode(node: Partial<ExplainPlanNode> & { id: string; nodeType: string }): ExplainPlanNode {
return { title: node.nodeType, details: [], children: [], ...node };
}
// root(Limit) → Sort → Hash Join → [Seq Scan, Hash → Index Scan]
function postgresPlan(): ExplainPlanNode[] {
return [
planNode({
id: "0",
nodeType: "Limit",
cost: "24102.11..24180.37",
rows: "100",
children: [
planNode({
id: "0.0",
nodeType: "Sort",
cost: "24000.00..24102.11",
rows: "42000",
details: ["Sort: o.created_at DESC", "Actual Rows: 84000"],
children: [
planNode({
id: "0.0.0",
nodeType: "Hash Join",
cost: "0.29..23800.00",
rows: "42000",
details: ["Join: Inner"],
children: [
planNode({ id: "0.0.0.0", nodeType: "Seq Scan", relation: "orders", cost: "0.00..18000.00", rows: "1200000", details: ["Filter: created_at >= '2026-01-01'"] }),
planNode({
id: "0.0.0.1",
nodeType: "Hash",
cost: "0.29..1200.00",
rows: "18500",
children: [planNode({ id: "0.0.0.1.0", nodeType: "Index Scan", relation: "customers", index: "idx_customers_region", cost: "0.29..1100.00", rows: "18500" })],
}),
],
}),
],
}),
],
}),
];
}
const mountedApps: App[] = [];
beforeEach(() => {
i18n.global.locale.value = "en";
});
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
async function mountDiagram(nodes: ExplainPlanNode[] = postgresPlan()) {
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup() {
return () => h(ExplainPlanDiagram, { nodes });
},
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(container);
await nextTick();
}
function cards(): HTMLElement[] {
return [...document.querySelectorAll<HTMLElement>("[data-plan-node]")];
}
function cardFor(operator: string): HTMLElement {
const card = cards().find((element) => element.textContent?.includes(operator));
if (!card) throw new Error(`No card rendered for ${operator}`);
return card;
}
function inspectorText(): string {
return document.querySelector("aside")?.textContent ?? "";
}
describe("ExplainPlanDiagram", () => {
it("renders one card per plan node and one edge per parent-child link", async () => {
await mountDiagram();
expect(cards()).toHaveLength(6);
expect(document.querySelectorAll("[data-plan-edges] path")).toHaveLength(5);
expect(document.querySelectorAll("[data-plan-edges] text")).toHaveLength(5);
});
it("shows the cost share badge on nodes with a numeric cost", async () => {
await mountDiagram();
// Self-cost of the sequential scan is 18000 of the plan's 24180.37 total.
expect(cardFor("Seq Scan").textContent).toContain("74.4%");
expect(cardFor("Seq Scan").textContent).toContain("orders");
expect(cardFor("Index Scan").textContent).toContain("idx_customers_region");
});
it("omits the cost badge when the engine reports no cost", async () => {
await mountDiagram([planNode({ id: "0", nodeType: "Plan", details: ["explain plan text"] })]);
expect(cards()).toHaveLength(1);
expect(cards()[0].textContent).not.toContain("%");
});
it("flags a node whose actual rows are at least twice the estimate", async () => {
await mountDiagram();
expect(cardFor("Sort").textContent).toContain("×2.0");
expect(cardFor("Index Scan").textContent).not.toContain("×");
});
it("preselects the costliest node and follows a click to another card", async () => {
await mountDiagram();
expect(inspectorText()).toContain("Seq Scan");
expect(inspectorText()).toContain("Table scan");
expect(inspectorText()).toContain("created_at >= '2026-01-01'");
cardFor("Index Scan").click();
await nextTick();
expect(inspectorText()).toContain("Index Scan");
expect(inspectorText()).toContain("Index scan");
expect(inspectorText()).toContain("idx_customers_region");
expect(inspectorText()).not.toContain("Table scan");
});
it("flags the mismatch on a plan parsed from real EXPLAIN ANALYZE output", async () => {
const analyzePlan = [
{
Plan: {
"Node Type": "Sort",
"Startup Cost": 24000.0,
"Total Cost": 24102.11,
"Plan Rows": 42000,
"Actual Rows": 96000,
"Actual Loops": 1,
"Actual Startup Time": 812.44,
"Actual Total Time": 851.09,
Plans: [{ "Node Type": "Seq Scan", "Relation Name": "orders", "Startup Cost": 0.0, "Total Cost": 18000.0, "Plan Rows": 1200000, "Actual Rows": 1187423, "Actual Loops": 1 }],
},
"Planning Time": 0.482,
"Execution Time": 862.104,
},
];
const result: QueryResult = { columns: ["QUERY PLAN"], rows: [[JSON.stringify(analyzePlan)]], affected_rows: 0, execution_time_ms: 1 };
await mountDiagram(parseExplainResult("postgres", result).nodes);
// 96000 measured against 42000 estimated is a 2.3x overrun.
expect(cardFor("Sort").textContent).toContain("×2.3");
// The scan landed within 1% of its estimate, so it stays unflagged.
expect(cardFor("Seq Scan").textContent).not.toContain("×");
// Planning/Execution time live on the root node, which is not the costliest one.
cardFor("Sort").click();
await nextTick();
expect(inspectorText()).toContain("Planning Time");
expect(inspectorText()).toContain("862.104 ms");
});
// Detail strings mirror what parseSqlServerRelOp emits for SET STATISTICS XML;
// see lib/__tests__/query/sqlserverActualPlan.spec.ts for the parser contract.
it("does not flag a SQL Server node whose cumulative rows only look like an overrun", async () => {
await mountDiagram([
planNode({
id: "0",
nodeType: "Nested Loops",
cost: "1.2",
rows: "1000",
details: ["Actual Rows: 1000", "Actual Threads: 3", "Actual Executions: 3"],
children: [
planNode({
id: "1",
nodeType: "Index Seek",
relation: "dbo.orders",
cost: "0.85",
rows: "10",
// 1000 cumulative rows over 100 executions is 10 per execution, exactly
// the estimate. Comparing the raw total would report a bogus x100.
details: ["Actual Rows: 1000", "Actual Threads: 3", "Actual Executions: 100", "Actual Rows Per Execution: 10"],
}),
],
}),
]);
expect(cardFor("Index Seek").textContent).not.toContain("×");
expect(cardFor("Nested Loops").textContent).not.toContain("×");
});
it("still flags a SQL Server node that misestimates per execution", async () => {
await mountDiagram([
planNode({
id: "0",
nodeType: "Index Seek",
relation: "dbo.orders",
cost: "0.85",
rows: "10",
details: ["Actual Rows: 5000", "Actual Executions: 100", "Actual Rows Per Execution: 50"],
}),
]);
expect(cardFor("Index Seek").textContent).toContain("×5.0");
});
it("keeps the zoom controls inside their range", async () => {
await mountDiagram();
const zoomOut = document.querySelector<HTMLButtonElement>('button[aria-label="Zoom out"]');
const zoomIn = document.querySelector<HTMLButtonElement>('button[aria-label="Zoom in"]');
expect(zoomOut && zoomIn).toBeTruthy();
expect(document.body.textContent).toContain("100%");
for (let step = 0; step < 10; step += 1) zoomOut?.click();
await nextTick();
expect(document.body.textContent).toContain("50%");
for (let step = 0; step < 20; step += 1) zoomIn?.click();
await nextTick();
expect(document.body.textContent).toContain("160%");
});
});
@@ -11,8 +11,36 @@ describe("ExplainPlanViewer MySQL fallback", () => {
expect(viewerSource).toContain("{ immediate: true },");
});
it("keeps the regular tree default when the visual plan arrives", () => {
it("keeps the canvas default when the visual plan arrives", () => {
expect(viewerSource).toContain('if (!loading && hasTableResult && !hasPlan) activeView.value = "table";');
expect(viewerSource).toContain('if (!available && activeView.value === "table") activeView.value = "tree";');
expect(viewerSource).toContain('if (!available && activeView.value === "table") activeView.value = "canvas";');
});
});
describe("ExplainPlanViewer canvas view", () => {
it("opens on the canvas and keeps the other views reachable", () => {
expect(viewerSource).toContain('const activeView = ref<"canvas" | "tree" | "summary" | "raw" | "table">("canvas");');
expect(viewerSource).toContain('<ExplainPlanDiagram :nodes="plan.nodes" />');
expect(viewerSource).toContain('import ExplainPlanDiagram from "./ExplainPlanDiagram.vue";');
for (const view of ["canvas", "tree", "summary", "raw", "table"]) {
expect(viewerSource, view).toContain(`activeView = '${view}'`);
}
});
it("derives the measured-rows chip from parsed nodes, not the raw plan text", () => {
expect(viewerSource).toContain('import { extractActualRows } from "@/lib/diagram/planCanvas";');
expect(viewerSource).toContain("const measuredRowsLabel = computed(() => {");
expect(viewerSource).toContain('if (databaseType !== "postgres" && databaseType !== "sqlserver") return undefined;');
expect(viewerSource).toContain("if (!flattenExplainPlanNodes(props.plan!.nodes).some((node) => extractActualRows(node) !== undefined)) return undefined;");
expect(viewerSource).toContain('return databaseType === "sqlserver" ? "ACTUAL" : "ANALYZE";');
expect(viewerSource).toContain('<span v-if="measuredRowsLabel"');
expect(viewerSource).toContain("{{ measuredRowsLabel }}</span>");
// The Dameng A-TRACE chip keeps its own raw-text condition.
expect(viewerSource).toContain("plan?.databaseType === 'dameng' && isRawString && rawContent.includes('->')");
});
it("puts the canvas tab first in the view switcher", () => {
const tabOrder = [...viewerSource.matchAll(/@click="activeView = '(\w+)'"/g)].map((match) => match[1]);
expect(tabOrder).toEqual(["canvas", "tree", "summary", "raw", "table"]);
});
});
@@ -104,6 +104,18 @@ const supportsTransaction = computed(() => supportsTransactionFeature(props.acti
const hasDefaultDatabaseOption = computed(() => activeDatabaseOptions.value.includes(""));
const schemaDatabaseKey = computed(() => props.activeTab.database || (isSingleDb.value ? "_" : ""));
const saveTooltip = computed(() => (props.activeTab.objectSource ? t("objects.saveSource") : t("toolbar.saveSql")));
// DM calls it autotrace, Postgres EXPLAIN ANALYZE, SQL Server the actual execution
// plan (SET STATISTICS XML); all three execute the statement.
const supportsExplainAnalyze = computed(() => {
const dbType = props.activeConnection?.db_type;
return dbType === "dameng" || dbType === "postgres" || dbType === "sqlserver";
});
const explainAnalyzeTooltip = computed(() => {
const dbType = props.activeConnection?.db_type;
if (dbType === "postgres") return t("toolbar.explainAnalyze");
if (dbType === "sqlserver") return t("toolbar.actualPlan");
return t("toolbar.autotrace");
});
const canSaveSql = computed(() => !!props.activeTab.externalSqlPath || !!props.activeTab.sql.trim());
const keywordCaseIsLower = computed(() => props.sqlKeywordCase === "lower");
const keywordCaseToggleTooltip = computed(() => (keywordCaseIsLower.value ? t("toolbar.keywordCaseUpper") : t("toolbar.keywordCaseLower")));
@@ -242,18 +254,24 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ activeTab.isExplaining ? t("toolbar.stopExplain") : t("toolbar.explainPlan") }}</TooltipContent>
</Tooltip>
<!-- Autotrace toggle (only for DM) -->
<Button
v-if="activeConnection?.db_type === 'dameng'"
variant="ghost"
size="icon"
class="h-6 w-6"
:class="props.explainMode === 'autotrace' ? 'text-green-600 bg-green-100 dark:text-green-300 dark:bg-green-900/30' : 'text-muted-foreground/50'"
:disabled="activeTab.isExecuting"
@click="emit('update:explainMode', props.explainMode === 'autotrace' ? 'explain' : 'autotrace')"
>
<span class="font-bold" style="font-size: 9px">A</span>
</Button>
<!-- Autotrace (DM) / EXPLAIN ANALYZE (Postgres) / actual plan (SQL Server) toggle -->
<Tooltip v-if="supportsExplainAnalyze">
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
:class="props.explainMode === 'autotrace' ? 'text-green-600 bg-green-100 dark:text-green-300 dark:bg-green-900/30' : 'text-muted-foreground/50'"
:disabled="activeTab.isExecuting"
:aria-label="explainAnalyzeTooltip"
:aria-pressed="props.explainMode === 'autotrace'"
@click="emit('update:explainMode', props.explainMode === 'autotrace' ? 'explain' : 'autotrace')"
>
<span class="font-bold" style="font-size: 9px">A</span>
</Button>
</TooltipTrigger>
<TooltipContent>{{ explainAnalyzeTooltip }}</TooltipContent>
</Tooltip>
<!-- Transaction toggle -->
<Tooltip v-if="supportsTransaction">
<TooltipTrigger as-child>