feat(elasticsearch): add visual search profiler

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-04 20:38:26 +08:00
1 parent 5d1061d490
commit 3ee0640fc2
22 files changed
+1093 -11

No files matched your search

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { ChevronRight, ChevronDown } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { formatProfileNanos } from "@/lib/elasticsearch/elasticsearchProfile";
import type { ElasticsearchProfileNode, ElasticsearchProfileHeatLevel } from "@/lib/elasticsearch/elasticsearchProfile";
const HEAT_COLORS: Record<ElasticsearchProfileHeatLevel, string> = {
none: "var(--muted-foreground)",
cool: "#22c55e",
warm: "#f59e0b",
hot: "#ef4444",
};
const HEAT_BADGE_CLASSES: Record<ElasticsearchProfileHeatLevel, string> = {
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",
};
/** Nodes at or below this depth start collapsed to keep large trees responsive. */
const COLLAPSE_DEFAULT_DEPTH = 4;
const props = withDefaults(
defineProps<{
node: ElasticsearchProfileNode;
depth?: number;
/** null = manual per-node state; "expanded"/"collapsed" = a global override. */
globalCollapse?: "expanded" | "collapsed" | null;
}>(),
{ depth: 0, globalCollapse: null },
);
const emit = defineEmits<{
/** The user toggled a node while a global expand/collapse was active. */
manual: [];
}>();
const { t } = useI18n();
const hasChildren = computed(() => props.node.children.length > 0);
const localCollapsed = ref((props.depth ?? 0) >= COLLAPSE_DEFAULT_DEPTH);
const collapsed = computed(() => {
if (props.globalCollapse === "collapsed") return true;
if (props.globalCollapse === "expanded") return false;
return localCollapsed.value;
});
function toggle() {
if (props.globalCollapse !== null) {
localCollapsed.value = !collapsed.value;
emit("manual");
} else {
localCollapsed.value = !localCollapsed.value;
}
}
const breakdownEntries = computed(() =>
Object.entries(props.node.breakdown ?? {})
.sort((a, b) => b[1] - a[1])
.map(([name, value]) => ({ name, value })),
);
const barPercent = computed(() => `${Math.min(100, props.node.costShare * 100)}%`);
const costPercent = computed(() => {
const percent = props.node.costShare * 100;
return percent >= 10 ? `${Math.round(percent)}%` : `${percent.toFixed(1)}%`;
});
</script>
<template>
<div>
<div class="flex min-w-0 items-center gap-1 rounded border bg-background px-2 py-1 text-xs" :class="node.isCriticalPath ? 'border-orange-500/40 bg-orange-500/[0.04]' : 'border-border'">
<Button v-if="hasChildren" variant="ghost" size="icon-xs" class="-ml-1 h-5 w-5 shrink-0 text-foreground/80" :aria-label="node.type" :aria-expanded="!collapsed" @click="toggle">
<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-mono font-medium">{{ node.type }}</span>
<span v-if="node.description" class="min-w-0 flex-1 truncate text-foreground/70" :title="node.description">{{ node.description }}</span>
<span v-if="!node.description && !hasChildren" class="min-w-0 flex-1" aria-hidden="true" />
<span class="shrink-0 tabular-nums text-foreground/80" :title="t('profile.selfTime')">self {{ formatProfileNanos(node.selfTimeInNanos) }}</span>
<span class="shrink-0 tabular-nums text-muted-foreground" :title="t('profile.totalTime')">total {{ formatProfileNanos(node.timeInNanos) }}</span>
<div class="flex w-24 shrink-0 items-center gap-1">
<div class="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full" :style="{ width: barPercent, background: HEAT_COLORS[node.heatLevel] }" />
</div>
<span class="shrink-0 rounded px-1 font-mono text-[10px] font-semibold tabular-nums" :class="HEAT_BADGE_CLASSES[node.heatLevel]" :title="t('profile.legendSelfTime')">{{ costPercent }}</span>
</div>
<span v-if="node.isCriticalPath" class="shrink-0 rounded border border-orange-500/30 bg-orange-500/10 px-1 py-0.5 text-[10px] font-semibold text-orange-700 dark:text-orange-300" :title="t('profile.criticalPath')">
{{ t("profile.criticalPathTag") }}
</span>
</div>
<div v-if="!collapsed">
<div v-if="breakdownEntries.length" class="ml-6 mt-px flex flex-wrap items-center gap-1 pl-2">
<span class="text-[10px] uppercase tracking-wide text-muted-foreground">{{ t("profile.breakdown") }}</span>
<span v-for="entry in breakdownEntries" :key="entry.name" class="rounded bg-muted/60 px-1 py-0.5 font-mono text-[10px] tabular-nums text-foreground/80">{{ entry.name }}:{{ formatProfileNanos(entry.value) }}</span>
</div>
<div v-if="node.children.length" class="ml-3 mt-px space-y-px border-l pl-2">
<ElasticsearchProfileNodeRow v-for="(child, index) in node.children" :key="index" :node="child" :depth="depth + 1" :global-collapse="globalCollapse" @manual="emit('manual')" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { Gauge, ChevronsDown, ChevronsUp } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import ElasticsearchProfileNodeRow from "@/components/common/ElasticsearchProfileNodeRow.vue";
import { countProfileNodes, formatProfileNanos, parseElasticsearchProfile, type ElasticsearchProfileShard } from "@/lib/elasticsearch/elasticsearchProfile";
const props = defineProps<{
/** The raw Elasticsearch `_search` response body. */
body: string;
}>();
const { t } = useI18n();
const parsed = computed(() => parseElasticsearchProfile(props.body));
const shards = computed<ElasticsearchProfileShard[]>(() => parsed.value?.shards ?? []);
const activeShardIndex = ref(0);
const globalCollapse = ref<"expanded" | "collapsed" | null>(null);
watch(
() => props.body,
() => {
activeShardIndex.value = 0;
globalCollapse.value = null;
},
);
watch(
() => parsed.value?.shards.length ?? 0,
() => {
if (activeShardIndex.value >= shards.value.length) activeShardIndex.value = 0;
},
);
const activeShard = computed(() => shards.value[activeShardIndex.value]);
const nodeCount = computed(() => (activeShard.value ? countProfileNodes(activeShard.value.tree) : 0));
const showCollapseProtection = computed(() => nodeCount.value > 80 && globalCollapse.value === null);
const searchLabel = computed(() => (activeShard.value?.searchCount && activeShard.value.searchCount > 1 ? ` · ${activeShard.value.searchCount} ${t("profile.searches")}` : ""));
</script>
<template>
<section data-elasticsearch-profile-root class="relative flex h-full min-h-0 flex-col bg-background">
<header class="flex min-h-11 shrink-0 items-center gap-2 border-b bg-muted/25 px-3 py-1.5 text-xs">
<div class="flex min-w-0 flex-1 items-center gap-2">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm" aria-hidden="true">
<Gauge class="h-3.5 w-3.5" />
</span>
<span class="font-medium">{{ t("profile.title") }}</span>
<div v-if="shards.length > 1" class="ml-1 inline-flex h-7 items-center overflow-hidden rounded-md border bg-muted/45 p-0.5">
<button
v-for="(shard, index) in shards"
:key="shard.id"
type="button"
class="h-6 min-w-0 max-w-40 truncate whitespace-nowrap rounded-[4px] px-2 text-xs transition-colors"
:class="activeShardIndex === index ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="activeShardIndex === index"
:title="t('profile.shard')"
@click="activeShardIndex = index"
>
{{ shard.id }}
</button>
</div>
<span class="shrink-0 rounded-full border border-border bg-background px-2 py-0.5 font-mono text-[11px] tabular-nums text-muted-foreground" role="status"> {{ activeShard ? formatProfileNanos(activeShard.totalTimeInNanos) : "—" }}{{ searchLabel }} </span>
</div>
<LightTooltip :text="t('profile.collapseAll')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('profile.collapseAll')" :aria-label="t('profile.collapseAll')" :disabled="!activeShard" @click="globalCollapse = 'collapsed'">
<ChevronsUp class="h-3.5 w-3.5" />
</Button>
</LightTooltip>
<LightTooltip :text="t('profile.expandAll')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('profile.expandAll')" :aria-label="t('profile.expandAll')" :disabled="!activeShard" @click="globalCollapse = 'expanded'">
<ChevronsDown class="h-3.5 w-3.5" />
</Button>
</LightTooltip>
</header>
<div class="min-h-0 flex-1 overflow-auto bg-background p-3">
<div v-if="!parsed" data-profile-empty class="flex h-full flex-col items-center justify-center gap-1 text-sm text-muted-foreground">
<div>{{ t("profile.emptyProfile") }}</div>
</div>
<div v-else-if="shards.length === 0" data-profile-empty class="flex h-full items-center justify-center text-sm text-muted-foreground">
{{ t("profile.emptyShards") }}
</div>
<template v-else>
<div v-if="showCollapseProtection" class="mb-2 rounded border border-border bg-muted/30 px-3 py-2 text-[11px] text-muted-foreground">
{{ t("profile.collapsedProtection", { nodes: nodeCount }) }}
</div>
<ElasticsearchProfileNodeRow :node="activeShard!.tree" :global-collapse="globalCollapse" @manual="globalCollapse = null" />
</template>
</div>
</section>
</template>
@@ -0,0 +1,121 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h("button", attrs, slots.default?.());
},
}),
};
});
import ElasticsearchProfilePanel from "@/components/common/ElasticsearchProfilePanel.vue";
const SAMPLE_BODY = JSON.stringify({
took: 42,
profile: {
shards: [
{
id: "[node1][products][0]",
searches: [
{
query: [
{
type: "BooleanQuery",
description: "title:text description:text",
time_in_nanos: 1234567,
breakdown: { build_scorer: 40000, next_doc: 300000 },
children: [
{ type: "TermQuery", description: "title:dbx", time_in_nanos: 500000, breakdown: { score: 250000 }, children: [] },
{ type: "TermQuery", description: "description:profiler", time_in_nanos: 300000, children: [] },
],
},
],
},
],
},
{
id: "[node1][products][1]",
searches: [
{
query: [{ type: "TermQuery", description: "title:dbx", time_in_nanos: 900000, children: [] }],
},
],
},
],
},
});
let app: App<Element> | null = null;
let root: HTMLDivElement | null = null;
async function flushUi() {
for (let index = 0; index < 4; index += 1) {
await Promise.resolve();
await nextTick();
}
}
async function mountPanel(body: string) {
root = document.createElement("div");
document.body.appendChild(root);
app = createApp(
defineComponent({
setup() {
return () => h(ElasticsearchProfilePanel, { body });
},
}),
);
app.mount(root);
await flushUi();
}
afterEach(() => {
app?.unmount();
app = null;
root?.remove();
root = null;
});
describe("ElasticsearchProfilePanel rendering", () => {
it("renders the query tree with type, description, self time and total time", async () => {
await mountPanel(SAMPLE_BODY);
const rootEl = root!.querySelector("[data-elasticsearch-profile-root]");
expect(rootEl).not.toBeNull();
expect(rootEl!.textContent).toContain("BooleanQuery");
expect(rootEl!.textContent).toContain("title:text description:text");
expect(rootEl!.textContent).toContain("TermQuery");
// self/total chips for the BooleanQuery root: 434567ns self, 1234567ns total.
expect(rootEl!.textContent).toContain("435µs");
expect(rootEl!.textContent).toContain("1.2ms");
});
it("shows shard pills and defaults to the highest-total shard", async () => {
await mountPanel(SAMPLE_BODY);
const shardButtons = [...root!.querySelectorAll<HTMLButtonElement>("[data-elasticsearch-profile-root] button")].filter((button) => button.textContent === "[node1][products][1]" || button.textContent === "[node1][products][0]");
expect(shardButtons).toHaveLength(2);
expect(shardButtons[0]!.getAttribute("aria-pressed")).toBe("true");
expect(shardButtons[1]!.getAttribute("aria-pressed")).toBe("false");
});
it("marks critical-path nodes and keeps a breakdown row for expanded nodes", async () => {
await mountPanel(SAMPLE_BODY);
const rootEl = root!.querySelector("[data-elasticsearch-profile-root]")!;
// The critical-path tag is rendered next to the BooleanQuery and its hot child.
expect(rootEl.textContent).toContain("profile.criticalPathTag");
expect(rootEl.textContent).toContain("breakdown");
expect(rootEl.textContent).toContain("build_scorer");
});
it("shows a friendly empty state instead of crashing for non-profile bodies", async () => {
await mountPanel('{"hits":{"total":0,"hits":[]}}');
expect(root!.querySelector("[data-profile-empty]")?.textContent).toContain("profile.emptyProfile");
});
});
@@ -14,6 +14,7 @@ import {
Columns3Cog,
Copy,
EyeOff,
Gauge,
Loader2,
Search,
TableProperties,
@@ -100,6 +101,7 @@ const MongoBucketBrowser = defineAsyncComponent(() => import("@/components/docum
const VectorBrowser = defineAsyncComponent(() => import("@/components/vector/VectorBrowser.vue"));
const HBaseBrowser = defineAsyncComponent(() => import("@/components/hbase/HBaseBrowser.vue"));
const ElasticsearchJsonResponsePanel = defineAsyncComponent(() => import("@/components/common/ElasticsearchJsonResponsePanel.vue"));
const ElasticsearchProfilePanel = defineAsyncComponent(() => import("@/components/common/ElasticsearchProfilePanel.vue"));
const MqAdminConsole = defineAsyncComponent(() => import("@/components/mq/MqAdminConsole.vue"));
const MqttAdminConsole = defineAsyncComponent(() => import("@/components/mqtt/MqttAdminConsole.vue"));
const NacosAdminConsole = defineAsyncComponent(() => import("@/components/nacos/NacosAdminConsole.vue"));
@@ -150,6 +152,7 @@ import type { CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect
import { codeMirrorSqlDialect, codeMirrorSqlDialectForConnection, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { chartableColumnIndexes } from "@/lib/dataGrid/chartData";
import { elasticsearchJsonResponseForResult } from "@/lib/elasticsearch/elasticsearchJsonResponse";
import { elasticsearchProfileBodyForResult, parseElasticsearchProfile } from "@/lib/elasticsearch/elasticsearchProfile";
import * as api from "@/lib/backend/api";
import { applyMongoGridChangesToDocument, applyMongoGridChangesToDocumentBaseline, buildMongoUpdateDocument, formatMongoShellLiteral, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
@@ -469,6 +472,13 @@ const activeElasticsearchRawBody = computed(() => {
if (activeEffectiveDatabaseType.value !== "elasticsearch" && activeEffectiveDatabaseType.value !== "easysearch") return undefined;
return props.activeTab.result?.elasticsearch_raw_body;
});
/** ES `_search?profile=true` body extracted from the active result, when present. */
const activeElasticsearchProfileBody = computed(() => elasticsearchProfileBodyForResult(activeEffectiveDatabaseType.value, props.activeTab.result));
/** Only surfaces the "Profile" entry when the response actually carries a profile section. */
const canShowProfile = computed(() => {
const body = activeElasticsearchProfileBody.value;
return body !== null && parseElasticsearchProfile(body) !== null;
});
/** Toggle between the _source table and the raw JSON panel for Elasticsearch REST results. */
const showElasticsearchRawJson = ref(false);
watch(
@@ -1675,10 +1685,12 @@ defineExpose({
class="ml-auto"
:active-view="activeOutputView"
:can-show-explain="canShowExplainOutput"
:can-show-profile="canShowProfile"
:can-export-archive="canExportResultArchive"
:archive-exporting="resultArchiveExporting"
:compact="standaloneResultToolbarCompact"
@select-explain="emit('update:activeOutputView', activeTab.id, 'explain')"
@select-profile="emit('update:activeOutputView', activeTab.id, 'profile')"
@export-archive="exportResultArchive"
/>
</div>
@@ -1695,6 +1707,8 @@ defineExpose({
:table-error="activeTab.explainTableError"
/>
<ElasticsearchProfilePanel v-else-if="activeOutputView === 'profile' && canShowProfile" class="flex-1 min-h-0" :body="activeElasticsearchProfileBody ?? ''" />
<QueryChart v-else-if="activeOutputView === 'chart' && activeTab.result && !activeElasticsearchJsonResponse" class="flex-1 min-h-0" :result="activeTab.result" />
<div v-else-if="activeOutputView === 'summary'" class="flex flex-1 min-h-0 flex-col bg-background">
@@ -1885,16 +1899,31 @@ defineExpose({
{{ showElasticsearchRawJson ? t("tabs.tableData") : t("redis.jsonView") }}
</button>
</template>
<template v-else-if="canShowProfile">
<div class="mx-1 h-4 w-px bg-border" />
<button
type="button"
class="inline-flex h-5 shrink-0 items-center gap-1 rounded-sm border border-transparent px-2 text-xs leading-none transition-colors"
:class="activeOutputView === 'profile' ? 'bg-secondary text-secondary-foreground' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="activeOutputView === 'profile'"
@click="emit('update:activeOutputView', activeTab.id, activeOutputView === 'profile' ? 'result' : 'profile')"
>
<Gauge class="h-3.5 w-3.5" />
{{ t("profile.title") }}
</button>
</template>
</template>
<template #result-toolbar-actions="{ compact }">
<DataGridColumnLayoutPopover :grid="dataGridRef" :compact="compact" />
<QueryResultToolbarActions
:active-view="activeOutputView"
:can-show-explain="canShowExplainOutput"
:can-show-profile="canShowProfile"
:can-export-archive="canExportResultArchive"
:archive-exporting="resultArchiveExporting"
:compact="compact"
@select-explain="emit('update:activeOutputView', activeTab.id, 'explain')"
@select-profile="emit('update:activeOutputView', activeTab.id, 'profile')"
@export-archive="exportResultArchive"
/>
</template>
@@ -1,15 +1,16 @@
<script setup lang="ts">
import { GitBranch, Loader2, Upload } from "@lucide/vue";
import { GitBranch, Gauge, Loader2, Upload } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import LightTooltip from "@/components/ui/LightTooltip.vue";
type OutputView = "result" | "summary" | "explain" | "chart" | "messages";
type OutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile";
withDefaults(
defineProps<{
activeView: OutputView;
canShowExplain: boolean;
canShowProfile: boolean;
canExportArchive: boolean;
archiveExporting: boolean;
compact?: boolean;
@@ -19,6 +20,7 @@ withDefaults(
const emit = defineEmits<{
selectExplain: [];
selectProfile: [];
exportArchive: [];
}>();
@@ -44,6 +46,22 @@ const { t } = useI18n();
</Button>
</LightTooltip>
<LightTooltip v-if="canShowProfile" :text="t('profile.title')" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
size="sm"
:variant="activeView === 'profile' ? 'secondary' : 'ghost'"
class="h-5 shrink-0 text-xs leading-none"
:class="compact ? 'w-6 gap-0 px-0' : 'gap-1 px-2'"
:title="t('profile.title')"
:aria-label="t('profile.title')"
:aria-pressed="activeView === 'profile'"
@click="emit('selectProfile')"
>
<Gauge class="block h-3.5 w-3.5 self-center" />
<span v-if="!compact" class="inline-flex h-4 items-center leading-none">{{ t("profile.title") }}</span>
</Button>
</LightTooltip>
<LightTooltip v-if="canExportArchive" :text="t('tabs.exportResultArchive')" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
variant="ghost"
@@ -5,8 +5,8 @@ import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import LightTooltip from "@/components/ui/LightTooltip.vue";
type OutputView = "result" | "summary" | "explain" | "chart" | "messages";
type PrimaryResultView = Exclude<OutputView, "explain">;
type OutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile";
type PrimaryResultView = Exclude<OutputView, "explain" | "profile">;
const props = withDefaults(
defineProps<{
@@ -44,7 +44,7 @@ export interface ContentAreaSurfaceProps {
activeTab: QueryTab;
activeConnection?: ConnectionConfig;
executableSql: string;
activeOutputView: "result" | "summary" | "explain" | "chart" | "messages";
activeOutputView: "result" | "summary" | "explain" | "chart" | "messages" | "profile";
formatSqlRequest: { id: number; tabId: string } | null;
compressSqlRequest: { id: number; tabId: string } | null;
selectedSql: string;
@@ -61,7 +61,7 @@ export interface ContentAreaSurfaceProps {
* global active tab after an await (see redevelopment guide §6.1).
*/
export interface ContentAreaSurfaceEmits {
"update:activeOutputView": [tabId: string, value: "result" | "summary" | "explain" | "chart" | "messages"];
"update:activeOutputView": [tabId: string, value: "result" | "summary" | "explain" | "chart" | "messages" | "profile"];
fixWithAi: [tabId: string, errorMessage: string];
sendSelectionToAi: [tabId: string, sql: string];
previewChangesAvailable: [tabId: string, value: boolean];