feat(elasticsearch): add mapping, settings, stats and clear actions

This commit is contained in:
onenewcode authored and GitHub committed 2026-08-31 22:31:35 +08:00
1 parent a84cb637ba
commit 8f3134dce8
32 files changed
+1262 -3

No files matched your search

@@ -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<string, unknown>) => (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<string, unknown> = {}) {
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);
});
});
@@ -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<ReturnType<typeof api.elasticsearchDeleteAllDocuments>> } | 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,