From 8f3134dce8df3f7800aabcb1f40493c18ed2b285 Mon Sep 17 00:00:00 2001 From: onenewcode Date: Mon, 31 Aug 2026 10:31:35 -0400 Subject: [PATCH] feat(elasticsearch): add mapping, settings, stats and clear actions --- .../components/document/DocumentBrowser.vue | 19 ++ .../components/editor/DangerConfirmDialog.vue | 9 +- .../ElasticsearchIndexMetadataDialog.vue | 121 ++++++++++++ .../src/components/sidebar/ConnectionTree.vue | 29 ++- .../sidebar/SidebarTreeRuntimeHost.vue | 68 +++++++ ...untimeHost.elasticsearchClearGuard.spec.ts | 64 ++++++ .../components/sidebar/sidebarAsyncDialogs.ts | 1 + .../sidebar/sidebarTreeDialogState.ts | 7 + ...cificMutationRuntime.elasticsearch.spec.ts | 156 +++++++++++++++ ...eSidebarDatabaseSpecificMutationRuntime.ts | 56 ++++++ apps/desktop/src/i18n/locales/en.ts | 11 ++ apps/desktop/src/i18n/locales/es.ts | 11 ++ apps/desktop/src/i18n/locales/it.ts | 11 ++ apps/desktop/src/i18n/locales/ja.ts | 11 ++ apps/desktop/src/i18n/locales/ko.ts | 11 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 11 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 11 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 11 ++ ...lasticsearchIndexTransportContract.spec.ts | 58 ++++++ apps/desktop/src/lib/backend/api.ts | 2 + apps/desktop/src/lib/backend/http.ts | 17 ++ apps/desktop/src/lib/backend/tauri.ts | 27 +++ .../elasticsearchIndexActions.spec.ts | 71 +++++++ .../lib/sidebar/elasticsearchIndexActions.ts | 104 ++++++++++ .../src/lib/sidebar/sidebarDangerDialog.ts | 2 + crates/dbx-core/src/db/easysearch_driver.rs | 19 ++ .../dbx-core/src/db/elasticsearch_driver.rs | 186 ++++++++++++++++++ crates/dbx-core/src/document_ops.rs | 89 +++++++++ crates/dbx-web/src/main.rs | 8 + crates/dbx-web/src/routes/document_store.rs | 42 ++++ src-tauri/src/commands/document_cmd.rs | 20 ++ src-tauri/src/lib.rs | 2 + 32 files changed, 1262 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/components/objects/ElasticsearchIndexMetadataDialog.vue create mode 100644 apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.elasticsearchClearGuard.spec.ts create mode 100644 apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.elasticsearch.spec.ts create mode 100644 apps/desktop/src/lib/backend/__tests__/elasticsearchIndexTransportContract.spec.ts create mode 100644 apps/desktop/src/lib/sidebar/__tests__/elasticsearchIndexActions.spec.ts create mode 100644 apps/desktop/src/lib/sidebar/elasticsearchIndexActions.ts diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index 2c33213fb..b8a9718bf 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -88,6 +88,7 @@ import type { GridNewRowMeta } from "@/lib/dataGrid/gridNewRowPlacement"; import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize"; import { documentDataGridColumnLayoutScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage"; import { documentGridColumnVisibilityScopeKey, migrateDocumentGridColumnVisibilityToLayout } from "@/lib/document/documentGridColumnVisibilityStorage"; +import { matchesElasticsearchIndexPattern, subscribeElasticsearchIndexCleared, type ElasticsearchIndexClearedDetail } from "@/lib/sidebar/elasticsearchIndexActions"; import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore } from "@/stores/settingsStore"; import { useToast } from "@/composables/useToast"; import JsonEditNode from "./JsonEditNode.vue"; @@ -2062,8 +2063,24 @@ async function loadDynamoDbTableDescription() { } } +/** + * The sidebar's "clear index data" action deletes documents behind this tab's + * back, so an open browser would keep listing rows that no longer exist. + * Reload when the cleared index is the one on screen. + */ +function handleElasticsearchIndexCleared(detail: ElasticsearchIndexClearedDetail) { + if (detail.connectionId !== props.connectionId) return; + // Clearing a grouped node deletes from every index its pattern matches, so a + // tab open on any concrete index under the pattern must refresh as well. + if (detail.index !== props.collection && !matchesElasticsearchIndexPattern(detail.index, props.collection)) return; + void refreshDocuments(); +} + +let unsubscribeElasticsearchIndexCleared: (() => void) | undefined; + onMounted(async () => { window.addEventListener("pointerdown", handleDocumentBrowserPointerDown, true); + unsubscribeElasticsearchIndexCleared = subscribeElasticsearchIndexCleared(handleElasticsearchIndexCleared); try { await connectionStore.ensureConnected(props.connectionId); } catch (e) { @@ -2078,6 +2095,8 @@ onMounted(async () => { }); onBeforeUnmount(() => { window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true); + unsubscribeElasticsearchIndexCleared?.(); + unsubscribeElasticsearchIndexCleared = undefined; if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value); documentRequestGeneration++; loadedDocumentQueryTotalCountRequest = undefined; diff --git a/apps/desktop/src/components/editor/DangerConfirmDialog.vue b/apps/desktop/src/components/editor/DangerConfirmDialog.vue index 876a74de2..b5711207a 100644 --- a/apps/desktop/src/components/editor/DangerConfirmDialog.vue +++ b/apps/desktop/src/components/editor/DangerConfirmDialog.vue @@ -35,6 +35,8 @@ const props = withDefaults( closeOnConfirm?: boolean; cancelable?: boolean; cancelRunningLoading?: boolean; + /** Holds the confirm button back until the caller's own precondition is met (e.g. the operator typed the target name). */ + confirmDisabled?: boolean; }>(), { sql: "", @@ -49,6 +51,7 @@ const props = withDefaults( closeOnConfirm: true, cancelable: false, cancelRunningLoading: false, + confirmDisabled: false, }, ); @@ -71,7 +74,9 @@ const dialogOpen = computed({ }); function onConfirm() { - if (props.loading) return; + // Guard here as well as on the button: a disabled button still fires on some + // synthetic/keyboard paths, and this one gates a destructive operation. + if (props.loading || props.confirmDisabled) return; if (props.closeOnConfirm) open.value = false; emit("confirm"); } @@ -129,7 +134,7 @@ async function copyFullCode() { {{ t("dangerDialog.cancelRunning") }} - diff --git a/apps/desktop/src/components/objects/ElasticsearchIndexMetadataDialog.vue b/apps/desktop/src/components/objects/ElasticsearchIndexMetadataDialog.vue new file mode 100644 index 000000000..4657b7fee --- /dev/null +++ b/apps/desktop/src/components/objects/ElasticsearchIndexMetadataDialog.vue @@ -0,0 +1,121 @@ + + + diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 244f08c8d..7ae30e1de 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -8,6 +8,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useToast } from "@/composables/useToast"; import type { ObjectSourceKind, QueryTab, TableInfo, TableNameFilter, TreeNode, TreeNodeType } from "@/types/database"; +import type { ElasticsearchIndexMetadataKind } from "@/lib/backend/tauri"; import { createSidebarSearchSubtreePreserver, filterSidebarSearchRootsByConnectionState, @@ -79,7 +80,7 @@ import { createSidebarActionTarget, findSidebarActionTarget, matchesSidebarActio import { syncSidebarTreeNodeExpansion } from "@/lib/sidebar/sidebarTreeExpansion"; import type { SidebarDangerDialogOption, SidebarDangerDialogRequest } from "@/lib/sidebar/sidebarDangerDialog"; import { resetSidebarTreeDialogState, sidebarDangerRunningExecutionId } from "./sidebarTreeDialogState"; -import { SidebarDangerConfirmDialog, SidebarDdlViewDialog, SidebarObjectSourceDialog, SidebarProcedureExecutionDialog, SidebarVisibleDatabasesDialog, SidebarVisibleNacosNamespacesDialog, SidebarVisibleSchemasDialog } from "./sidebarAsyncDialogs"; +import { SidebarDangerConfirmDialog, SidebarDdlViewDialog, SidebarElasticsearchIndexMetadataDialog, SidebarObjectSourceDialog, SidebarProcedureExecutionDialog, SidebarVisibleDatabasesDialog, SidebarVisibleNacosNamespacesDialog, SidebarVisibleSchemasDialog } from "./sidebarAsyncDialogs"; import { sortConnectionListForDisplay } from "@/lib/sidebar/connectionListSort"; import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay"; import { alignedSidebarCommentLabelWidths, isSidebarCommentAlignableNode, sidebarTreeNaturalContentWidth, sidebarTreeNodeComment, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout"; @@ -133,6 +134,8 @@ const sidebarTreeRuntime = createSidebarTreeRuntime(); const sidebarTreeRuntimeInitialNode: TreeNode = { id: "__sidebar-runtime__", label: "", type: "connection-group" }; const sidebarDdlTarget = ref(null); const sidebarDdlOpen = ref(false); +const sidebarElasticsearchIndexMetadataTarget = ref<{ node: TreeNode; kind: ElasticsearchIndexMetadataKind } | null>(null); +const sidebarElasticsearchIndexMetadataOpen = ref(false); const sidebarObjectSourceTarget = ref<{ node: TreeNode; initialEditing: boolean } | null>(null); const sidebarObjectSourceOpen = ref(false); const sidebarProcedureTarget = ref(null); @@ -1921,6 +1924,7 @@ async function openSidebarExtensionDetails(node: TreeNode) { function beginSidebarAction(): number { sidebarActionGeneration += 1; sidebarDdlOpen.value = false; + sidebarElasticsearchIndexMetadataOpen.value = false; sidebarObjectSourceOpen.value = false; sidebarProcedureOpen.value = false; sidebarVisibleDatabasesOpen.value = false; @@ -1928,6 +1932,7 @@ function beginSidebarAction(): number { sidebarVisibleNacosNamespacesOpen.value = false; sidebarTableNameFilterOpen.value = false; sidebarDdlTarget.value = null; + sidebarElasticsearchIndexMetadataTarget.value = null; sidebarObjectSourceTarget.value = null; sidebarProcedureTarget.value = null; sidebarVisibleDatabasesTarget.value = null; @@ -1958,6 +1963,13 @@ function openSidebarDdlForSelection(): boolean { return true; } +function openSidebarElasticsearchIndexMetadata(node: TreeNode, kind: ElasticsearchIndexMetadataKind) { + if (!node.connectionId) return; + beginSidebarAction(); + sidebarElasticsearchIndexMetadataTarget.value = { node: createSidebarActionTarget(node), kind }; + sidebarElasticsearchIndexMetadataOpen.value = true; +} + function openSidebarObjectSource(node: TreeNode, initialEditing: boolean) { if (!node.connectionId || !node.database || !objectSourceTargetForTreeNode(node)) return; // TYPE/TYPE_BODY only have a source implementation on Xugu; PostgreSQL-family @@ -2114,6 +2126,10 @@ watch(sidebarDdlOpen, (open) => { if (!open) sidebarDdlTarget.value = null; }); +watch(sidebarElasticsearchIndexMetadataOpen, (open) => { + if (!open) sidebarElasticsearchIndexMetadataTarget.value = null; +}); + watch(sidebarObjectSourceOpen, (open) => { if (!open) sidebarObjectSourceTarget.value = null; }); @@ -2356,6 +2372,7 @@ onUnmounted(() => { sidebarContextMenuTarget.value = null; sidebarContextMenuItems.value = []; sidebarDdlTarget.value = null; + sidebarElasticsearchIndexMetadataTarget.value = null; sidebarObjectSourceTarget.value = null; sidebarProcedureTarget.value = null; sidebarVisibleDatabasesTarget.value = null; @@ -2394,6 +2411,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid @search-toggle="onSearchToggle" @node-toggled="onNodeToggled" @open-ddl="openSidebarDdl" + @open-elasticsearch-index-metadata="openSidebarElasticsearchIndexMetadata" @open-object-source="openSidebarObjectSource" @open-procedure="openSidebarProcedure" @open-settings="openSidebarSettings" @@ -2598,6 +2616,14 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid :format-dialect="sqlFormatDialectForDbType(sidebarDdlDatabaseType)" /> + + }), ); +routeDangerDialog(showClearElasticsearchIndexConfirm, () => { + // Pin the label for the life of this dialog. `activeNode` follows the tree + // selection, which the confirmation must not, and the typed-name gate below + // has to compare against the index the operator actually opened. + const index = activeNode.value.label; + const isPattern = isElasticsearchIndexPattern(index); + clearElasticsearchIndexTypedName.value = ""; + return dangerRequest({ + title: t("contextMenu.elasticsearchClearIndex"), + // A grouped node is a wildcard covering many indexes, so it gets its own + // wording rather than one that reads as a single index. + message: t(isPattern ? "contextMenu.elasticsearchClearIndexPatternMessage" : "contextMenu.elasticsearchClearIndexMessage", { index }), + detailsText: t("contextMenu.elasticsearchClearIndexDetails"), + sql: elasticsearchClearIndexPreview(index), + confirmLabel: t("contextMenu.elasticsearchClearIndexConfirm"), + get loading() { + return clearElasticsearchIndexLoading.value; + }, + // A wildcard node clears every index it matches, so the pattern has to be + // typed back before the button unlocks. Concrete index names skip this. + ...(isPattern + ? { + textInput: { + value: "", + label: t("contextMenu.elasticsearchClearIndexTypeToConfirm", { index }), + placeholder: index, + onInput(value: string) { + clearElasticsearchIndexTypedName.value = value; + }, + }, + } + : {}), + // Declared directly on this literal, never inside the spread above: object + // spread evaluates an accessor once and copies the resulting value, which + // would freeze the gate shut at its construction-time answer. Concrete + // index names need no gate, and `isElasticsearchClearConfirmed` already + // returns true for them, so this stays unlocked for them. + get confirmDisabled() { + return !isElasticsearchClearConfirmed(index, clearElasticsearchIndexTypedName.value); + }, + closeOnConfirm: false, + confirm: confirmClearElasticsearchIndex, + }); +}); + routeDangerDialog(showFlushRedisDbConfirm, () => dangerRequest({ title: t("redis.flushDb"), @@ -5428,6 +5488,14 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean { if (canRenameMongoCollection.value) { items.push({ label: t("contextMenu.renameObject"), action: openRenameMongoCollectionDialog, icon: Pencil, shortcut: shortcutRename }); } + if (canManageElasticsearchIndex.value) { + items.push({ label: "", separator: true }); + items.push({ label: t("contextMenu.elasticsearchViewMapping"), action: () => openElasticsearchIndexMetadata("mapping"), icon: Braces }); + items.push({ label: t("contextMenu.elasticsearchViewSettings"), action: () => openElasticsearchIndexMetadata("settings"), icon: Settings2 }); + items.push({ label: t("contextMenu.elasticsearchViewStats"), action: () => openElasticsearchIndexMetadata("stats"), icon: BarChart3 }); + items.push({ label: "", separator: true }); + items.push({ label: t("contextMenu.elasticsearchClearIndex"), action: clearElasticsearchIndex, icon: Eraser, variant: "destructive" as const }); + } if (canDropMilvusCollection.value) { items.push({ label: "", separator: true }); items.push({ label: t("contextMenu.dropCollection"), action: dropMilvusCollection, icon: Trash2, shortcut: shortcutDelete, variant: "destructive" as const }); diff --git a/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.elasticsearchClearGuard.spec.ts b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.elasticsearchClearGuard.spec.ts new file mode 100644 index 000000000..83dd3cce8 --- /dev/null +++ b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.elasticsearchClearGuard.spec.ts @@ -0,0 +1,64 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const runtimeHostSource = readFileSync(new URL("../SidebarTreeRuntimeHost.vue", import.meta.url), "utf8"); +const connectionTreeSource = readFileSync(new URL("../ConnectionTree.vue", import.meta.url), "utf8"); +const dangerDialogSource = readFileSync(new URL("../../editor/DangerConfirmDialog.vue", import.meta.url), "utf8"); + +/** + * `isElasticsearchClearConfirmed` is unit-tested on its own, but a correct pure + * function is worthless if the dialog never consults it. These assertions pin + * the wiring that connects the two, end to end through the shared dialog. + */ +describe("Elasticsearch clear-index wildcard guard wiring", () => { + it("gates the confirm button on the typed pattern for wildcard nodes only", () => { + const clearRoute = runtimeHostSource.slice(runtimeHostSource.indexOf("routeDangerDialog(showClearElasticsearchIndexConfirm")); + expect(clearRoute).toContain("const isPattern = isElasticsearchIndexPattern(index)"); + expect(clearRoute).toMatch(/\.\.\.\(isPattern\s*\?\s*\{/); + }); + + /** + * Regression: the gate once lived inside the `...(isPattern ? {…} : {})` + * spread. Object spread *invokes* an accessor and copies the resulting value + * as a plain data property, so the getter answered once at construction time + * — while the input was still empty — and the confirm button stayed disabled + * no matter what was typed. The wording read correctly and the getter was + * present in the source the whole time, so only its position catches this. + */ + it("declares confirmDisabled outside the spread, so it stays a live getter", () => { + const clearRoute = runtimeHostSource.slice(runtimeHostSource.indexOf("routeDangerDialog(showClearElasticsearchIndexConfirm"), runtimeHostSource.indexOf("routeDangerDialog(showFlushRedisDbConfirm")); + const spreadStart = clearRoute.indexOf("...(isPattern"); + const spreadEnd = clearRoute.indexOf(": {}),", spreadStart); + expect(spreadStart).toBeGreaterThan(-1); + expect(spreadEnd).toBeGreaterThan(spreadStart); + // Nothing spread into the request may be an accessor. + expect(clearRoute.slice(spreadStart, spreadEnd)).not.toMatch(/\bget\s+\w+\s*\(/); + // It has to sit directly on the request literal instead. + expect(clearRoute.slice(spreadEnd)).toMatch(/get confirmDisabled\(\) \{\s*return !isElasticsearchClearConfirmed\(index, clearElasticsearchIndexTypedName\.value\);/); + }); + + it("pins the index label for the life of the dialog instead of following the tree selection", () => { + const clearRoute = runtimeHostSource.slice(runtimeHostSource.indexOf("routeDangerDialog(showClearElasticsearchIndexConfirm"), runtimeHostSource.indexOf("routeDangerDialog(showFlushRedisDbConfirm")); + expect(clearRoute).toContain("const index = activeNode.value.label;"); + // Reading activeNode again inside the request would reintroduce the drift. + expect(clearRoute.match(/activeNode\.value/g)).toHaveLength(1); + }); + + it("clears any previously typed name when the dialog is opened again", () => { + const clearRoute = runtimeHostSource.slice(runtimeHostSource.indexOf("routeDangerDialog(showClearElasticsearchIndexConfirm"), runtimeHostSource.indexOf("routeDangerDialog(showFlushRedisDbConfirm")); + expect(clearRoute).toContain('clearElasticsearchIndexTypedName.value = "";'); + }); + + it("forwards confirmDisabled from the sidebar request to the shared danger dialog", () => { + expect(connectionTreeSource).toContain(':confirm-disabled="sidebarDangerDialogRequest.confirmDisabled"'); + }); + + it("honours confirmDisabled on both the button and the confirm handler", () => { + expect(dangerDialogSource).toContain(':disabled="loading || confirmDisabled"'); + expect(dangerDialogSource).toMatch(/function onConfirm\(\) \{[\s\S]*?if \(props\.loading \|\| props\.confirmDisabled\) return;/); + }); + + it("leaves confirmDisabled off by default so existing danger dialogs are unaffected", () => { + expect(dangerDialogSource).toContain("confirmDisabled: false,"); + }); +}); diff --git a/apps/desktop/src/components/sidebar/sidebarAsyncDialogs.ts b/apps/desktop/src/components/sidebar/sidebarAsyncDialogs.ts index 824d379fa..43eb77b6b 100644 --- a/apps/desktop/src/components/sidebar/sidebarAsyncDialogs.ts +++ b/apps/desktop/src/components/sidebar/sidebarAsyncDialogs.ts @@ -22,5 +22,6 @@ export const SidebarVisibleNacosNamespacesDialog = lazySidebarDialog(() => impor export const SidebarVisibleSchemasDialog = lazySidebarDialog(() => import("@/components/sidebar/VisibleSchemasDialog.vue")); export const SidebarDdlViewDialog = lazySidebarDialog(() => import("@/components/objects/DdlViewDialog.vue")); export const SidebarObjectSourceDialog = lazySidebarDialog(() => import("@/components/objects/ObjectSourceDialog.vue")); +export const SidebarElasticsearchIndexMetadataDialog = lazySidebarDialog(() => import("@/components/objects/ElasticsearchIndexMetadataDialog.vue")); export const SidebarProcedureExecutionDialog = lazySidebarDialog(() => import("@/components/objects/ProcedureExecutionDialog.vue")); export const SidebarMongoIndexManagerDialog = lazySidebarDialog(() => import("@/components/sidebar/MongoIndexManagerDialog.vue")); diff --git a/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts b/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts index cb13a00f1..79228837d 100644 --- a/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts +++ b/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts @@ -163,6 +163,10 @@ export function resetMongoIndexManager() { mongoIndexManagerMode.value = "view"; mongoEditIndexOriginalName.value = ""; } +export const showClearElasticsearchIndexConfirm = ref(false); +export const clearElasticsearchIndexLoading = ref(false); +/** Name typed back by the operator before a wildcard index node may be cleared. */ +export const clearElasticsearchIndexTypedName = ref(""); export const showFlushRedisDbConfirm = ref(false); export const showRedisDatabaseAliasDialog = ref(false); export const redisDatabaseAliasInput = ref(""); @@ -213,6 +217,7 @@ const openFlags = [ showDropAllMongoIndexesConfirm, showCreateMongoIndexDialog, showMongoIndexManagerDialog, + showClearElasticsearchIndexConfirm, showFlushRedisDbConfirm, showRedisDatabaseAliasDialog, showCreateSchemaDialog, @@ -232,6 +237,8 @@ export function resetSidebarTreeDialogState() { createDatabasePreviewSql.value = ""; createDatabaseAuthorizationResults.value = []; createDatabaseAuthorizationApplying.value = false; + clearElasticsearchIndexLoading.value = false; + clearElasticsearchIndexTypedName.value = ""; redisDatabaseAliasInput.value = ""; redisDatabaseAliasSaving.value = false; cloneMongoCollectionName.value = ""; diff --git a/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.elasticsearch.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.elasticsearch.spec.ts new file mode 100644 index 000000000..1cb11853e --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.elasticsearch.spec.ts @@ -0,0 +1,156 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { shallowRef } from "vue"; +import type { DatabaseType, TreeNode } from "@/types/database"; +import { clearElasticsearchIndexLoading, showClearElasticsearchIndexConfirm, sidebarDangerTarget } from "@/components/sidebar/sidebarTreeDialogState"; +import { ELASTICSEARCH_INDEX_CLEARED_EVENT } from "@/lib/sidebar/elasticsearchIndexActions"; + +const mocks = vi.hoisted(() => ({ + toast: vi.fn(), + ensureConnected: vi.fn().mockResolvedValue(undefined), + elasticsearchDeleteAllDocuments: vi.fn(), + getConfig: vi.fn(), +})); + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ + t: (key: string, params?: Record) => (params ? `${key}:${JSON.stringify(params)}` : key), + }), +})); + +vi.mock("@/composables/useToast", () => ({ + useToast: () => ({ toast: mocks.toast }), +})); + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({}), +})); + +vi.mock("@/lib/backend/api", () => ({ + elasticsearchDeleteAllDocuments: (...args: unknown[]) => mocks.elasticsearchDeleteAllDocuments(...args), + mongoListIndexSpecs: vi.fn(), + mongoCreateIndex: vi.fn(), + mongoCloneCollection: vi.fn(), + mongoDropCollection: vi.fn(), + mongoDropDatabase: vi.fn(), + mongoDropIndexes: vi.fn(), + mongoRenameCollection: vi.fn(), + nacosCreateNamespace: vi.fn(), + nacosUpdateNamespace: vi.fn(), + redisFlushDb: vi.fn(), +})); + +vi.mock("@/lib/sidebar/sidebarActionTarget", () => ({ + findSidebarActionTarget: () => null, +})); + +import { useSidebarDatabaseSpecificMutationRuntime } from "@/composables/useSidebarDatabaseSpecificMutationRuntime"; + +function config(dbType: DatabaseType, production = false) { + return { id: "conn-1", name: "Search", db_type: dbType, host: "localhost", port: 9200, username: "", password: "", is_production: production }; +} + +function indexNode(label = "orders"): TreeNode { + return { id: `conn-1:__collection:${label}`, label, type: "elasticsearch-index", connectionId: "conn-1", database: "default", isExpanded: false }; +} + +function clearResult(overrides: Record = {}) { + return { total: 3, deleted: 3, versionConflicts: 0, timedOut: false, failures: [], ...overrides }; +} + +function runtime(node: TreeNode) { + return useSidebarDatabaseSpecificMutationRuntime({ + activeNode: shallowRef(node), + connectionStore: { getConfig: mocks.getConfig, ensureConnected: mocks.ensureConnected } as any, + }); +} + +describe("Elasticsearch index sidebar mutations", () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + mocks.ensureConnected.mockResolvedValue(undefined); + mocks.elasticsearchDeleteAllDocuments.mockResolvedValue(clearResult()); + sidebarDangerTarget.value = null; + showClearElasticsearchIndexConfirm.value = false; + clearElasticsearchIndexLoading.value = false; + }); + + it("offers the index actions only on Elasticsearch-protocol connections", () => { + for (const dbType of ["elasticsearch", "easysearch"] as const) { + mocks.getConfig.mockReturnValue(config(dbType)); + expect(runtime(indexNode()).canManageElasticsearchIndex.value, dbType).toBe(true); + } + // Meilisearch reuses the elasticsearch-index node type but has its own API. + mocks.getConfig.mockReturnValue(config("meilisearch")); + expect(runtime(indexNode()).canManageElasticsearchIndex.value).toBe(false); + }); + + it("clears documents for the index and reports the deleted count", async () => { + mocks.getConfig.mockReturnValue(config("elasticsearch")); + const cleared = vi.fn(); + window.addEventListener(ELASTICSEARCH_INDEX_CLEARED_EVENT, cleared); + const feature = runtime(indexNode()); + + feature.clearElasticsearchIndex(); + expect(showClearElasticsearchIndexConfirm.value).toBe(true); + + await feature.confirmClearElasticsearchIndex(); + window.removeEventListener(ELASTICSEARCH_INDEX_CLEARED_EVENT, cleared); + + expect(mocks.ensureConnected).toHaveBeenCalledWith("conn-1"); + expect(mocks.elasticsearchDeleteAllDocuments).toHaveBeenCalledWith("conn-1", "orders"); + expect(mocks.toast).toHaveBeenCalledWith(expect.stringContaining("elasticsearchClearIndexDone"), expect.anything()); + // An open document browser has to learn the rows are gone. + expect(cleared).toHaveBeenCalledTimes(1); + const clearedEvent = cleared.mock.calls[0]![0] as CustomEvent; + expect(clearedEvent.detail).toEqual({ connectionId: "conn-1", index: "orders" }); + expect(clearElasticsearchIndexLoading.value).toBe(false); + }); + + it("reports a partial clear rather than success when documents survive", async () => { + mocks.getConfig.mockReturnValue(config("elasticsearch")); + mocks.elasticsearchDeleteAllDocuments.mockResolvedValue(clearResult({ total: 10, deleted: 8, versionConflicts: 2 })); + const feature = runtime(indexNode()); + + await feature.confirmClearElasticsearchIndex(); + + expect(mocks.toast).toHaveBeenCalledWith(expect.stringContaining("elasticsearchClearIndexPartial"), expect.anything()); + expect(mocks.toast).not.toHaveBeenCalledWith(expect.stringContaining("elasticsearchClearIndexDone"), expect.anything()); + }); + + it("never clears a Meilisearch index through the Elasticsearch path", async () => { + mocks.getConfig.mockReturnValue(config("meilisearch")); + + await runtime(indexNode()).confirmClearElasticsearchIndex(); + + expect(mocks.elasticsearchDeleteAllDocuments).not.toHaveBeenCalled(); + }); + + it("does not clear when production confirmation is cancelled", async () => { + mocks.getConfig.mockReturnValue(config("elasticsearch", true)); + const feature = runtime(indexNode()); + + const pending = feature.confirmClearElasticsearchIndex(); + await Promise.resolve(); + const { useProductionSafetyStore } = await import("@/stores/productionSafetyStore"); + useProductionSafetyStore().cancel(); + await pending; + + expect(mocks.ensureConnected).not.toHaveBeenCalled(); + expect(mocks.elasticsearchDeleteAllDocuments).not.toHaveBeenCalled(); + }); + + it("surfaces a backend failure and releases the loading state", async () => { + mocks.getConfig.mockReturnValue(config("elasticsearch")); + mocks.elasticsearchDeleteAllDocuments.mockRejectedValue(new Error("index_not_found_exception")); + const feature = runtime(indexNode()); + + await feature.confirmClearElasticsearchIndex(); + + expect(mocks.toast).toHaveBeenCalledWith(expect.stringContaining("contextMenu.tableOperationFailed"), expect.anything()); + expect(clearElasticsearchIndexLoading.value).toBe(false); + }); +}); diff --git a/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts b/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts index ceab7d9b7..50d0ccb29 100644 --- a/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts +++ b/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts @@ -32,6 +32,7 @@ import { type MongoCreateIndexRequest, type MongoIndexSpecSnapshot, } from "@/lib/sidebar/mongoCollectionMutation"; +import { elasticsearchClearIndexPreview, isElasticsearchProtocolIndex, isPartialElasticsearchClear, notifyElasticsearchIndexCleared } from "@/lib/sidebar/elasticsearchIndexActions"; import { supportsMongoAllDriverMutations, supportsMongoIndexMutations, supportsNativeMongoDriverMutations } from "@/lib/mongo/mongoCapabilities"; import { runMongoSidebarMutation } from "@/lib/sidebar/runMongoSidebarMutation"; import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard"; @@ -60,6 +61,8 @@ import { dropAllMongoIndexesLoading, showDropDatabaseConfirm, dropDatabaseLoading, + showClearElasticsearchIndexConfirm, + clearElasticsearchIndexLoading, showFlushRedisDbConfirm, showRedisDatabaseAliasDialog, redisDatabaseAliasInput, @@ -148,6 +151,13 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba return canMutateMongoCollectionNode(node) && isCloneableMongoCollection(node.label, mongoCollectionKindFromNode(node)); } + function canMutateElasticsearchIndexNode(node: TreeNode): boolean { + if (!node.connectionId) return false; + return isElasticsearchProtocolIndex(node.type, connectionStore.getConfig(node.connectionId)?.db_type); + } + + const canManageElasticsearchIndex = computed(() => canMutateElasticsearchIndexNode(activeNode.value)); + const canDropMongoCollection = computed(() => canMutateMongoCollectionNode(activeNode.value)); const canDropMilvusCollection = computed(() => activeNode.value.type === "vector-collection" && !!activeNode.value.connectionId && !!activeNode.value.database && connectionStore.getConfig(activeNode.value.connectionId)?.db_type === "milvus"); const canRenameMongoCollection = computed(() => canRenameMongoCollectionNode(activeNode.value)); @@ -898,6 +908,49 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba showFlushRedisDbConfirm.value = true; } + function clearElasticsearchIndex() { + clearElasticsearchIndexLoading.value = false; + showClearElasticsearchIndexConfirm.value = true; + } + + async function confirmClearElasticsearchIndex() { + const node = sidebarDangerTarget.value ?? activeNode.value; + const connectionId = node.connectionId; + if (!canMutateElasticsearchIndexNode(node) || !connectionId || clearElasticsearchIndexLoading.value) return; + const index = node.label; + // Only the mutation is guarded. Reporting the outcome sits outside the try + // so a failure in a post-success side effect can never make a completed + // deletion look like it failed. + let executed: { result: Awaited> } | undefined; + try { + executed = await executeWithProductionContextGuard({ + connection: connectionStore.getConfig(connectionId), + database: node.database, + reviewText: elasticsearchClearIndexPreview(index), + source: t("production.sourceSidebar"), + execute: async () => { + clearElasticsearchIndexLoading.value = true; + await connectionStore.ensureConnected(connectionId); + return { result: await api.elasticsearchDeleteAllDocuments(connectionId, index) }; + }, + }); + } catch (error: unknown) { + toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, error) }), 5000); + return; + } finally { + clearElasticsearchIndexLoading.value = false; + } + // The guard returns undefined only when the user declines confirmation. + if (executed === undefined) return; + const result = executed.result; + if (isPartialElasticsearchClear(result)) { + toast(t("contextMenu.elasticsearchClearIndexPartial", { index, deleted: result.deleted, total: result.total }), 6000); + } else { + toast(t("contextMenu.elasticsearchClearIndexDone", { index, deleted: result.deleted }), 3000); + } + notifyElasticsearchIndexCleared(connectionId, index); + } + function prepareRedisDatabaseAliasDialog() { const node = activeNode.value; redisDatabaseAliasInput.value = node.connectionId && node.database != null ? connectionStore.getRedisDatabaseAlias(node.connectionId, node.database) || "" : ""; @@ -1190,6 +1243,9 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba dropMongoCollection, dropMongoIndex, dropAllMongoIndexes, + canManageElasticsearchIndex, + clearElasticsearchIndex, + confirmClearElasticsearchIndex, flushRedisDb, prepareRedisDatabaseAliasDialog, confirmRedisDatabaseAlias, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index fc04d0577..aab115fa7 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3268,6 +3268,17 @@ export default { attachSqliteDatabaseDesktopOnly: "Attaching SQLite database files is only available in the desktop app", dropDatabaseSuccess: 'Database "{name}" dropped', dropCollection: "Drop Collection", + elasticsearchViewMapping: "View Mapping", + elasticsearchViewSettings: "View Index Settings", + elasticsearchViewStats: "View Index Stats", + elasticsearchClearIndex: "Clear Index Data", + elasticsearchClearIndexMessage: "This deletes every document in index {index}. The mapping, settings, and aliases are kept. Continue?", + elasticsearchClearIndexPatternMessage: "{index} is a grouped pattern, not one index. This deletes every document in every index it matches. Mappings, settings, and aliases are kept. Continue?", + elasticsearchClearIndexTypeToConfirm: "Type {index} to confirm", + elasticsearchClearIndexDetails: "Documents are removed with _delete_by_query; the index itself is not dropped.", + elasticsearchClearIndexConfirm: "Clear Data", + elasticsearchClearIndexDone: "Cleared {index}: {deleted} documents deleted", + elasticsearchClearIndexPartial: "Cleared {index} partially: {deleted} of {total} documents deleted. Check conflicts or shard failures and run it again.", cloneCollection: "Clone Collection", cloneCollectionTitle: "Clone as New Collection", cloneCollectionNamePlaceholder: "New collection name", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index bfd609ca5..678236a89 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3142,6 +3142,17 @@ export default withEnglishFallback({ attachSqliteDatabaseDesktopOnly: "Adjuntar archivos de base de datos SQLite solo está disponible en la app de escritorio", dropDatabaseSuccess: 'Base de datos "{name}" eliminada', dropCollection: "Eliminar colección", + elasticsearchViewMapping: "Ver mapping", + elasticsearchViewSettings: "Ver configuración del índice", + elasticsearchViewStats: "Ver estadísticas del índice", + elasticsearchClearIndex: "Vaciar datos del índice", + elasticsearchClearIndexMessage: "Se eliminarán todos los documentos del índice {index}. Se conservan el mapping, la configuración y los alias. ¿Continuar?", + elasticsearchClearIndexPatternMessage: "{index} es un patrón agrupado, no un solo índice. Se eliminarán todos los documentos de todos los índices que coincidan. Se conservan mappings, configuración y alias. ¿Continuar?", + elasticsearchClearIndexTypeToConfirm: "Escribe {index} para confirmar", + elasticsearchClearIndexDetails: "Los documentos se eliminan con _delete_by_query; el índice en sí no se elimina.", + elasticsearchClearIndexConfirm: "Vaciar datos", + elasticsearchClearIndexDone: "{index} vaciado: {deleted} documentos eliminados", + elasticsearchClearIndexPartial: "{index} vaciado parcialmente: {deleted} de {total} documentos eliminados. Revisa los conflictos o fallos de shard y vuelve a ejecutarlo.", cloneCollection: "Clonar colección", cloneCollectionTitle: "Clonar como nueva colección", cloneCollectionNamePlaceholder: "Nombre de la nueva colección", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 6de60831b..5bbea0ffb 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3140,6 +3140,17 @@ export default withEnglishFallback({ attachSqliteDatabaseDesktopOnly: "Il collegamento di file di database SQLite è disponibile solo nell'applicazione desktop", dropDatabaseSuccess: 'Database "{name}" eliminato', dropCollection: "Elimina Collection", + elasticsearchViewMapping: "Visualizza mapping", + elasticsearchViewSettings: "Visualizza impostazioni indice", + elasticsearchViewStats: "Visualizza statistiche indice", + elasticsearchClearIndex: "Svuota dati dell'indice", + elasticsearchClearIndexMessage: "Verranno eliminati tutti i documenti dell'indice {index}. Mapping, impostazioni e alias vengono mantenuti. Continuare?", + elasticsearchClearIndexPatternMessage: "{index} è un pattern aggregato, non un singolo indice. Verranno eliminati tutti i documenti di ogni indice corrispondente. Mapping, impostazioni e alias vengono mantenuti. Continuare?", + elasticsearchClearIndexTypeToConfirm: "Digita {index} per confermare", + elasticsearchClearIndexDetails: "I documenti vengono eliminati con _delete_by_query; l'indice stesso non viene rimosso.", + elasticsearchClearIndexConfirm: "Svuota dati", + elasticsearchClearIndexDone: "{index} svuotato: {deleted} documenti eliminati", + elasticsearchClearIndexPartial: "{index} svuotato parzialmente: {deleted} di {total} documenti eliminati. Verifica conflitti o errori di shard ed esegui di nuovo.", cloneCollection: "Clona raccolta", cloneCollectionTitle: "Clona come nuova raccolta", cloneCollectionNamePlaceholder: "Nome della nuova raccolta", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index f3fe53bcd..d9a3a4e23 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3162,6 +3162,17 @@ export default withEnglishFallback({ attachSqliteDatabaseDesktopOnly: "SQLiteデータベースファイルのアタッチはデスクトップアプリでのみ利用可能です", dropDatabaseSuccess: "データベース「{name}」を削除しました", dropCollection: "コレクションを削除", + elasticsearchViewMapping: "マッピングを表示", + elasticsearchViewSettings: "インデックス設定を表示", + elasticsearchViewStats: "インデックス統計を表示", + elasticsearchClearIndex: "インデックスデータをクリア", + elasticsearchClearIndexMessage: "インデックス {index} の全ドキュメントを削除します。マッピング・設定・エイリアスは保持されます。続行しますか?", + elasticsearchClearIndexPatternMessage: "{index} は集約されたワイルドカードパターンであり、単一のインデックスではありません。一致するすべてのインデックスの全ドキュメントを削除します。マッピング・設定・エイリアスは保持されます。続行しますか?", + elasticsearchClearIndexTypeToConfirm: "確認のため {index} を入力してください", + elasticsearchClearIndexDetails: "_delete_by_query でドキュメントを削除します。インデックス自体は削除されません。", + elasticsearchClearIndexConfirm: "データをクリア", + elasticsearchClearIndexDone: "{index} をクリアしました: {deleted} 件のドキュメントを削除", + elasticsearchClearIndexPartial: "{index} は完全にはクリアされていません: {total} 件中 {deleted} 件を削除。競合やシャード障害を確認して再実行してください。", cloneCollection: "コレクションを複製", cloneCollectionTitle: "新しいコレクションとして複製", cloneCollectionNamePlaceholder: "新しいコレクション名", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 9fa2c94cf..ce036723c 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -3033,6 +3033,17 @@ export default withEnglishFallback({ attachSqliteDatabaseDesktopOnly: "SQLite 데이터베이스 파일 연결은 데스크톱 앱에서만 사용할 수 있습니다", dropDatabaseSuccess: '데이터베이스 "{name}" 삭제됨', dropCollection: "컬렉션 삭제", + elasticsearchViewMapping: "매핑 보기", + elasticsearchViewSettings: "인덱스 설정 보기", + elasticsearchViewStats: "인덱스 통계 보기", + elasticsearchClearIndex: "인덱스 데이터 비우기", + elasticsearchClearIndexMessage: "인덱스 {index}의 모든 문서를 삭제합니다. 매핑, 설정, 별칭은 유지됩니다. 계속할까요?", + elasticsearchClearIndexPatternMessage: "{index}는 단일 인덱스가 아니라 그룹화된 와일드카드 패턴입니다. 일치하는 모든 인덱스의 문서가 삭제됩니다. 매핑, 설정, 별칭은 유지됩니다. 계속할까요?", + elasticsearchClearIndexTypeToConfirm: "확인하려면 {index}를 입력하세요", + elasticsearchClearIndexDetails: "_delete_by_query로 문서를 삭제하며 인덱스 자체는 삭제되지 않습니다.", + elasticsearchClearIndexConfirm: "데이터 비우기", + elasticsearchClearIndexDone: "{index} 비움: 문서 {deleted}건 삭제", + elasticsearchClearIndexPartial: "{index}가 완전히 비워지지 않았습니다: {total}건 중 {deleted}건 삭제. 충돌 또는 샤드 실패를 확인한 뒤 다시 실행하세요.", cloneCollection: "컬렉션 복제", cloneCollectionTitle: "새 컬렉션으로 복제", cloneCollectionNamePlaceholder: "새 컬렉션 이름", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 384bd9ffa..b168ba1fb 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3138,6 +3138,17 @@ export default withEnglishFallback({ attachSqliteDatabaseDesktopOnly: "Anexar arquivos de banco de dados SQLite só está disponível no aplicativo desktop", dropDatabaseSuccess: 'Banco de dados "{name}" removido', dropCollection: "Remover coleção", + elasticsearchViewMapping: "Ver mapping", + elasticsearchViewSettings: "Ver configurações do índice", + elasticsearchViewStats: "Ver estatísticas do índice", + elasticsearchClearIndex: "Limpar dados do índice", + elasticsearchClearIndexMessage: "Todos os documentos do índice {index} serão excluídos. Mapping, configurações e aliases são mantidos. Continuar?", + elasticsearchClearIndexPatternMessage: "{index} é um padrão agrupado, não um único índice. Todos os documentos de todos os índices correspondentes serão excluídos. Mappings, configurações e aliases são mantidos. Continuar?", + elasticsearchClearIndexTypeToConfirm: "Digite {index} para confirmar", + elasticsearchClearIndexDetails: "Os documentos são removidos com _delete_by_query; o índice em si não é excluído.", + elasticsearchClearIndexConfirm: "Limpar dados", + elasticsearchClearIndexDone: "{index} limpo: {deleted} documentos excluídos", + elasticsearchClearIndexPartial: "{index} limpo parcialmente: {deleted} de {total} documentos excluídos. Verifique conflitos ou falhas de shard e execute novamente.", cloneCollection: "Clonar coleção", cloneCollectionTitle: "Clonar como nova coleção", cloneCollectionNamePlaceholder: "Nome da nova coleção", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 60d9ecbfe..74961b69b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -3189,6 +3189,17 @@ export default withEnglishFallback({ attachSqliteDatabaseDesktopOnly: "附加 SQLite 数据库文件仅支持桌面端", dropDatabaseSuccess: "数据库「{name}」已删除", dropCollection: "删除集合", + elasticsearchViewMapping: "查看 Mapping", + elasticsearchViewSettings: "查看索引配置", + elasticsearchViewStats: "查看索引统计", + elasticsearchClearIndex: "清空索引数据", + elasticsearchClearIndexMessage: "将删除索引 {index} 中的全部文档,保留 mapping、配置与别名。是否继续?", + elasticsearchClearIndexPatternMessage: "{index} 是聚合出的通配模式,不是单个索引。将删除该模式命中的所有索引中的全部文档,保留 mapping、配置与别名。是否继续?", + elasticsearchClearIndexTypeToConfirm: "输入 {index} 以确认", + elasticsearchClearIndexDetails: "通过 _delete_by_query 删除文档,索引本身不会被删除。", + elasticsearchClearIndexConfirm: "清空数据", + elasticsearchClearIndexDone: "已清空 {index}:删除 {deleted} 条文档", + elasticsearchClearIndexPartial: "{index} 未完全清空:{total} 条中删除了 {deleted} 条。请检查版本冲突或分片失败后重试。", cloneCollection: "克隆集合", cloneCollectionTitle: "克隆为新集合", cloneCollectionNamePlaceholder: "新集合名", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 9ff703559..89740360f 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3139,6 +3139,17 @@ export default withEnglishFallback({ attachSqliteDatabaseDesktopOnly: "附加 SQLite 資料庫檔案僅支援桌面端", dropDatabaseSuccess: "資料庫「{name}」已刪除", dropCollection: "刪除集合", + elasticsearchViewMapping: "檢視 Mapping", + elasticsearchViewSettings: "檢視索引設定", + elasticsearchViewStats: "檢視索引統計", + elasticsearchClearIndex: "清空索引資料", + elasticsearchClearIndexMessage: "將刪除索引 {index} 中的所有文件,保留 mapping、設定與別名。是否繼續?", + elasticsearchClearIndexPatternMessage: "{index} 是彙總出的萬用字元模式,不是單一索引。將刪除該模式命中的所有索引中的全部文件,保留 mapping、設定與別名。是否繼續?", + elasticsearchClearIndexTypeToConfirm: "輸入 {index} 以確認", + elasticsearchClearIndexDetails: "透過 _delete_by_query 刪除文件,索引本身不會被刪除。", + elasticsearchClearIndexConfirm: "清空資料", + elasticsearchClearIndexDone: "已清空 {index}:刪除 {deleted} 筆文件", + elasticsearchClearIndexPartial: "{index} 未完全清空:{total} 筆中刪除了 {deleted} 筆。請檢查版本衝突或分片失敗後重試。", cloneCollection: "複製集合", cloneCollectionTitle: "複製為新集合", cloneCollectionNamePlaceholder: "新集合名稱", diff --git a/apps/desktop/src/lib/backend/__tests__/elasticsearchIndexTransportContract.spec.ts b/apps/desktop/src/lib/backend/__tests__/elasticsearchIndexTransportContract.spec.ts new file mode 100644 index 000000000..e6f47e385 --- /dev/null +++ b/apps/desktop/src/lib/backend/__tests__/elasticsearchIndexTransportContract.spec.ts @@ -0,0 +1,58 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const tauri = readFileSync(new URL("../tauri.ts", import.meta.url), "utf8"); +const http = readFileSync(new URL("../http.ts", import.meta.url), "utf8"); +const api = readFileSync(new URL("../api.ts", import.meta.url), "utf8"); +const tauriRegistry = readFileSync(new URL("../../../../../../src-tauri/src/lib.rs", import.meta.url), "utf8"); +const webRegistry = readFileSync(new URL("../../../../../../crates/dbx-web/src/main.rs", import.meta.url), "utf8"); +const coreOps = readFileSync(new URL("../../../../../../crates/dbx-core/src/document_ops.rs", import.meta.url), "utf8"); + +function functionBody(source: string, operation: string): string { + const start = source.indexOf(`export async function ${operation}(`); + expect(start, `${operation} transport function`).toBeGreaterThanOrEqual(0); + const end = source.indexOf("\nexport async function ", start + 1); + return source.slice(start, end === -1 ? source.length : end); +} + +/** + * Nothing type-checks a frontend command name against its Rust registration, so + * a rename on either side would ship a menu whose actions all fail at runtime. + */ +const operations = [ + { name: "elasticsearchGetIndexMetadata", command: "elasticsearch_get_index_metadata", route: "/api/document-store/elasticsearch/index-metadata" }, + { name: "elasticsearchDeleteAllDocuments", command: "elasticsearch_delete_all_documents", route: "/api/document-store/elasticsearch/documents/delete-all" }, +] as const; + +describe("Elasticsearch index action dual transport contract", () => { + it.each(operations)("registers $name end-to-end", ({ name, command, route }) => { + expect(functionBody(tauri, name).match(/invoke\("([^"]+)"/)?.[1], `${name} invoke command`).toBe(command); + expect(functionBody(http, name).match(/post\("([^"]+)"/)?.[1], `${name} HTTP route`).toBe(route); + expect(api).toContain(`${name} = forward("${name}")`); + expect(tauriRegistry).toContain(`commands::document_cmd::${command},`); + expect(webRegistry).toContain(`"${route.replace(/^\/api/, "")}",`); + }); + + it("sends the metadata kind literals the Rust enum deserializes", () => { + const body = functionBody(tauri, "elasticsearchGetIndexMetadata"); + expect(body).toContain("kind,"); + // Mirrors ElasticsearchIndexMetadataKind's camelCase variant renaming. + expect(tauri).toContain('export type ElasticsearchIndexMetadataKind = "mapping" | "settings" | "stats"'); + expect(coreOps).toContain("pub enum ElasticsearchIndexMetadataKind"); + }); + + it("keeps the clear-index result fields the partial-clear check reads", () => { + for (const field of ["total", "deleted", "versionConflicts", "timedOut", "failures"]) { + expect(tauri, `ElasticsearchDeleteByQueryResult.${field}`).toContain(` ${field}:`); + } + }); + + it("guards the destructive clear behind a backend write check", () => { + const commands = readFileSync(new URL("../../../../../../src-tauri/src/commands/document_cmd.rs", import.meta.url), "utf8"); + const routes = readFileSync(new URL("../../../../../../crates/dbx-web/src/routes/document_store.rs", import.meta.url), "utf8"); + const tauriCommand = commands.slice(commands.indexOf("pub async fn elasticsearch_delete_all_documents(")); + const webRoute = routes.slice(routes.indexOf("pub async fn elasticsearch_delete_all_documents(")); + expect(tauriCommand.slice(0, tauriCommand.indexOf("\n}"))).toContain("ensure_connection_writable"); + expect(webRoute.slice(0, webRoute.indexOf("\n}"))).toContain("ensure_writable"); + }); +}); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 6d0108c75..3df6b793b 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -790,6 +790,8 @@ export const mongoFindOneAndDelete = forward("mongoFindOneAndDelete"); // Elasticsearch export const elasticsearchListIndices = forward("elasticsearchListIndices"); +export const elasticsearchGetIndexMetadata = forward("elasticsearchGetIndexMetadata"); +export const elasticsearchDeleteAllDocuments = forward("elasticsearchDeleteAllDocuments"); export const vectorListCollections = forward("vectorListCollections"); // History diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 97f37bcb8..451539577 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -147,6 +147,8 @@ import type { PromptTemplate, SshPromptResolution, MeilisearchIndexOverview, + ElasticsearchIndexMetadataKind, + ElasticsearchDeleteByQueryResult, } from "@/lib/backend/tauri"; import type { QueryEditability } from "@/lib/sql/sqlAnalysis"; import { isTerminalTransferProgress } from "@/lib/backend/transferProgress"; @@ -3869,6 +3871,21 @@ export async function elasticsearchCountDocuments(connectionId: string, index: s }); } +export async function elasticsearchGetIndexMetadata(connectionId: string, index: string, kind: ElasticsearchIndexMetadataKind): Promise> { + return post("/api/document-store/elasticsearch/index-metadata", { + connectionId, + index, + kind, + }); +} + +export async function elasticsearchDeleteAllDocuments(connectionId: string, index: string): Promise { + return post("/api/document-store/elasticsearch/documents/delete-all", { + connectionId, + index, + }); +} + export async function mongoCountDocuments(connectionId: string, database: string, collection: string, filter?: string, mode?: "accurate" | "legacy", executionId?: string): Promise { return post("/api/mongo/count-documents", { connectionId, diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 0cea51266..89c97bdf9 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -3859,6 +3859,33 @@ export async function elasticsearchCountDocuments(connectionId: string, index: s }); } +/** Read-only index metadata endpoints exposed on the Elasticsearch index context menu. */ +export type ElasticsearchIndexMetadataKind = "mapping" | "settings" | "stats"; + +/** Outcome of clearing an index: mapping and settings are kept, documents are not. */ +export interface ElasticsearchDeleteByQueryResult { + total: number; + deleted: number; + versionConflicts: number; + timedOut: boolean; + failures: string[]; +} + +export async function elasticsearchGetIndexMetadata(connectionId: string, index: string, kind: ElasticsearchIndexMetadataKind): Promise> { + return invoke("elasticsearch_get_index_metadata", { + connectionId, + index, + kind, + }); +} + +export async function elasticsearchDeleteAllDocuments(connectionId: string, index: string): Promise { + return invoke("elasticsearch_delete_all_documents", { + connectionId, + index, + }); +} + export async function mongoCountDocuments(connectionId: string, database: string, collection: string, filter?: string, mode?: "accurate" | "legacy", executionId?: string): Promise { return invoke("mongo_count_documents", { connectionId, diff --git a/apps/desktop/src/lib/sidebar/__tests__/elasticsearchIndexActions.spec.ts b/apps/desktop/src/lib/sidebar/__tests__/elasticsearchIndexActions.spec.ts new file mode 100644 index 000000000..31ecde9c8 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/__tests__/elasticsearchIndexActions.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { elasticsearchClearIndexPreview, isElasticsearchClearConfirmed, isElasticsearchIndexPattern, isElasticsearchProtocolIndex, isPartialElasticsearchClear, matchesElasticsearchIndexPattern } from "@/lib/sidebar/elasticsearchIndexActions"; +import type { ElasticsearchDeleteByQueryResult } from "@/lib/backend/tauri"; + +function clearResult(overrides: Partial = {}): ElasticsearchDeleteByQueryResult { + return { total: 10, deleted: 10, versionConflicts: 0, timedOut: false, failures: [], ...overrides }; +} + +describe("Elasticsearch index actions", () => { + it("only claims index nodes on Elasticsearch-protocol connections", () => { + expect(isElasticsearchProtocolIndex("elasticsearch-index", "elasticsearch")).toBe(true); + expect(isElasticsearchProtocolIndex("elasticsearch-index", "easysearch")).toBe(true); + // Meilisearch reuses the same tree node type but speaks a different API. + expect(isElasticsearchProtocolIndex("elasticsearch-index", "meilisearch")).toBe(false); + expect(isElasticsearchProtocolIndex("vector-collection", "elasticsearch")).toBe(false); + expect(isElasticsearchProtocolIndex("elasticsearch-index", undefined)).toBe(false); + }); + + it("previews the exact request the clear action sends", () => { + const preview = elasticsearchClearIndexPreview("logs-2026.08"); + expect(preview).toContain("POST /logs-2026.08/_delete_by_query?conflicts=proceed&refresh=true"); + expect(preview).toContain('"match_all"'); + // The index itself must not appear as a DELETE target. + expect(preview).not.toContain("DELETE"); + }); + + it("matches concrete indexes under a grouped node's pattern", () => { + expect(matchesElasticsearchIndexPattern("logs-2026.08.*", "logs-2026.08.01")).toBe(true); + expect(matchesElasticsearchIndexPattern("logs-?.*", "logs-1.daily")).toBe(true); + expect(matchesElasticsearchIndexPattern("logs-2026.08.*", "logs-2026.09.01")).toBe(false); + expect(matchesElasticsearchIndexPattern("logs-?", "logs-12")).toBe(false); + expect(matchesElasticsearchIndexPattern("logs", "logs-daily")).toBe(false); + expect(matchesElasticsearchIndexPattern("*", "anything")).toBe(true); + }); + + it("separates grouped wildcard nodes from real index names", () => { + expect(isElasticsearchIndexPattern("logs-2026.08.*")).toBe(true); + expect(isElasticsearchIndexPattern("logs-?")).toBe(true); + // Characters that are legal in an index name must not read as a pattern. + expect(isElasticsearchIndexPattern("logs-2026.08.04")).toBe(false); + expect(isElasticsearchIndexPattern("orders_v2-prod")).toBe(false); + }); + + it("lets a concrete index clear without extra typing", () => { + expect(isElasticsearchClearConfirmed("orders", "")).toBe(true); + expect(isElasticsearchClearConfirmed("logs-2026.08.04", "anything")).toBe(true); + }); + + it("holds a wildcard clear back until the pattern is typed back verbatim", () => { + expect(isElasticsearchClearConfirmed("logs-2026.08.*", "")).toBe(false); + expect(isElasticsearchClearConfirmed("logs-2026.08.*", "logs-2026.08")).toBe(false); + // A near miss must not unlock: this is the whole point of the gate. + expect(isElasticsearchClearConfirmed("logs-2026.08.*", "logs-2026.09.*")).toBe(false); + expect(isElasticsearchClearConfirmed("logs-2026.08.*", "LOGS-2026.08.*")).toBe(false); + expect(isElasticsearchClearConfirmed("logs-2026.08.*", "logs-2026.08.*")).toBe(true); + // Padding is invisible in the input, so it is forgiven. + expect(isElasticsearchClearConfirmed("logs-2026.08.*", " logs-2026.08.* ")).toBe(true); + }); + + it("treats a fully deleted match set as a complete clear", () => { + expect(isPartialElasticsearchClear(clearResult())).toBe(false); + expect(isPartialElasticsearchClear(clearResult({ total: 0, deleted: 0 }))).toBe(false); + }); + + it("flags every way _delete_by_query can leave documents behind", () => { + expect(isPartialElasticsearchClear(clearResult({ deleted: 7 }))).toBe(true); + expect(isPartialElasticsearchClear(clearResult({ versionConflicts: 2 }))).toBe(true); + expect(isPartialElasticsearchClear(clearResult({ timedOut: true }))).toBe(true); + expect(isPartialElasticsearchClear(clearResult({ failures: ['{"index":"logs"}'] }))).toBe(true); + }); +}); diff --git a/apps/desktop/src/lib/sidebar/elasticsearchIndexActions.ts b/apps/desktop/src/lib/sidebar/elasticsearchIndexActions.ts new file mode 100644 index 000000000..88bcb95c6 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/elasticsearchIndexActions.ts @@ -0,0 +1,104 @@ +import type { DatabaseType } from "@/types/database"; +import type { ElasticsearchDeleteByQueryResult } from "@/lib/backend/tauri"; + +/** + * `elasticsearch-index` tree nodes are also used for Meilisearch indexes, which + * speak a different API. Only true Elasticsearch-protocol connections expose the + * mapping/settings/stats and clear-index actions. + */ +export function isElasticsearchProtocolIndex(nodeType: string, dbType: DatabaseType | undefined): boolean { + return nodeType === "elasticsearch-index" && (dbType === "elasticsearch" || dbType === "easysearch"); +} + +/** + * With an `index_grouping` regex configured, a sidebar index node is a collapsed + * pattern (`logs-2026.08.*`) rather than one index, and clearing it hits every + * index the pattern matches. `*` and `?` are illegal in real Elasticsearch index + * names, so their presence identifies a pattern node unambiguously — the clear + * confirmation must spell that blast radius out instead of naming "an index". + */ +export function isElasticsearchIndexPattern(index: string): boolean { + return index.includes("*") || index.includes("?"); +} + +/** + * Whether a concrete index name falls under a grouped node's pattern, so + * surfaces open on that index (e.g. document tabs) know a wildcard clear + * touched their data too. `*` matches any run of characters and `?` exactly + * one, mirroring the sidebar grouping semantics. + */ +export function matchesElasticsearchIndexPattern(pattern: string, index: string): boolean { + const match = (patternOffset: number, indexOffset: number): boolean => { + while (patternOffset < pattern.length) { + const character = pattern[patternOffset]!; + if (character === "*") { + for (let next = indexOffset; next <= index.length; next++) { + if (match(patternOffset + 1, next)) return true; + } + return false; + } + if (character !== "?" && character !== index[indexOffset]) return false; + if (indexOffset >= index.length) return false; + patternOffset++; + indexOffset++; + } + return indexOffset === index.length; + }; + return match(0, 0); +} + +/** + * Whether the operator has cleared the extra hurdle a wildcard node imposes. + * + * A concrete index name confirms with the button alone. A pattern node clears + * every index it matches, so it additionally requires the pattern to be typed + * back verbatim — the same stance Elasticsearch takes with its own + * `action.destructive_requires_name` default. Surrounding whitespace is + * forgiven because it is invisible in the input; nothing else is. + */ +export function isElasticsearchClearConfirmed(index: string, typedName: string): boolean { + if (!isElasticsearchIndexPattern(index)) return true; + return typedName.trim() === index; +} + +/** + * The request the clear action sends, shown verbatim in the confirmation dialog + * so the reviewer can see it deletes documents rather than the index itself. + */ +export function elasticsearchClearIndexPreview(index: string): string { + return `POST /${index}/_delete_by_query?conflicts=proceed&refresh=true\n{ "query": { "match_all": {} } }`; +} + +export const ELASTICSEARCH_INDEX_CLEARED_EVENT = "dbx-elasticsearch-index-cleared"; + +export interface ElasticsearchIndexClearedDetail { + connectionId: string; + index: string; +} + +/** + * Tells an open document browser for this index that its rows are gone. Best + * effort: the clear itself already succeeded by the time this runs, so a + * missing DOM (non-browser host) must not turn into a reported failure. + */ +export function notifyElasticsearchIndexCleared(connectionId: string, index: string): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(ELASTICSEARCH_INDEX_CLEARED_EVENT, { detail: { connectionId, index } })); +} + +/** Returns an unsubscribe function, so callers cannot leak the listener by unregistering a different closure. */ +export function subscribeElasticsearchIndexCleared(listener: (detail: ElasticsearchIndexClearedDetail) => void): () => void { + if (typeof window === "undefined") return () => undefined; + const handleEvent = (event: Event) => listener((event as CustomEvent).detail); + window.addEventListener(ELASTICSEARCH_INDEX_CLEARED_EVENT, handleEvent); + return () => window.removeEventListener(ELASTICSEARCH_INDEX_CLEARED_EVENT, handleEvent); +} + +/** + * `_delete_by_query` returns HTTP 200 even when shards failed or documents were + * skipped on a version conflict, so a plain "cleared" toast can be wrong. Report + * a partial outcome whenever the run did not delete every matched document. + */ +export function isPartialElasticsearchClear(result: ElasticsearchDeleteByQueryResult): boolean { + return result.timedOut || result.failures.length > 0 || result.versionConflicts > 0 || result.deleted < result.total; +} diff --git a/apps/desktop/src/lib/sidebar/sidebarDangerDialog.ts b/apps/desktop/src/lib/sidebar/sidebarDangerDialog.ts index 6d05e664c..dfb9b69b4 100644 --- a/apps/desktop/src/lib/sidebar/sidebarDangerDialog.ts +++ b/apps/desktop/src/lib/sidebar/sidebarDangerDialog.ts @@ -31,6 +31,8 @@ export interface SidebarDangerDialogRequest { details?: string; detailsText?: string; loading?: boolean; + /** Keeps the confirm button held back until the request's own precondition is met (e.g. a typed target name). */ + confirmDisabled?: boolean; closeOnConfirm?: boolean; progress?: SidebarDangerDialogProgress; option?: SidebarDangerDialogOption; diff --git a/crates/dbx-core/src/db/easysearch_driver.rs b/crates/dbx-core/src/db/easysearch_driver.rs index bb7627ea1..4be52a55b 100644 --- a/crates/dbx-core/src/db/easysearch_driver.rs +++ b/crates/dbx-core/src/db/easysearch_driver.rs @@ -82,6 +82,25 @@ pub async fn count_documents(client: &EasysearchClient, index: &str, filter: Opt elasticsearch_driver::count_documents(&client.inner, index, filter).await.map_err(easysearch_error) } +pub async fn get_index_mapping(client: &EasysearchClient, index: &str) -> Result { + elasticsearch_driver::get_index_mapping(&client.inner, index).await.map_err(easysearch_error) +} + +pub async fn get_index_settings(client: &EasysearchClient, index: &str) -> Result { + elasticsearch_driver::get_index_settings(&client.inner, index).await.map_err(easysearch_error) +} + +pub async fn get_index_stats(client: &EasysearchClient, index: &str) -> Result { + elasticsearch_driver::get_index_stats(&client.inner, index).await.map_err(easysearch_error) +} + +pub async fn delete_all_documents( + client: &EasysearchClient, + index: &str, +) -> Result { + elasticsearch_driver::delete_all_documents(&client.inner, index).await.map_err(easysearch_error) +} + pub async fn insert_document( client: &EasysearchClient, index: &str, diff --git a/crates/dbx-core/src/db/elasticsearch_driver.rs b/crates/dbx-core/src/db/elasticsearch_driver.rs index d3ea7fccf..de7487d72 100644 --- a/crates/dbx-core/src/db/elasticsearch_driver.rs +++ b/crates/dbx-core/src/db/elasticsearch_driver.rs @@ -1101,6 +1101,91 @@ pub async fn count_documents(client: &EsClient, index: &str, filter: Option<&str Ok(result.count) } +/// `GET /{index}/_mapping` —— 字段映射。 +pub async fn get_index_mapping(client: &EsClient, index: &str) -> Result { + get_index_metadata(client, index, "_mapping").await +} + +/// `GET /{index}/_settings` —— 索引配置(分片数、副本数、analysis 等)。 +pub async fn get_index_settings(client: &EsClient, index: &str) -> Result { + get_index_metadata(client, index, "_settings").await +} + +/// `GET /{index}/_stats` —— 索引统计(文档数、存储大小、各类操作计数)。 +pub async fn get_index_stats(client: &EsClient, index: &str) -> Result { + get_index_metadata(client, index, "_stats").await +} + +/// 侧边栏的索引节点可能是 `index_grouping` 折叠出的通配模式(如 `logs-*`)。 +/// 这三个端点都原生接受通配,`elasticsearch_path_segment` 也不转义 `*`, +/// 因此聚合节点会如实返回该模式命中的全部索引。 +async fn get_index_metadata(client: &EsClient, index: &str, endpoint: &str) -> Result { + let path = elasticsearch_index_path(index, endpoint); + let resp = client.get(&path).send().await.map_err(|e| format!("Elasticsearch request failed: {e}"))?; + if !client.response_status(&resp).is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Elasticsearch error: {body}")); + } + resp.json().await.map_err(|e| format!("Elasticsearch parse error: {e}")) +} + +/// 清空索引数据的结果。文档删除可能部分失败(分片错误、并发写入), +/// 所以返回计数与失败明细,让调用方能如实告知用户而不是笼统报成功。 +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ElasticsearchDeleteByQueryResult { + /// 匹配到的文档数。 + pub total: u64, + /// 实际删除的文档数。 + pub deleted: u64, + /// 因并发写入导致的版本冲突数(`conflicts=proceed` 下会被跳过而非中断)。 + pub version_conflicts: u64, + /// 请求是否在删完前超时。 + pub timed_out: bool, + /// 分片级失败的原始 JSON,逐条保留以便排查。 + pub failures: Vec, +} + +#[derive(Deserialize)] +struct DeleteByQueryResponse { + #[serde(default)] + total: u64, + #[serde(default)] + deleted: u64, + #[serde(default)] + version_conflicts: u64, + #[serde(default)] + timed_out: bool, + #[serde(default)] + failures: Vec, +} + +/// 清空索引数据:`POST /{index}/_delete_by_query` + `match_all`,只删文档, +/// 保留 mapping、settings、别名。 +/// +/// `conflicts=proceed` 让并发写入引起的版本冲突被跳过而不是中断整批删除; +/// `refresh=true` 让删除立即对随后的搜索与计数可见,否则清空后刷新数据页 +/// 仍会看到旧文档。 +pub async fn delete_all_documents(client: &EsClient, index: &str) -> Result { + let path = format!("{}?conflicts=proceed&refresh=true", elasticsearch_index_path(index, "_delete_by_query")); + let body = serde_json::json!({ "query": { "match_all": {} } }); + let resp = client.post(&path).json(&body).send().await.map_err(|e| format!("Elasticsearch request failed: {e}"))?; + if !client.response_status(&resp).is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Elasticsearch error: {body}")); + } + + let result: DeleteByQueryResponse = + resp.json().await.map_err(|e| format!("Elasticsearch delete-by-query parse error: {e}"))?; + Ok(ElasticsearchDeleteByQueryResult { + total: result.total, + deleted: result.deleted, + version_conflicts: result.version_conflicts, + timed_out: result.timed_out, + failures: result.failures.iter().map(Value::to_string).collect(), + }) +} + fn search_response_to_document_result(result: SearchResponse) -> Result { // A 200 search response can still omit failed shards. Only expose an // exact total when both the total relation and shard metadata agree. @@ -4226,6 +4311,107 @@ mod tests { assert_eq!(result.rows[0][1].as_str(), Some(response_body)); } + #[tokio::test] + async fn index_metadata_endpoints_read_their_index_scoped_paths() { + use tokio::io::AsyncWriteExt; + + for (kind, endpoint) in [("mapping", "_mapping"), ("settings", "_settings"), ("stats", "_stats")] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let expected = format!("GET /my-index/{endpoint} "); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut socket).await; + assert!(request.starts_with(&expected), "unexpected request: {request}"); + let body = r#"{"my-index":{}}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + socket.write_all(response.as_bytes()).await.unwrap(); + }); + + let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(1)); + let value = match kind { + "mapping" => super::get_index_mapping(&client, "my-index").await.unwrap(), + "settings" => super::get_index_settings(&client, "my-index").await.unwrap(), + _ => super::get_index_stats(&client, "my-index").await.unwrap(), + }; + server.await.unwrap(); + assert_eq!(value, json!({ "my-index": {} })); + } + } + + #[tokio::test] + async fn delete_all_documents_posts_match_all_and_reports_skipped_documents() { + use tokio::io::AsyncWriteExt; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut socket).await; + // The index itself must survive: this is a delete-by-query, not a DELETE /index. + assert!( + request.starts_with("POST /logs/_delete_by_query?conflicts=proceed&refresh=true "), + "unexpected request: {request}" + ); + assert!(request.contains(r#""match_all""#), "unexpected body: {request}"); + let body = + r#"{"total":10,"deleted":8,"version_conflicts":2,"timed_out":false,"failures":[{"index":"logs"}]}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + socket.write_all(response.as_bytes()).await.unwrap(); + }); + + let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(1)); + let result = super::delete_all_documents(&client, "logs").await.unwrap(); + server.await.unwrap(); + + assert_eq!(result.total, 10); + assert_eq!(result.deleted, 8); + assert_eq!(result.version_conflicts, 2); + assert!(!result.timed_out); + assert_eq!(result.failures, vec![r#"{"index":"logs"}"#.to_string()]); + } + + /// ES 6.x through 9.x all answer `_delete_by_query` with the same counter + /// names wrapped in a larger, version-dependent envelope (`retries`, + /// `throttled_millis`, `slices`, …). Parsing must ignore the envelope + /// instead of failing on fields a given server happens to add. + #[tokio::test] + async fn delete_all_documents_ignores_version_specific_response_fields() { + use tokio::io::AsyncWriteExt; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + read_http_request(&mut socket).await; + let body = r#"{"took":42,"timed_out":false,"total":5,"deleted":5,"batches":1,"version_conflicts":0,"noops":0,"retries":{"bulk":0,"search":0},"throttled_millis":0,"requests_per_second":-1.0,"throttled_until_millis":0,"slices":[],"failures":[]}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + socket.write_all(response.as_bytes()).await.unwrap(); + }); + + let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(1)); + let result = super::delete_all_documents(&client, "logs").await.unwrap(); + server.await.unwrap(); + + assert_eq!(result.total, 5); + assert_eq!(result.deleted, 5); + assert_eq!(result.version_conflicts, 0); + assert!(!result.timed_out); + assert!(result.failures.is_empty()); + } + #[tokio::test] async fn execute_rest_head_request_returns_http_status() { use tokio::io::AsyncWriteExt; diff --git a/crates/dbx-core/src/document_ops.rs b/crates/dbx-core/src/document_ops.rs index f78caf88d..11b57604a 100644 --- a/crates/dbx-core/src/document_ops.rs +++ b/crates/dbx-core/src/document_ops.rs @@ -27,6 +27,15 @@ pub struct MongoGridFsBucketInfo { pub total_bytes: i64, } +/// 侧边栏「查看索引 Mapping / 索引配置 / 索引统计」请求的只读元数据端点。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ElasticsearchIndexMetadataKind { + Mapping, + Settings, + Stats, +} + fn cmp_names(left: &str, right: &str) -> std::cmp::Ordering { let left_lower = left.to_lowercase(); let right_lower = right.to_lowercase(); @@ -568,6 +577,66 @@ pub async fn count_elasticsearch_documents_core( } } +/// Elasticsearch/Easysearch 索引的只读元数据端点。`kind` 选择 `_mapping` +/// (字段映射)、`_settings`(索引配置)或 `_stats`(索引统计)。 +pub async fn elasticsearch_get_index_metadata_core( + state: &AppState, + connection_id: &str, + index: &str, + kind: ElasticsearchIndexMetadataKind, +) -> Result { + ensure_document_pool(state, connection_id).await?; + let connections = state.connections.read().await; + match connections.get(connection_id).ok_or("Not found")? { + PoolKind::Elasticsearch(client) => { + let client = client.clone(); + drop(connections); + match kind { + ElasticsearchIndexMetadataKind::Mapping => { + elasticsearch_driver::get_index_mapping(&client, index).await + } + ElasticsearchIndexMetadataKind::Settings => { + elasticsearch_driver::get_index_settings(&client, index).await + } + ElasticsearchIndexMetadataKind::Stats => elasticsearch_driver::get_index_stats(&client, index).await, + } + } + PoolKind::Easysearch(client) => { + let client = client.clone(); + drop(connections); + match kind { + ElasticsearchIndexMetadataKind::Mapping => easysearch_driver::get_index_mapping(&client, index).await, + ElasticsearchIndexMetadataKind::Settings => easysearch_driver::get_index_settings(&client, index).await, + ElasticsearchIndexMetadataKind::Stats => easysearch_driver::get_index_stats(&client, index).await, + } + } + _ => Err("Not an Elasticsearch connection".to_string()), + } +} + +/// 清空索引数据:删除全部文档,保留 mapping、settings 与别名。 +pub async fn elasticsearch_delete_all_documents_core( + state: &AppState, + connection_id: &str, + index: &str, +) -> Result { + ensure_document_pool(state, connection_id).await?; + let connections = state.connections.read().await; + match connections.get(connection_id).ok_or("Not found")? { + PoolKind::Elasticsearch(client) => { + let client = client.clone(); + drop(connections); + elasticsearch_driver::delete_all_documents(&client, index).await + } + PoolKind::Easysearch(client) => { + let client = client.clone(); + drop(connections); + easysearch_driver::delete_all_documents(&client, index).await + } + _ => Err("Not an Elasticsearch connection".to_string()), + } +} + fn is_unknown_agent_method_error(error: &str, method: &str) -> bool { let lower = error.to_ascii_lowercase(); lower.contains(method) && (lower.contains("unknown method") || lower.contains("method not found")) @@ -1170,4 +1239,24 @@ mod tests { assert!(error.contains("Unsupported GridFS bucket sort field")); } + + /// The desktop and web frontends send these three literals for the index + /// metadata menu items. A rename on either side silently breaks all three + /// actions, so pin the wire strings here. + #[test] + fn elasticsearch_index_metadata_kind_matches_the_frontend_wire_strings() { + use super::ElasticsearchIndexMetadataKind; + + for (kind, wire) in [ + (ElasticsearchIndexMetadataKind::Mapping, "mapping"), + (ElasticsearchIndexMetadataKind::Settings, "settings"), + (ElasticsearchIndexMetadataKind::Stats, "stats"), + ] { + assert_eq!(serde_json::to_value(kind).unwrap(), serde_json::json!(wire)); + assert_eq!( + serde_json::from_value::(serde_json::json!(wire)).unwrap(), + kind + ); + } + } } diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 96c8c5ac9..850897030 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -818,6 +818,14 @@ async fn main() { "/document-store/elasticsearch-count-documents", post(routes::document_store::elasticsearch_count_documents), ) + .route( + "/document-store/elasticsearch/index-metadata", + post(routes::document_store::elasticsearch_get_index_metadata), + ) + .route( + "/document-store/elasticsearch/documents/delete-all", + post(routes::document_store::elasticsearch_delete_all_documents), + ) .route("/document-store/list-gridfs-buckets", post(routes::document_store::list_gridfs_buckets)) .route("/document-store/create-gridfs-bucket", post(routes::document_store::create_gridfs_bucket)) .route("/document-store/delete-gridfs-bucket", post(routes::document_store::delete_gridfs_bucket)) diff --git a/crates/dbx-web/src/routes/document_store.rs b/crates/dbx-web/src/routes/document_store.rs index fbf172310..22cd2650f 100644 --- a/crates/dbx-web/src/routes/document_store.rs +++ b/crates/dbx-web/src/routes/document_store.rs @@ -98,6 +98,21 @@ pub struct ElasticsearchCountDocumentsRequest { pub execution_id: Option, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ElasticsearchIndexRequest { + pub connection_id: String, + pub index: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ElasticsearchIndexMetadataRequest { + pub connection_id: String, + pub index: String, + pub kind: dbx_core::document_ops::ElasticsearchIndexMetadataKind, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct DocumentInsertRequest { @@ -385,6 +400,33 @@ pub async fn elasticsearch_count_documents( Ok(Json(result)) } +pub async fn elasticsearch_get_index_metadata( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + let result = dbx_core::document_ops::elasticsearch_get_index_metadata_core( + &state.app, + &req.connection_id, + &req.index, + req.kind, + ) + .await + .map_err(AppError::from)?; + Ok(Json(result)) +} + +pub async fn elasticsearch_delete_all_documents( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "Delete all documents").await?; + let result = + dbx_core::document_ops::elasticsearch_delete_all_documents_core(&state.app, &req.connection_id, &req.index) + .await + .map_err(AppError::from)?; + Ok(Json(result)) +} + pub async fn insert_document( State(state): State>, Json(req): Json, diff --git a/src-tauri/src/commands/document_cmd.rs b/src-tauri/src/commands/document_cmd.rs index 294c2f506..8eb4881e7 100644 --- a/src-tauri/src/commands/document_cmd.rs +++ b/src-tauri/src/commands/document_cmd.rs @@ -132,6 +132,26 @@ pub async fn elasticsearch_count_documents( .await } +#[tauri::command] +pub async fn elasticsearch_get_index_metadata( + state: State<'_, Arc>, + connection_id: String, + index: String, + kind: dbx_core::document_ops::ElasticsearchIndexMetadataKind, +) -> Result { + dbx_core::document_ops::elasticsearch_get_index_metadata_core(&state, &connection_id, &index, kind).await +} + +#[tauri::command] +pub async fn elasticsearch_delete_all_documents( + state: State<'_, Arc>, + connection_id: String, + index: String, +) -> Result { + ensure_connection_writable(&state, &connection_id, "Delete all documents").await?; + dbx_core::document_ops::elasticsearch_delete_all_documents_core(&state, &connection_id, &index).await +} + #[tauri::command] pub async fn document_insert_document( state: State<'_, Arc>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a3ac53634..2cc949ff4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2198,6 +2198,8 @@ pub fn run() { commands::document_cmd::document_count_documents, commands::document_cmd::dynamodb_describe_table, commands::document_cmd::elasticsearch_count_documents, + commands::document_cmd::elasticsearch_get_index_metadata, + commands::document_cmd::elasticsearch_delete_all_documents, commands::document_cmd::document_list_gridfs_buckets, commands::document_cmd::document_create_gridfs_bucket, commands::document_cmd::document_delete_gridfs_bucket,