feat(explain): add plan canvas with actual execution plans
This commit is contained in:
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 }" />< 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 }" />> 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>
|
||||
|
||||
Reference in new issue
Block a user