diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 95cd999d1d..0d27e45005 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -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; } " diff --git a/apps/desktop/src/components/common/ElasticsearchProfileNodeRow.vue b/apps/desktop/src/components/common/ElasticsearchProfileNodeRow.vue new file mode 100644 index 0000000000..8c4bf19a0b --- /dev/null +++ b/apps/desktop/src/components/common/ElasticsearchProfileNodeRow.vue @@ -0,0 +1,109 @@ + + + diff --git a/apps/desktop/src/components/common/ElasticsearchProfilePanel.vue b/apps/desktop/src/components/common/ElasticsearchProfilePanel.vue new file mode 100644 index 0000000000..acfd904248 --- /dev/null +++ b/apps/desktop/src/components/common/ElasticsearchProfilePanel.vue @@ -0,0 +1,93 @@ + + + diff --git a/apps/desktop/src/components/common/__tests__/ElasticsearchProfilePanel.spec.ts b/apps/desktop/src/components/common/__tests__/ElasticsearchProfilePanel.spec.ts new file mode 100644 index 0000000000..356d9d3db5 --- /dev/null +++ b/apps/desktop/src/components/common/__tests__/ElasticsearchProfilePanel.spec.ts @@ -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 | 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("[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"); + }); +}); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 67b99d8fa4..d8c80d6a81 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -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" /> @@ -1695,6 +1707,8 @@ defineExpose({ :table-error="activeTab.explainTableError" /> + +
@@ -1885,16 +1899,31 @@ defineExpose({ {{ showElasticsearchRawJson ? t("tabs.tableData") : t("redis.jsonView") }} + diff --git a/apps/desktop/src/components/layout/QueryResultToolbarActions.vue b/apps/desktop/src/components/layout/QueryResultToolbarActions.vue index cc12b58cf2..be3261de03 100644 --- a/apps/desktop/src/components/layout/QueryResultToolbarActions.vue +++ b/apps/desktop/src/components/layout/QueryResultToolbarActions.vue @@ -1,15 +1,16 @@