fix(explain): fix execution plan node detail overflow
This commit is contained in:
parent
80ef2e579e
commit
85b6afd3c1
|
|
@ -1,57 +1,107 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ChevronRight, ChevronDown } from "@lucide/vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { ChevronRight, ChevronDown, Info } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import type { ExplainPlanNode } from "@/lib/diagram/explainPlan";
|
||||
|
||||
const props = defineProps<{
|
||||
node: ExplainPlanNode;
|
||||
depth?: number;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const collapsed = ref(false);
|
||||
const hasChildren = computed(() => props.node.children.length > 0);
|
||||
|
||||
function toggle() {
|
||||
if (props.node.children.length > 0) {
|
||||
collapsed.value = !collapsed.value;
|
||||
}
|
||||
interface ExplainPlanDetailEntry {
|
||||
label?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function actualRowsFromDetails(): string | undefined {
|
||||
for (const d of props.node.details) {
|
||||
const m = d.match(/Actual Rows:\s*(\S+)/);
|
||||
if (m) return m[1];
|
||||
const detailEntries = computed<ExplainPlanDetailEntry[]>(() =>
|
||||
props.node.details
|
||||
.map((detail): ExplainPlanDetailEntry => {
|
||||
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),
|
||||
);
|
||||
|
||||
const detailPreviewEntries = computed(() => detailEntries.value.filter((detail) => detail.label !== "Actual Rows").slice(0, 2));
|
||||
|
||||
function toggleCollapsed() {
|
||||
collapsed.value = !collapsed.value;
|
||||
}
|
||||
|
||||
const actualRows = computed(() => {
|
||||
for (const detail of props.node.details) {
|
||||
const match = detail.match(/Actual Rows:\s*(\S+)/);
|
||||
if (match) return match[1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const actualRows = actualRowsFromDetails();
|
||||
const hasActualStats = !!actualRows;
|
||||
const rowDiffers = hasActualStats && actualRows !== props.node.rows;
|
||||
const hasActualStats = computed(() => !!actualRows.value);
|
||||
const rowDiffers = computed(() => hasActualStats.value && actualRows.value !== props.node.rows);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Single line: collapse icon + title + badges all in one row -->
|
||||
<div class="flex cursor-pointer items-center gap-1 rounded border bg-background px-2 py-1 text-xs hover:bg-muted/30" :class="{ 'border-green-300 dark:border-green-700': hasActualStats }" @click="toggle">
|
||||
<ChevronRight v-if="node.children.length > 0 && collapsed" class="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<ChevronDown v-else-if="node.children.length > 0" class="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<div class="flex min-w-0 items-center gap-1 rounded border bg-background px-2 py-1 text-xs">
|
||||
<Button v-if="hasChildren" variant="ghost" size="icon-xs" class="-ml-1 h-5 w-5 shrink-0 text-foreground/80" :aria-label="node.title" :aria-expanded="!collapsed" @click="toggleCollapsed">
|
||||
<ChevronRight v-if="collapsed" class="h-3 w-3" aria-hidden="true" />
|
||||
<ChevronDown v-else class="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
<span v-else class="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
|
||||
<span class="shrink-0 rounded bg-muted px-1 py-0.5 font-medium">{{ node.nodeType }}</span>
|
||||
<span v-if="node.relation" class="shrink-0 truncate max-w-[120px] text-blue-600 dark:text-blue-400">{{ node.relation }}</span>
|
||||
<span v-if="node.index" class="shrink-0 text-emerald-600 dark:text-emerald-400">[{{ node.index }}]</span>
|
||||
<span v-if="node.cost" class="shrink-0 tabular-nums text-muted-foreground">c:{{ node.cost }}</span>
|
||||
<span v-if="node.rows" class="shrink-0 tabular-nums text-amber-600 dark:text-amber-400">e:{{ node.rows }}</span>
|
||||
<span v-if="hasActualStats" class="shrink-0 tabular-nums font-semibold" :class="rowDiffers ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'"
|
||||
<span v-if="node.relation" class="shrink-0 truncate max-w-[120px] text-foreground">{{ node.relation }}</span>
|
||||
<span v-if="node.index" class="shrink-0 font-medium text-foreground/80">[{{ node.index }}]</span>
|
||||
<span v-if="node.cost" class="shrink-0 tabular-nums text-foreground/80">c:{{ node.cost }}</span>
|
||||
<span v-if="node.rows" class="shrink-0 tabular-nums text-foreground/80">e:{{ node.rows }}</span>
|
||||
<span v-if="hasActualStats" class="shrink-0 tabular-nums font-semibold text-foreground"
|
||||
>a:{{ actualRows }}<span v-if="rowDiffers">({{ Math.round((Number(actualRows) / Number(node.rows)) * 100) }}%)</span></span
|
||||
>
|
||||
|
||||
<!-- Details collapsed into tooltip on hover -->
|
||||
<span v-if="node.details.length" class="ml-auto shrink-0 overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground/40" :title="node.details.join('\n')">{{ node.details.join(" ") }}</span>
|
||||
<div v-if="detailPreviewEntries.length" class="ml-1 flex min-w-0 flex-1 items-center gap-2 text-foreground/80">
|
||||
<span v-for="(detail, index) in detailPreviewEntries" :key="index" class="min-w-0 flex-1 truncate">
|
||||
<span v-if="detail.label" class="font-medium text-foreground">{{ detail.label }}:</span>
|
||||
{{ detail.value }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Popover v-if="detailEntries.length">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="xs" class="ml-auto h-5 shrink-0 gap-1 px-1.5 text-[11px] font-normal text-foreground/80" :aria-label="t('explain.details')">
|
||||
<Info class="h-3 w-3" aria-hidden="true" />
|
||||
{{ t("explain.details") }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-[min(32rem,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] gap-0 overflow-hidden p-0" @click.stop>
|
||||
<div data-native-clipboard class="max-h-[min(24rem,calc(100vh-8rem))] overflow-auto p-3">
|
||||
<div v-for="(detail, index) in detailEntries" :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 text-popover-foreground">
|
||||
{{ detail.label }}
|
||||
</div>
|
||||
<div class="select-text whitespace-pre-wrap break-words text-xs leading-5">
|
||||
{{ detail.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- Children (collapsible) -->
|
||||
<div v-if="node.children.length && !collapsed" class="ml-3 mt-px space-y-px border-l pl-2">
|
||||
<ExplainPlanNodeTree v-for="child in node.children" :key="child.id" :node="child" :depth="(depth || 0) + 1" />
|
||||
<ExplainPlanNodeTree v-for="child in node.children" :key="child.id" :node="child" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const nodeTreeSource = readFileSync(new URL("../ExplainPlanNodeTree.vue", import.meta.url), "utf8");
|
||||
const globalStyles = readFileSync(new URL("../../../styles/globals.css", import.meta.url), "utf8");
|
||||
|
||||
type Rgb = [number, number, number];
|
||||
|
||||
type ThemeColors = {
|
||||
selector: string;
|
||||
background: Rgb;
|
||||
foreground: Rgb;
|
||||
popover: Rgb;
|
||||
popoverForeground: Rgb;
|
||||
};
|
||||
|
||||
function parseRgb(value: string): Rgb {
|
||||
const channels = value
|
||||
.match(/\d+(?:\.\d+)?/g)
|
||||
?.slice(0, 3)
|
||||
.map(Number);
|
||||
if (!channels || channels.length !== 3) throw new Error(`Expected an opaque RGB color, received ${value}`);
|
||||
return [channels[0], channels[1], channels[2]];
|
||||
}
|
||||
|
||||
function tokenColor(block: string, token: string): Rgb | undefined {
|
||||
const value = block.match(new RegExp(`--${token}:\\s*rgb\\(([^)]+)\\)`))?.[1];
|
||||
return value ? parseRgb(value) : undefined;
|
||||
}
|
||||
|
||||
const themeColors: ThemeColors[] = [...globalStyles.matchAll(/(?<selector>:root|\.dark|html\.theme-[\w-]+(?:\.dark)?)\s*\{(?<block>[^{}]*)\}/g)]
|
||||
.map((match) => {
|
||||
const block = match.groups?.block ?? "";
|
||||
const background = tokenColor(block, "background");
|
||||
const foreground = tokenColor(block, "foreground");
|
||||
const popover = tokenColor(block, "popover");
|
||||
const popoverForeground = tokenColor(block, "popover-foreground");
|
||||
if (!background || !foreground || !popover || !popoverForeground) return undefined;
|
||||
|
||||
return { selector: match.groups?.selector ?? "", background, foreground, popover, popoverForeground };
|
||||
})
|
||||
.filter((theme): theme is ThemeColors => !!theme);
|
||||
|
||||
function linearRgbChannel(channel: number): number {
|
||||
const normalized = channel / 255;
|
||||
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
function luminance([red, green, blue]: Rgb): number {
|
||||
return 0.2126 * linearRgbChannel(red) + 0.7152 * linearRgbChannel(green) + 0.0722 * linearRgbChannel(blue);
|
||||
}
|
||||
|
||||
function contrastRatio(first: Rgb, second: Rgb): number {
|
||||
const [lighter, darker] = [luminance(first), luminance(second)].sort((left, right) => right - left);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function blend(foreground: Rgb, background: Rgb, opacity: number): Rgb {
|
||||
return foreground.map((channel, index) => Math.round(channel * opacity + background[index] * (1 - opacity))) as Rgb;
|
||||
}
|
||||
|
||||
describe("ExplainPlanNodeTree interactions", () => {
|
||||
it("keeps a concise preview of the most useful details in the tree row", () => {
|
||||
expect(nodeTreeSource).toContain('const detailPreviewEntries = computed(() => detailEntries.value.filter((detail) => detail.label !== "Actual Rows").slice(0, 2))');
|
||||
expect(nodeTreeSource).toContain('<div v-if="detailPreviewEntries.length"');
|
||||
expect(nodeTreeSource).toContain('v-for="(detail, index) in detailPreviewEntries"');
|
||||
expect(nodeTreeSource).toContain('class="min-w-0 flex-1 truncate"');
|
||||
expect(nodeTreeSource).not.toContain("text-muted-foreground/40");
|
||||
});
|
||||
|
||||
it("shows long details in a click-triggered, selectable popover", () => {
|
||||
expect(nodeTreeSource).toContain('import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"');
|
||||
expect(nodeTreeSource).toContain('<Popover v-if="detailEntries.length">');
|
||||
expect(nodeTreeSource).toContain("<PopoverTrigger as-child>");
|
||||
expect(nodeTreeSource).toContain('<PopoverContent align="end"');
|
||||
expect(nodeTreeSource).toContain('data-native-clipboard class="max-h-');
|
||||
expect(nodeTreeSource).toContain("select-text whitespace-pre-wrap break-words");
|
||||
expect(nodeTreeSource).not.toContain("node.details.join");
|
||||
expect(nodeTreeSource).not.toContain("{{ detailEntries.length }}");
|
||||
expect(nodeTreeSource.match(/\{\{ t\("explain\.details"\) \}\}/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps detail fields distinct and tree disclosure on its own button", () => {
|
||||
expect(nodeTreeSource).toContain('const separatorIndex = detail.indexOf(":")');
|
||||
expect(nodeTreeSource).toContain('v-if="detail.label"');
|
||||
expect(nodeTreeSource).toContain('<Button v-if="hasChildren"');
|
||||
expect(nodeTreeSource).toContain(':aria-expanded="!collapsed"');
|
||||
expect(nodeTreeSource).toContain('@click="toggleCollapsed"');
|
||||
expect(nodeTreeSource).not.toContain("cursor-pointer");
|
||||
expect(nodeTreeSource).not.toContain('@click="toggle"');
|
||||
});
|
||||
|
||||
it("uses semantic foreground tokens instead of fixed status hues", () => {
|
||||
expect(nodeTreeSource).toContain("text-foreground/80");
|
||||
expect(nodeTreeSource).toContain("text-popover-foreground");
|
||||
expect(nodeTreeSource).not.toMatch(/text-(?:blue|emerald|amber|green)-/);
|
||||
expect(nodeTreeSource).not.toMatch(/border-(?:blue|emerald|amber|green)-/);
|
||||
});
|
||||
|
||||
it("keeps foreground-based tree metadata readable in every application palette", () => {
|
||||
expect(themeColors).toHaveLength(26);
|
||||
|
||||
for (const theme of themeColors) {
|
||||
expect(contrastRatio(blend(theme.foreground, theme.background, 0.8), theme.background), theme.selector).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrastRatio(theme.popoverForeground, theme.popover), theme.selector).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue