feat(elasticsearch): add visual search profiler
This commit is contained in:
1 parent
5d1061d490
commit
3ee0640fc2
22 files changed
+1093
-11
No files matched your search
@@ -293,7 +293,7 @@ const cursorPos = ref(0);
|
||||
const previewChangesAvailable = ref(false);
|
||||
const formatSqlRequest = ref<{ id: number; tabId: string } | null>(null);
|
||||
const compressSqlRequest = ref<{ id: number; tabId: string } | null>(null);
|
||||
const activeOutputView = ref<"result" | "summary" | "explain" | "chart" | "messages">("result");
|
||||
const activeOutputView = ref<"result" | "summary" | "explain" | "chart" | "messages" | "profile">("result");
|
||||
const newQueryContextSource = ref<"tab" | "sidebar">("tab");
|
||||
const queryEditorDdlTarget = ref<{ connectionId: string; database: string; catalog?: string; schema?: string; tableName: string; objectType?: ObjectSourceKind } | null>(null);
|
||||
const queryEditorObjectSourceTarget = ref<{
|
||||
@@ -3512,7 +3512,7 @@ onUnmounted(() => {
|
||||
:block-dangerous-redis-commands="blockDangerousRedisCommands"
|
||||
:zen-mode="isZenMode"
|
||||
@update:active-output-view="
|
||||
(tabId: string, view: 'result' | 'summary' | 'explain' | 'chart' | 'messages') => {
|
||||
(tabId: string, view: 'result' | 'summary' | 'explain' | 'chart' | 'messages' | 'profile') => {
|
||||
if (tabId === queryStore.activeTabId) activeOutputView = view;
|
||||
}
|
||||
"
|
||||
|
||||
@@ -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>
|
||||
+121
@@ -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];
|
||||
|
||||
@@ -125,7 +125,7 @@ export function useSqlExecution(deps: {
|
||||
activeConnection: ComputedRef<ConnectionConfig | undefined>;
|
||||
executableSql: ComputedRef<string>;
|
||||
resolveExecutableSql?: (snapshot?: SqlExecutionSnapshot, tab?: QueryTab) => Promise<string>;
|
||||
activeOutputView: Ref<"result" | "summary" | "explain" | "chart" | "messages">;
|
||||
activeOutputView: Ref<"result" | "summary" | "explain" | "chart" | "messages" | "profile">;
|
||||
blockDangerousRedisCommands?: Ref<boolean>;
|
||||
onMissingDatabase?: (tabId?: string) => void;
|
||||
requestDangerConfirmation?: (request: SqlExecutionDangerRequest) => Promise<boolean>;
|
||||
|
||||
@@ -2397,6 +2397,22 @@ export default {
|
||||
emptySql: "No SQL to explain",
|
||||
unsafe: "This first version only explains SELECT / WITH / TABLE / VALUES statements",
|
||||
},
|
||||
profile: {
|
||||
title: "Profile",
|
||||
emptyShards: "No shard profile data",
|
||||
emptyProfile: 'This response does not contain a `profile` section. Add `"profile": true` to the search body and re-run the query.',
|
||||
collapseAll: "Collapse all",
|
||||
expandAll: "Expand all",
|
||||
selfTime: "Self",
|
||||
totalTime: "Total",
|
||||
breakdown: "Phases",
|
||||
criticalPath: "Critical path",
|
||||
criticalPathTag: "Critical",
|
||||
shard: "Shard",
|
||||
searches: "searches",
|
||||
collapsedProtection: "Large profile: deep nodes start collapsed ({nodes} nodes)",
|
||||
legendSelfTime: "Self time excludes children; the bar shows the share of the shard's total query time.",
|
||||
},
|
||||
lineage: {
|
||||
title: "Field Lineage",
|
||||
open: "View Field Lineage",
|
||||
|
||||
@@ -2323,6 +2323,22 @@ export default withEnglishFallback({
|
||||
emptySql: "No hay SQL para analizar",
|
||||
unsafe: "Esta versión solo analiza sentencias SELECT / WITH / TABLE / VALUES",
|
||||
},
|
||||
profile: {
|
||||
title: "Perfil",
|
||||
emptyShards: "Sin datos de perfil de shards",
|
||||
emptyProfile: 'Esta respuesta no contiene una sección `profile`. Añade "profile": true al cuerpo de la búsqueda y vuelve a ejecutarla.',
|
||||
collapseAll: "Contraer todo",
|
||||
expandAll: "Expandir todo",
|
||||
selfTime: "Propio",
|
||||
totalTime: "Total",
|
||||
breakdown: "Fases",
|
||||
criticalPath: "Ruta crítica",
|
||||
criticalPathTag: "Crítico",
|
||||
shard: "Shard",
|
||||
searches: "búsquedas",
|
||||
collapsedProtection: "Perfil grande: los nodos profundos comienzan contraídos ({nodes} nodos)",
|
||||
legendSelfTime: "El tiempo propio excluye a los hijos; la barra muestra la proporción del tiempo total de consulta del shard.",
|
||||
},
|
||||
lineage: {
|
||||
title: "Linaje de campo",
|
||||
open: "Ver linaje de campo",
|
||||
|
||||
@@ -2321,6 +2321,22 @@ export default withEnglishFallback({
|
||||
emptySql: "Nessun SQL da spiegare",
|
||||
unsafe: "Questa prima versione spiega solo istruzioni SELECT / WITH / TABLE / VALUES",
|
||||
},
|
||||
profile: {
|
||||
title: "Profilo",
|
||||
emptyShards: "Nessun dato di profilo degli shard",
|
||||
emptyProfile: 'Questa risposta non contiene una sezione `profile`. Aggiungi "profile": true al corpo della ricerca ed esegui di nuovo la query.',
|
||||
collapseAll: "Comprimi tutto",
|
||||
expandAll: "Espandi tutto",
|
||||
selfTime: "Proprio",
|
||||
totalTime: "Totale",
|
||||
breakdown: "Fasi",
|
||||
criticalPath: "Percorso critico",
|
||||
criticalPathTag: "Critico",
|
||||
shard: "Shard",
|
||||
searches: "ricerche",
|
||||
collapsedProtection: "Profilo grande: i nodi profondi iniziano compressi ({nodes} nodi)",
|
||||
legendSelfTime: "Il tempo proprio esclude i figli; la barra mostra la quota del tempo di query totale dello shard.",
|
||||
},
|
||||
lineage: {
|
||||
title: "Lineage dei Campi",
|
||||
open: "Visualizza Lineage dei Campi",
|
||||
|
||||
@@ -2348,6 +2348,22 @@ export default withEnglishFallback({
|
||||
emptySql: "実行計画を表示するSQLがありません",
|
||||
unsafe: "この最初のバージョンではSELECT / WITH / TABLE / VALUES文のみの実行計画を表示します",
|
||||
},
|
||||
profile: {
|
||||
title: "プロファイラ",
|
||||
emptyShards: "シャードのプロファイルデータがありません",
|
||||
emptyProfile: 'このレスポンスに `profile` セクションがありません。検索ボディに "profile": true を追加して再実行してください。',
|
||||
collapseAll: "すべて折りたたむ",
|
||||
expandAll: "すべて展開",
|
||||
selfTime: "自身",
|
||||
totalTime: "合計",
|
||||
breakdown: "フェーズ",
|
||||
criticalPath: "クリティカルパス",
|
||||
criticalPathTag: "重要",
|
||||
shard: "シャード",
|
||||
searches: "回の検索",
|
||||
collapsedProtection: "大きいプロファイル: 深いノードは折りたたまれた状態で始まります({nodes} ノード)",
|
||||
legendSelfTime: "自身の時間は子を含みません。バーはシャードの総クエリ時間に占める割合を示します。",
|
||||
},
|
||||
lineage: {
|
||||
title: "フィールドリネージ",
|
||||
open: "フィールドリネージを表示",
|
||||
|
||||
@@ -2222,6 +2222,22 @@ export default withEnglishFallback({
|
||||
emptySql: "실행 계획을 볼 SQL이 없습니다",
|
||||
unsafe: "이 첫 번째 버전은 SELECT / WITH / TABLE / VALUES 구문만 지원합니다",
|
||||
},
|
||||
profile: {
|
||||
title: "프로파일러",
|
||||
emptyShards: "샤드 프로파일 데이터가 없습니다",
|
||||
emptyProfile: '이 응답에 `profile` 섹션이 없습니다. 검색 본문에 "profile": true를 추가하고 다시 실행하세요.',
|
||||
collapseAll: "모두 접기",
|
||||
expandAll: "모두 펼치기",
|
||||
selfTime: "자체",
|
||||
totalTime: "전체",
|
||||
breakdown: "단계",
|
||||
criticalPath: "임계 경로",
|
||||
criticalPathTag: "중요",
|
||||
shard: "샤드",
|
||||
searches: "회 검색",
|
||||
collapsedProtection: "큰 프로파일: 깊은 노드는 접힌 상태로 시작합니다 ({nodes}개 노드)",
|
||||
legendSelfTime: "자체 시간은 하위 노드를 제외합니다. 막대는 샤드 전체 쿼리 시간에서 차지하는 비율을 보여줍니다.",
|
||||
},
|
||||
lineage: {
|
||||
title: "필드 계보",
|
||||
open: "필드 계보 보기",
|
||||
|
||||
@@ -2323,6 +2323,22 @@ export default withEnglishFallback({
|
||||
emptySql: "Nenhum SQL para explicar",
|
||||
unsafe: "Esta primeira versão explica apenas instruções SELECT / WITH / TABLE / VALUES",
|
||||
},
|
||||
profile: {
|
||||
title: "Perfil",
|
||||
emptyShards: "Sem dados de perfil de shards",
|
||||
emptyProfile: 'Esta resposta não contém uma seção `profile`. Adicione "profile": true ao corpo da pesquisa e execute novamente a consulta.',
|
||||
collapseAll: "Recolher tudo",
|
||||
expandAll: "Expandir tudo",
|
||||
selfTime: "Próprio",
|
||||
totalTime: "Total",
|
||||
breakdown: "Fases",
|
||||
criticalPath: "Caminho crítico",
|
||||
criticalPathTag: "Crítico",
|
||||
shard: "Shard",
|
||||
searches: "buscas",
|
||||
collapsedProtection: "Perfil grande: nós profundos começam recolhidos ({nodes} nós)",
|
||||
legendSelfTime: "O tempo próprio exclui os filhos; a barra mostra a parcela do tempo total de consulta do shard.",
|
||||
},
|
||||
lineage: {
|
||||
title: "Linhagem de Campo",
|
||||
open: "Visualizar Linhagem de Campo",
|
||||
|
||||
@@ -2397,6 +2397,22 @@ export default withEnglishFallback({
|
||||
emptySql: "Açıklanacak SQL yok",
|
||||
unsafe: "Bu ilk sürüm yalnızca SELECT / WITH / TABLE / VALUES ifadelerini açıklar",
|
||||
},
|
||||
profile: {
|
||||
title: "Profil",
|
||||
emptyShards: "Parça profil verisi yok",
|
||||
emptyProfile: 'Bu yanıtta `profile` bölümü yok. Arama gövdesine "profile": true ekleyip sorguyu yeniden çalıştırın.',
|
||||
collapseAll: "Tümünü daralt",
|
||||
expandAll: "Tümünü genişlet",
|
||||
selfTime: "Kendi",
|
||||
totalTime: "Toplam",
|
||||
breakdown: "Aşamalar",
|
||||
criticalPath: "Kritik yol",
|
||||
criticalPathTag: "Kritik",
|
||||
shard: "Parça",
|
||||
searches: "arama",
|
||||
collapsedProtection: "Büyük profil: derin düğümler daraltılmış olarak başlar ({nodes} düğüm)",
|
||||
legendSelfTime: "Kendi süresi alt öğeleri hariç tutar; çubuk, parçanın toplam sorgu süresindeki payını gösterir.",
|
||||
},
|
||||
lineage: {
|
||||
title: "Alan Soy Ağacı",
|
||||
open: "Alan Soy Ağacını Görüntüle",
|
||||
|
||||
@@ -2320,6 +2320,22 @@ export default withEnglishFallback({
|
||||
emptySql: "当前没有可分析的 SQL",
|
||||
unsafe: "第一版执行计划仅支持 SELECT / WITH / TABLE / VALUES",
|
||||
},
|
||||
profile: {
|
||||
title: "查询剖析",
|
||||
emptyShards: "没有分片剖析数据",
|
||||
emptyProfile: '响应中没有 `profile` 字段。请在搜索请求体中加入 "profile": true 后重新执行。',
|
||||
collapseAll: "全部折叠",
|
||||
expandAll: "全部展开",
|
||||
selfTime: "自身耗时",
|
||||
totalTime: "总耗时",
|
||||
breakdown: "阶段",
|
||||
criticalPath: "关键路径",
|
||||
criticalPathTag: "关键",
|
||||
shard: "分片",
|
||||
searches: "次搜索",
|
||||
collapsedProtection: "剖析数据较大:深层节点默认折叠(共 {nodes} 个节点)",
|
||||
legendSelfTime: "自身耗时不含子节点;进度条表示占该分片总查询时间的比例。",
|
||||
},
|
||||
lineage: {
|
||||
title: "字段血缘",
|
||||
open: "查看字段血缘",
|
||||
|
||||
@@ -2321,6 +2321,22 @@ export default withEnglishFallback({
|
||||
emptySql: "目前沒有可分析的 SQL",
|
||||
unsafe: "第一版執行計畫僅支援 SELECT / WITH / TABLE / VALUES",
|
||||
},
|
||||
profile: {
|
||||
title: "查詢剖析",
|
||||
emptyShards: "沒有分片剖析資料",
|
||||
emptyProfile: '回應中沒有 `profile` 欄位。請在搜尋請求體中加入 "profile": true 後重新執行。',
|
||||
collapseAll: "全部摺疊",
|
||||
expandAll: "全部展開",
|
||||
selfTime: "自身耗時",
|
||||
totalTime: "總耗時",
|
||||
breakdown: "階段",
|
||||
criticalPath: "關鍵路徑",
|
||||
criticalPathTag: "關鍵",
|
||||
shard: "分片",
|
||||
searches: "次搜尋",
|
||||
collapsedProtection: "剖析資料較大:深層節點預設摺疊(共 {nodes} 個節點)",
|
||||
legendSelfTime: "自身耗時不含子節點;進度條表示佔該分片總查詢時間的比例。",
|
||||
},
|
||||
lineage: {
|
||||
title: "欄位血緣",
|
||||
open: "檢視欄位血緣",
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { countProfileNodes, elasticsearchProfileBodyForResult, formatProfileNanos, maxProfileDepth, parseElasticsearchProfile } from "@/lib/elasticsearch/elasticsearchProfile";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
const PROFILE_BODY = JSON.stringify({
|
||||
took: 42,
|
||||
timed_out: false,
|
||||
profile: {
|
||||
shards: [
|
||||
{
|
||||
id: "[2aE02wQiS1y8JQxQ4YrW3w][products][0]",
|
||||
searches: [
|
||||
{
|
||||
query: [
|
||||
{
|
||||
type: "BooleanQuery",
|
||||
description: "title:text description:text",
|
||||
time_in_nanos: 1234567,
|
||||
breakdown: {
|
||||
build_scorer_count: 1,
|
||||
build_scorer: 40000,
|
||||
next_doc: 300000,
|
||||
score: 450000,
|
||||
match: 0,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "TermQuery",
|
||||
description: "title:dbx",
|
||||
time_in_nanos: 500000,
|
||||
breakdown: { score: 250000, next_doc: 100000 },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
type: "TermQuery",
|
||||
description: "description:profiler",
|
||||
time_in_nanos: 300000,
|
||||
breakdown: { score: 150000, next_doc: 50000 },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "[2aE02wQiS1y8JQxQ4YrW3w][products][1]",
|
||||
searches: [
|
||||
{
|
||||
query: [
|
||||
{
|
||||
type: "TermQuery",
|
||||
description: "title:dbx",
|
||||
time_in_nanos: 900000,
|
||||
breakdown: { score: 300000 },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
describe("parseElasticsearchProfile", () => {
|
||||
it("maps ES profile query nodes onto the profiler tree", () => {
|
||||
const parsed = parseElasticsearchProfile(PROFILE_BODY);
|
||||
expect(parsed).not.toBeNull();
|
||||
const root = parsed!.shards[0]!.tree;
|
||||
expect(root.type).toBe("BooleanQuery");
|
||||
expect(root.description).toBe("title:text description:text");
|
||||
expect(root.timeInNanos).toBe(1234567);
|
||||
expect(root.children).toHaveLength(2);
|
||||
expect(root.children[0]!.type).toBe("TermQuery");
|
||||
expect(root.children[0]!.timeInNanos).toBe(500000);
|
||||
});
|
||||
|
||||
it("computes selfTime as cumulative time minus children", () => {
|
||||
const root = parseElasticsearchProfile(PROFILE_BODY)!.shards[0]!.tree;
|
||||
expect(root.selfTimeInNanos).toBe(1234567 - 500000 - 300000);
|
||||
expect(root.children[0]!.selfTimeInNanos).toBe(500000);
|
||||
expect(root.selfTimeInNanos).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("clamps selfTime to zero when children exceed the parent", () => {
|
||||
const body = JSON.stringify({
|
||||
profile: {
|
||||
shards: [
|
||||
{
|
||||
id: "shard-0",
|
||||
searches: [{ query: [{ type: "ParentQuery", time_in_nanos: 100, children: [{ type: "ChildQuery", time_in_nanos: 250, children: [] }] }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const tree = parseElasticsearchProfile(body)!.shards[0]!.tree;
|
||||
expect(tree.selfTimeInNanos).toBe(0);
|
||||
expect(tree.costShare).toBe(0);
|
||||
expect(tree.heatLevel).toBe("none");
|
||||
});
|
||||
|
||||
it("computes costShare relative to the tree root time and assigns heat levels", () => {
|
||||
const tree = parseElasticsearchProfile(PROFILE_BODY)!.shards[0]!.tree;
|
||||
// selfCosts telescope to the root: 1234567.
|
||||
expect(tree.costShare).toBeCloseTo((1234567 - 800000) / 1234567, 6);
|
||||
expect(tree.children[0]!.costShare).toBeCloseTo(500000 / 1234567, 6);
|
||||
expect(tree.children[1]!.costShare).toBeCloseTo(300000 / 1234567, 6);
|
||||
// ~35% self share is >20% → hot, matching planCanvas thresholds.
|
||||
expect(tree.heatLevel).toBe("hot");
|
||||
});
|
||||
|
||||
it("marks the critical path recursively to the leaf", () => {
|
||||
const tree = parseElasticsearchProfile(PROFILE_BODY)!.shards[0]!.tree;
|
||||
const [left, right] = tree.children;
|
||||
// The highest-cost child is TermQuery title:dbx (500000).
|
||||
expect(tree.isCriticalPath).toBe(true);
|
||||
expect(left!.isCriticalPath).toBe(true);
|
||||
expect(right!.isCriticalPath).toBe(false);
|
||||
});
|
||||
|
||||
it("selects the critical path by cumulative time, not self time", () => {
|
||||
// Regression: the longest branch has most of its cost in descendants, while
|
||||
// a sibling has more local (self) work. Selecting by costShare/selfTime
|
||||
// would wrongly mark the sibling as the critical path.
|
||||
const body = JSON.stringify({
|
||||
profile: {
|
||||
shards: [
|
||||
{
|
||||
id: "s0",
|
||||
searches: [
|
||||
{
|
||||
query: [
|
||||
{
|
||||
type: "BooleanQuery",
|
||||
time_in_nanos: 1000,
|
||||
children: [
|
||||
{
|
||||
type: "SpanQuery",
|
||||
description: "cheap node, deep cost",
|
||||
time_in_nanos: 900,
|
||||
children: [{ type: "TermQuery", description: "deep leaf", time_in_nanos: 899, children: [] }],
|
||||
},
|
||||
{
|
||||
type: "TermQuery",
|
||||
description: "local work, short branch",
|
||||
time_in_nanos: 100,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const tree = parseElasticsearchProfile(body)!.shards[0]!.tree;
|
||||
const [deep, local] = tree.children;
|
||||
// SpanQuery (900 cumulative, 1 self) has less self time than TermQuery
|
||||
// (100 self) but is the genuinely longest branch — the critical path must
|
||||
// follow cumulative time all the way to the deep leaf.
|
||||
expect(deep!.isCriticalPath).toBe(true);
|
||||
expect(deep!.children[0]!.isCriticalPath).toBe(true);
|
||||
expect(local!.isCriticalPath).toBe(false);
|
||||
});
|
||||
|
||||
it("sorts shards so the highest total is first", () => {
|
||||
const parsed = parseElasticsearchProfile(PROFILE_BODY)!;
|
||||
expect(parsed.shards).toHaveLength(2);
|
||||
expect(parsed.shards[0]!.totalTimeInNanos).toBeGreaterThanOrEqual(parsed.shards[1]!.totalTimeInNanos);
|
||||
expect(parsed.shards[0]!.id).toContain("[0]");
|
||||
});
|
||||
|
||||
it("merges multiple searches into one tree per shard", () => {
|
||||
const body = JSON.stringify({
|
||||
profile: {
|
||||
shards: [
|
||||
{
|
||||
id: "s0",
|
||||
searches: [{ query: [{ type: "TermQuery", time_in_nanos: 100, children: [] }] }, { query: [{ type: "MatchAllQuery", time_in_nanos: 50, children: [] }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const shard = parseElasticsearchProfile(body)!.shards[0]!;
|
||||
expect(shard.tree.type).toBe("search");
|
||||
expect(shard.tree.children).toHaveLength(2);
|
||||
expect(shard.tree.timeInNanos).toBe(150);
|
||||
expect(shard.searchCount).toBe(2);
|
||||
});
|
||||
|
||||
it("parses time_in_nanos beyond Number.MAX_SAFE_INTEGER without corrupting the tree", () => {
|
||||
// 2^53 is exactly representable but exceeds Number.MAX_SAFE_INTEGER, so the
|
||||
// safe JSON parser keeps the literal verbatim and the tree still parses to a
|
||||
// finite, usable timing instead of dropping or re-quoting the value.
|
||||
const huge = 2 ** 53;
|
||||
const body = JSON.stringify({
|
||||
profile: { shards: [{ id: "s0", searches: [{ query: [{ type: "BigQuery", time_in_nanos: huge, children: [] }] }] }] },
|
||||
});
|
||||
const shard = parseElasticsearchProfile(body)!.shards[0]!;
|
||||
expect(shard.tree.timeInNanos).toBe(huge);
|
||||
expect(shard.tree.selfTimeInNanos).toBe(huge);
|
||||
});
|
||||
|
||||
it("returns null for malformed JSON", () => {
|
||||
expect(parseElasticsearchProfile("not json")).toBeNull();
|
||||
expect(parseElasticsearchProfile("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when profile or shards are missing", () => {
|
||||
expect(parseElasticsearchProfile(JSON.stringify({ took: 1 }))).toBeNull();
|
||||
expect(parseElasticsearchProfile(JSON.stringify({ profile: {} }))).toBeNull();
|
||||
expect(parseElasticsearchProfile(JSON.stringify({ profile: { shards: [] } }))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when every shard has no query nodes", () => {
|
||||
const body = JSON.stringify({ profile: { shards: [{ id: "s0", searches: [{ query: [] }] }] } });
|
||||
expect(parseElasticsearchProfile(body)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("elasticsearchProfileBodyForResult", () => {
|
||||
const esResult = (partial: Partial<QueryResult>): QueryResult => ({
|
||||
columns: [],
|
||||
rows: [],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
...partial,
|
||||
});
|
||||
|
||||
it("reads the preserved raw body for ES-compatible databases", () => {
|
||||
const body = '{"profile":{"shards":[]}}';
|
||||
const result = esResult({ elasticsearch_raw_body: body });
|
||||
expect(elasticsearchProfileBodyForResult("elasticsearch", result)).toBe(body);
|
||||
expect(elasticsearchProfileBodyForResult("easysearch", result)).toBe(body);
|
||||
});
|
||||
|
||||
it("falls back to the two-column status/response row shape", () => {
|
||||
const result = esResult({ columns: ["status", "response"], rows: [[200, '{"profile":{}}']] });
|
||||
expect(elasticsearchProfileBodyForResult("elasticsearch", result)).toBe('{"profile":{}}');
|
||||
});
|
||||
|
||||
it("returns null for non-ES databases and empty bodies", () => {
|
||||
const result = esResult({ elasticsearch_raw_body: '{"profile":{}}' });
|
||||
expect(elasticsearchProfileBodyForResult("mysql", result)).toBeNull();
|
||||
expect(elasticsearchProfileBodyForResult("elasticsearch", esResult({}))).toBeNull();
|
||||
expect(elasticsearchProfileBodyForResult("elasticsearch", esResult({ elasticsearch_raw_body: "" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("profile helpers", () => {
|
||||
it("counts nodes and measures depth", () => {
|
||||
const tree = parseElasticsearchProfile(PROFILE_BODY)!.shards[0]!.tree;
|
||||
expect(countProfileNodes(tree)).toBe(3);
|
||||
expect(maxProfileDepth(tree)).toBe(1);
|
||||
});
|
||||
|
||||
it("formats nanoseconds into readable units", () => {
|
||||
expect(formatProfileNanos(0)).toBe("0ns");
|
||||
expect(formatProfileNanos(500)).toBe("500ns");
|
||||
expect(formatProfileNanos(1500)).toBe("1.5µs");
|
||||
expect(formatProfileNanos(2_500_000)).toBe("2.5ms");
|
||||
expect(formatProfileNanos(1.2e9)).toBe("1.2s");
|
||||
});
|
||||
|
||||
it("shares the planCanvas heatLevel thresholds", () => {
|
||||
// Assert the wiring: shares >20% → hot, 5–20% → warm, else cool.
|
||||
const body = JSON.stringify({
|
||||
profile: {
|
||||
shards: [
|
||||
{
|
||||
id: "s0",
|
||||
searches: [
|
||||
{
|
||||
query: [
|
||||
{
|
||||
type: "Root",
|
||||
time_in_nanos: 1000,
|
||||
children: [
|
||||
{ type: "Hot", time_in_nanos: 250, children: [] },
|
||||
{ type: "Warm", time_in_nanos: 100, children: [] },
|
||||
{ type: "Cool", time_in_nanos: 10, children: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const tree = parseElasticsearchProfile(body)!.shards[0]!.tree;
|
||||
expect(tree.children[0]!.heatLevel).toBe("hot");
|
||||
expect(tree.children[1]!.heatLevel).toBe("warm");
|
||||
expect(tree.children[2]!.heatLevel).toBe("cool");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { QueryTab } from "@/types/database";
|
||||
import type { SavedOpenTab } from "@/lib/app/openTabsPersistence";
|
||||
|
||||
export type DetachedOutputView = "result" | "summary" | "explain" | "chart" | "messages";
|
||||
export type DetachedOutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile";
|
||||
|
||||
export interface DetachedTabRuntimeState {
|
||||
editorViewport?: QueryTab["editorViewport"];
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
|
||||
import { heatLevel as planHeatLevel } from "@/lib/diagram/planCanvas";
|
||||
import { isElasticsearchCompatibleDatabaseType, type DatabaseType, type QueryResult } from "@/types/database";
|
||||
|
||||
/**
|
||||
* Visual Search Profiler for Elasticsearch `_search?profile=true` responses.
|
||||
*
|
||||
* Parses the `profile.shards[].searches[].query[]` timing tree so the frontend
|
||||
* can render it as a collapsible tree with cost-share progress bars. The
|
||||
* response body is preserved end-to-end by the REST-DSL query path
|
||||
* (`QueryResult.elasticsearch_raw_body` or the two-column status/response
|
||||
* result), so this is a pure-frontend feature — no Rust changes.
|
||||
*
|
||||
* `time_in_nanos` is cumulative over a node's subtree; `breakdown` is the
|
||||
* node's own per-phase time. `selfTimeInNanos = timeInNanos − Σ children`,
|
||||
* matching the self-cost accounting used by the SQL explain `planCanvas`.
|
||||
*/
|
||||
|
||||
export type ElasticsearchProfileHeatLevel = "none" | "cool" | "warm" | "hot";
|
||||
|
||||
export interface ElasticsearchProfileNode {
|
||||
/** Lucene query type, e.g. "BooleanQuery" / "TermQuery". */
|
||||
type: string;
|
||||
/** Human-readable query description when Elasticsearch provides one. */
|
||||
description?: string;
|
||||
/** Cumulative time including children, in nanoseconds. */
|
||||
timeInNanos: number;
|
||||
/** The node's own per-phase timings (next_doc / build_scorer / score / …). */
|
||||
breakdown?: Record<string, number>;
|
||||
children: ElasticsearchProfileNode[];
|
||||
/** timeInNanos minus the children's cumulative time, clamped to ≥ 0. */
|
||||
selfTimeInNanos: number;
|
||||
/** selfTime / total self time of the shard's tree (planCanvas semantics). */
|
||||
costShare: number;
|
||||
heatLevel: ElasticsearchProfileHeatLevel;
|
||||
/** Marked along the single highest-cost child chain down to a leaf. */
|
||||
isCriticalPath: boolean;
|
||||
}
|
||||
|
||||
export interface ElasticsearchProfileShard {
|
||||
id: string;
|
||||
tree: ElasticsearchProfileNode;
|
||||
totalTimeInNanos: number;
|
||||
/** Number of _search requests profiled on this shard (>1 when merged under a wrapper node). */
|
||||
searchCount?: number;
|
||||
}
|
||||
|
||||
export interface ElasticsearchProfileData {
|
||||
/** Shards sorted by total time, highest first. */
|
||||
shards: ElasticsearchProfileShard[];
|
||||
/** The parsed `profile` section, kept for a potential raw view. */
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
interface RawQueryNode {
|
||||
type?: unknown;
|
||||
description?: unknown;
|
||||
time_in_nanos?: unknown;
|
||||
breakdown?: unknown;
|
||||
children?: unknown;
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
/** Reads a numeric literal, including large numbers preserved by safeJsonFormat. */
|
||||
function readProfileNumber(value: unknown): number {
|
||||
if (isLosslessJsonNumber(value)) {
|
||||
const parsed = Number(value.raw);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function mapQueryNode(raw: RawQueryNode): ElasticsearchProfileNode {
|
||||
const childrenRaw = Array.isArray(raw.children) ? raw.children : [];
|
||||
const children: ElasticsearchProfileNode[] = [];
|
||||
for (const child of childrenRaw) {
|
||||
const record = readRecord(child);
|
||||
if (record) children.push(mapQueryNode(record as RawQueryNode));
|
||||
}
|
||||
|
||||
const timeInNanos = Math.max(0, readProfileNumber(raw.time_in_nanos));
|
||||
const childrenTotal = children.reduce((sum, child) => sum + child.timeInNanos, 0);
|
||||
const selfTimeInNanos = Math.max(0, timeInNanos - childrenTotal);
|
||||
|
||||
let breakdown: Record<string, number> | undefined;
|
||||
const breakdownRaw = readRecord(raw.breakdown);
|
||||
if (breakdownRaw) {
|
||||
const entries = Object.entries(breakdownRaw)
|
||||
.map(([name, value]) => [name, readProfileNumber(value)] as const)
|
||||
.filter(([, value]) => value > 0);
|
||||
if (entries.length > 0) breakdown = Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
return {
|
||||
type: typeof raw.type === "string" && raw.type ? raw.type : "Query",
|
||||
description: typeof raw.description === "string" && raw.description ? raw.description : undefined,
|
||||
timeInNanos,
|
||||
breakdown,
|
||||
children,
|
||||
selfTimeInNanos,
|
||||
costShare: 0,
|
||||
heatLevel: "none",
|
||||
isCriticalPath: false,
|
||||
};
|
||||
}
|
||||
|
||||
function applyCostShares(node: ElasticsearchProfileNode, denominator: number): void {
|
||||
node.costShare = node.selfTimeInNanos / denominator;
|
||||
// Zero-cost nodes render a plain label, not a green "cool" bar.
|
||||
node.heatLevel = node.selfTimeInNanos > 0 ? planHeatLevel(node.costShare) : "none";
|
||||
for (const child of node.children) applyCostShares(child, denominator);
|
||||
}
|
||||
|
||||
function markCriticalPath(node: ElasticsearchProfileNode): void {
|
||||
node.isCriticalPath = true;
|
||||
if (node.children.length === 0) return;
|
||||
let best = node.children[0];
|
||||
for (const child of node.children) {
|
||||
// Select by cumulative time_in_nanos (inclusive of descendants), not self
|
||||
// time: a long branch whose cost is mostly in descendants must win over a
|
||||
// child with more local work but a shorter subtree. Ties keep the first
|
||||
// child (stable, deterministic).
|
||||
if (child.timeInNanos > best.timeInNanos) best = child;
|
||||
}
|
||||
markCriticalPath(best);
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates costShare/heatLevel/isCriticalPath for the tree. The total self
|
||||
* time telescopes to the tree root's cumulative time, so `root.timeInNanos`
|
||||
* is the same denominator planCanvas uses for SQL explain nodes.
|
||||
*/
|
||||
function finalizeTree(node: ElasticsearchProfileNode): void {
|
||||
if (node.timeInNanos > 0) applyCostShares(node, node.timeInNanos);
|
||||
markCriticalPath(node);
|
||||
}
|
||||
|
||||
interface BuiltShard {
|
||||
tree: ElasticsearchProfileNode;
|
||||
total: number;
|
||||
searchCount: number;
|
||||
}
|
||||
|
||||
/** Builds a single query tree for one shard from its `searches[]` array. */
|
||||
function buildShard(searches: unknown): BuiltShard | null {
|
||||
const searchList = Array.isArray(searches) ? searches : [];
|
||||
const queryRoots: ElasticsearchProfileNode[] = [];
|
||||
for (const search of searchList) {
|
||||
const record = readRecord(search);
|
||||
if (!record) continue;
|
||||
const queryArray = Array.isArray(record.query) ? record.query : [];
|
||||
for (const query of queryArray) {
|
||||
const queryRecord = readRecord(query);
|
||||
if (queryRecord) queryRoots.push(mapQueryNode(queryRecord as RawQueryNode));
|
||||
}
|
||||
}
|
||||
if (queryRoots.length === 0) return null;
|
||||
|
||||
const total = queryRoots.reduce((sum, root) => sum + root.timeInNanos, 0);
|
||||
// A single search keeps its real query root as the tree; multiple searches
|
||||
// are merged under a synthetic "search" container so cost shares stay on one
|
||||
// comparable scale.
|
||||
let tree: ElasticsearchProfileNode;
|
||||
if (queryRoots.length === 1) {
|
||||
tree = queryRoots[0];
|
||||
} else {
|
||||
tree = {
|
||||
type: "search",
|
||||
timeInNanos: total,
|
||||
children: queryRoots,
|
||||
selfTimeInNanos: 0,
|
||||
costShare: 0,
|
||||
heatLevel: "none",
|
||||
isCriticalPath: false,
|
||||
};
|
||||
}
|
||||
finalizeTree(tree);
|
||||
return { tree, total, searchCount: queryRoots.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an Elasticsearch `_search` response body into the profiler tree.
|
||||
* Returns `null` for malformed JSON, a missing/invalid `profile` section,
|
||||
* absent/empty `shards`, or searches with no query nodes — never throws.
|
||||
*/
|
||||
export function parseElasticsearchProfile(body: string): ElasticsearchProfileData | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseJsonPreservingLargeNumbers(body);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const root = readRecord(parsed);
|
||||
if (!root) return null;
|
||||
const profile = readRecord(root.profile);
|
||||
if (!profile) return null;
|
||||
|
||||
const shardsRaw = profile.shards;
|
||||
if (!Array.isArray(shardsRaw) || shardsRaw.length === 0) return null;
|
||||
|
||||
const shards: ElasticsearchProfileShard[] = [];
|
||||
for (const shardRaw of shardsRaw) {
|
||||
const record = readRecord(shardRaw);
|
||||
if (!record) continue;
|
||||
const id = typeof record.id === "string" && record.id ? record.id : String(shards.length + 1);
|
||||
const built = buildShard(record.searches);
|
||||
if (!built) continue;
|
||||
shards.push({ id, tree: built.tree, totalTimeInNanos: built.total, searchCount: built.searchCount });
|
||||
}
|
||||
if (shards.length === 0) return null;
|
||||
|
||||
shards.sort((a, b) => b.totalTimeInNanos - a.totalTimeInNanos);
|
||||
return { shards, raw: profile };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the ES response body from a query result, either the preserved raw
|
||||
* REST body (`elasticsearch_raw_body`) or the two-column status/response
|
||||
* single-row shape used for large/error responses. Non-ES databases return null.
|
||||
*/
|
||||
export function elasticsearchProfileBodyForResult(databaseType: DatabaseType | undefined, result: QueryResult | undefined): string | null {
|
||||
if (!isElasticsearchCompatibleDatabaseType(databaseType) || !result) return null;
|
||||
if (typeof result.elasticsearch_raw_body === "string" && result.elasticsearch_raw_body.length > 0) return result.elasticsearch_raw_body;
|
||||
|
||||
if (result.columns.length === 2 && result.columns[0] === "status" && result.columns[1] === "response" && result.rows.length === 1) {
|
||||
const body = result.rows[0]?.[1];
|
||||
if (typeof body === "string" && body.length > 0) return body;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Counts every node in the tree, including the root. */
|
||||
export function countProfileNodes(node: ElasticsearchProfileNode): number {
|
||||
let count = 1;
|
||||
for (const child of node.children) count += countProfileNodes(child);
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Maximum depth below the given node (leaf = 0). */
|
||||
export function maxProfileDepth(node: ElasticsearchProfileNode): number {
|
||||
if (node.children.length === 0) return 0;
|
||||
let max = 0;
|
||||
for (const child of node.children) max = Math.max(max, maxProfileDepth(child));
|
||||
return 1 + max;
|
||||
}
|
||||
|
||||
function trimZero(value: string): string {
|
||||
return value.replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, "");
|
||||
}
|
||||
|
||||
/** Formats nanoseconds as ns / µs / ms / s with sensible rounding. */
|
||||
export function formatProfileNanos(nanos: number): string {
|
||||
const value = Math.max(0, nanos);
|
||||
if (value >= 1e9) return `${trimZero((value / 1e9).toFixed(2))}s`;
|
||||
if (value >= 1e6) {
|
||||
const ms = value / 1e6;
|
||||
return `${trimZero(ms.toFixed(ms >= 100 ? 0 : 1))}ms`;
|
||||
}
|
||||
if (value >= 1e3) {
|
||||
const us = value / 1e3;
|
||||
return `${trimZero(us.toFixed(us >= 100 ? 0 : 1))}µs`;
|
||||
}
|
||||
return `${value}ns`;
|
||||
}
|
||||
Reference in new issue
Block a user