feat(elasticsearch): add mapping, settings, stats and clear actions
This commit is contained in:
1 parent
a84cb637ba
commit
8f3134dce8
32 files changed
+1262
-3
No files matched your search
+156
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user