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

This commit is contained in:
onenewcode
2026-08-31 22:31:35 +08:00
committed by GitHub
parent a84cb637ba
commit 8f3134dce8
32 changed files with 1262 additions and 3 deletions
@@ -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;
@@ -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") }}
</Button>
<Button v-else variant="outline" :disabled="loading" @click="open = false">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="destructive" class="gap-1.5" :disabled="loading" @click="onConfirm">
<Button variant="destructive" class="gap-1.5" :disabled="loading || confirmDisabled" @click="onConfirm">
<Loader2 v-if="loading" class="h-3.5 w-3.5 animate-spin" />
{{ confirmLabel || t("dangerDialog.confirm") }}
</Button>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Clipboard, Loader2, RefreshCw } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
import { useToast } from "@/composables/useToast";
import { copyToClipboard } from "@/lib/common/clipboard";
import * as api from "@/lib/backend/api";
import { translateBackendError } from "@/i18n/backend-errors";
import type { ElasticsearchIndexMetadataKind } from "@/lib/backend/tauri";
const props = defineProps<{
open: boolean;
connectionId: string;
index: string;
kind: ElasticsearchIndexMetadataKind;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
}>();
const { t } = useI18n();
const { toast } = useToast();
const content = ref("");
const loading = ref(false);
const error = ref("");
const titleKey: Record<ElasticsearchIndexMetadataKind, string> = {
mapping: "contextMenu.elasticsearchViewMapping",
settings: "contextMenu.elasticsearchViewSettings",
stats: "contextMenu.elasticsearchViewStats",
};
const dialogTitle = computed(() => `${t(titleKey[props.kind])} - ${props.index}`);
async function load() {
error.value = "";
loading.value = true;
try {
const metadata = await api.elasticsearchGetIndexMetadata(props.connectionId, props.index, props.kind);
content.value = JSON.stringify(metadata, null, 2);
} catch (e: any) {
content.value = "";
error.value = translateBackendError(t, e);
} finally {
loading.value = false;
}
}
// Reload on open, and when the menu reopens the dialog for a different endpoint
// of the same index (mapping → settings) without an intervening close.
watch(
() => [props.open, props.connectionId, props.index, props.kind] as const,
([open]) => {
if (!open) return;
content.value = "";
void load();
},
{ immediate: true },
);
async function copyContent() {
if (!content.value) return;
try {
await copyToClipboard(content.value);
toast(t("connection.copied"), 2000);
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
function onClose() {
emit("update:open", false);
}
</script>
<template>
<Dialog :open="props.open" @update:open="onClose">
<DialogContent class="sm:max-w-190">
<DialogHeader>
<!-- Index names can be long enough to truncate; the title attribute keeps the full name reachable. -->
<DialogTitle class="truncate" :title="dialogTitle">{{ dialogTitle }}</DialogTitle>
</DialogHeader>
<!--
The frame keeps a definite height on purpose. The editor chain below it
is sized in percentages, and a percentage height against a min-height-only
parent collapses to auto — CodeMirror would then grow past the frame and
a long mapping would be clipped with no scrollbar.
-->
<div class="flex h-[60vh] min-h-80 flex-col overflow-hidden rounded border">
<div v-if="loading" class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
<span>{{ t("common.loading") }}</span>
</div>
<div v-else-if="error" class="flex flex-1 flex-col items-center justify-center gap-3 p-4 text-sm">
<p class="text-center break-all text-destructive">{{ error }}</p>
<Button variant="outline" size="sm" @click="load">
<RefreshCw />
{{ t("common.retry") }}
</Button>
</div>
<RedisJsonEditor v-else :model-value="content" read-only word-wrap :line-numbers="false" presentation="viewer" class="min-h-0 flex-1" />
</div>
<DialogFooter>
<Button variant="outline" @click="onClose">{{ t("common.close") }}</Button>
<Button variant="outline" :disabled="loading" @click="load">
<RefreshCw class="h-4 w-4" />
{{ t("grid.refresh") }}
</Button>
<Button variant="outline" :disabled="!content" @click="copyContent">
<Clipboard class="h-4 w-4" />
{{ t("common.copy") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -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<TreeNode | null>(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<TreeNode | null>(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)"
/>
<SidebarElasticsearchIndexMetadataDialog
v-if="sidebarElasticsearchIndexMetadataTarget"
v-model:open="sidebarElasticsearchIndexMetadataOpen"
:connection-id="sidebarElasticsearchIndexMetadataTarget.node.connectionId!"
:index="sidebarElasticsearchIndexMetadataTarget.node.label"
:kind="sidebarElasticsearchIndexMetadataTarget.kind"
/>
<SidebarObjectSourceDialog
v-if="sidebarObjectSourceTarget && sidebarObjectSourceType"
v-model:open="sidebarObjectSourceOpen"
@@ -2684,6 +2710,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
:details-text="sidebarDangerDialogRequest.detailsText"
:confirm-label="sidebarDangerDialogRequest.confirmLabel"
:loading="sidebarDangerDialogConfirming || sidebarDangerDialogRequest.loading"
:confirm-disabled="sidebarDangerDialogRequest.confirmDisabled"
:close-on-confirm="false"
:cancelable="!!sidebarDangerDialogRequest.cancelRunning"
:cancel-running-loading="sidebarDangerDialogCancelling"
@@ -11,7 +11,9 @@ import { useSidebarTreeToolRuntime } from "@/composables/useSidebarTreeToolRunti
import { useI18n } from "vue-i18n";
import { translateBackendError } from "@/i18n/backend-errors";
import {
BarChart3,
BookOpen,
Braces,
Database,
ChevronsDown,
FolderOpen,
@@ -70,6 +72,7 @@ import { useToast } from "@/composables/useToast";
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
import type { ColumnInfo, DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
import * as api from "@/lib/backend/api";
import type { ElasticsearchIndexMetadataKind } from "@/lib/backend/tauri";
import { queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
import { connectionUsesVisibleSchemaFilter } from "@/lib/database/visibleDatabases";
@@ -82,6 +85,7 @@ import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting"
import { isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers";
import { isXuguSyntheticTreeNode } from "@/lib/sidebar/xuguPublicSynonyms";
import { buildXuguSchedulerJobSql, type XuguSchedulerJobAction } from "@/lib/database/xuguSchedulerJobSql";
import { elasticsearchClearIndexPreview, isElasticsearchClearConfirmed, isElasticsearchIndexPattern } from "@/lib/sidebar/elasticsearchIndexActions";
import { mysqlObjectTemplateForGroup } from "@/lib/sidebar/mysqlObjectTemplates";
import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate } from "@/lib/table/tableSqlTemplates";
import { qualifiedTableName } from "@/lib/table/tableSelectSql";
@@ -296,6 +300,9 @@ import {
mongoIndexManagerSelectedName,
mongoIndexManagerMode,
mongoEditIndexOriginalName,
showClearElasticsearchIndexConfirm,
clearElasticsearchIndexLoading,
clearElasticsearchIndexTypedName,
showFlushRedisDbConfirm,
showCreateSchemaDialog,
createSchemaName,
@@ -393,6 +400,7 @@ const emit = defineEmits<{
"search-toggle": [node: TreeNode];
"context-menu": [event: MouseEvent, node: TreeNode, items: ContextMenuItem[]];
"open-ddl": [node: TreeNode];
"open-elasticsearch-index-metadata": [node: TreeNode, kind: ElasticsearchIndexMetadataKind];
"open-object-source": [node: TreeNode, initialEditing: boolean];
"open-procedure": [node: TreeNode];
"open-settings": [initialTab: string];
@@ -527,6 +535,9 @@ const {
dropMilvusCollection,
dropMongoIndex,
dropAllMongoIndexes,
canManageElasticsearchIndex,
clearElasticsearchIndex,
confirmClearElasticsearchIndex,
flushRedisDb,
prepareRedisDatabaseAliasDialog,
confirmRedisDatabaseAlias,
@@ -1918,6 +1929,10 @@ async function openDdl() {
emit("open-ddl", targets[0]!);
}
function openElasticsearchIndexMetadata(kind: ElasticsearchIndexMetadataKind) {
emit("open-elasticsearch-index-metadata", createSidebarActionTarget(activeNode.value), kind);
}
async function refresh() {
const node = activeNode.value;
try {
@@ -4555,6 +4570,51 @@ routeDangerDialog(showDropAllMongoIndexesConfirm, () =>
}),
);
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 });
@@ -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,");
});
});
@@ -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"));
@@ -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 = "";
@@ -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,
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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: "新しいコレクション名",
+11
View File
@@ -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: "새 컬렉션 이름",
+11
View File
@@ -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",
+11
View File
@@ -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: "新集合名",
+11
View File
@@ -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: "新集合名稱",
@@ -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");
});
});
+2
View File
@@ -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
+17
View File
@@ -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<Record<string, any>> {
return post("/api/document-store/elasticsearch/index-metadata", {
connectionId,
index,
kind,
});
}
export async function elasticsearchDeleteAllDocuments(connectionId: string, index: string): Promise<ElasticsearchDeleteByQueryResult> {
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<number> {
return post("/api/mongo/count-documents", {
connectionId,
+27
View File
@@ -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<Record<string, any>> {
return invoke("elasticsearch_get_index_metadata", {
connectionId,
index,
kind,
});
}
export async function elasticsearchDeleteAllDocuments(connectionId: string, index: string): Promise<ElasticsearchDeleteByQueryResult> {
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<number> {
return invoke("mongo_count_documents", {
connectionId,
@@ -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> = {}): 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);
});
});
@@ -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<ElasticsearchIndexClearedDetail>(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<ElasticsearchIndexClearedDetail>).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;
}
@@ -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;
@@ -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<Value, String> {
elasticsearch_driver::get_index_mapping(&client.inner, index).await.map_err(easysearch_error)
}
pub async fn get_index_settings(client: &EasysearchClient, index: &str) -> Result<Value, String> {
elasticsearch_driver::get_index_settings(&client.inner, index).await.map_err(easysearch_error)
}
pub async fn get_index_stats(client: &EasysearchClient, index: &str) -> Result<Value, String> {
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::ElasticsearchDeleteByQueryResult, String> {
elasticsearch_driver::delete_all_documents(&client.inner, index).await.map_err(easysearch_error)
}
pub async fn insert_document(
client: &EasysearchClient,
index: &str,
@@ -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<Value, String> {
get_index_metadata(client, index, "_mapping").await
}
/// `GET /{index}/_settings` —— 索引配置(分片数、副本数、analysis 等)。
pub async fn get_index_settings(client: &EsClient, index: &str) -> Result<Value, String> {
get_index_metadata(client, index, "_settings").await
}
/// `GET /{index}/_stats` —— 索引统计(文档数、存储大小、各类操作计数)。
pub async fn get_index_stats(client: &EsClient, index: &str) -> Result<Value, String> {
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<Value, String> {
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<String>,
}
#[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<Value>,
}
/// 清空索引数据:`POST /{index}/_delete_by_query` + `match_all`,只删文档,
/// 保留 mapping、settings、别名。
///
/// `conflicts=proceed` 让并发写入引起的版本冲突被跳过而不是中断整批删除;
/// `refresh=true` 让删除立即对随后的搜索与计数可见,否则清空后刷新数据页
/// 仍会看到旧文档。
pub async fn delete_all_documents(client: &EsClient, index: &str) -> Result<ElasticsearchDeleteByQueryResult, String> {
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<DocumentQueryResult, String> {
// 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;
+89
View File
@@ -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<serde_json::Value, String> {
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<elasticsearch_driver::ElasticsearchDeleteByQueryResult, String> {
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::<ElasticsearchIndexMetadataKind>(serde_json::json!(wire)).unwrap(),
kind
);
}
}
}
+8
View File
@@ -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))
@@ -98,6 +98,21 @@ pub struct ElasticsearchCountDocumentsRequest {
pub execution_id: Option<String>,
}
#[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<Arc<WebState>>,
Json(req): Json<ElasticsearchIndexMetadataRequest>,
) -> Result<Json<serde_json::Value>, 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<Arc<WebState>>,
Json(req): Json<ElasticsearchIndexRequest>,
) -> Result<Json<dbx_core::db::elasticsearch_driver::ElasticsearchDeleteByQueryResult>, 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<Arc<WebState>>,
Json(req): Json<DocumentInsertRequest>,
+20
View File
@@ -132,6 +132,26 @@ pub async fn elasticsearch_count_documents(
.await
}
#[tauri::command]
pub async fn elasticsearch_get_index_metadata(
state: State<'_, Arc<AppState>>,
connection_id: String,
index: String,
kind: dbx_core::document_ops::ElasticsearchIndexMetadataKind,
) -> Result<serde_json::Value, String> {
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<AppState>>,
connection_id: String,
index: String,
) -> Result<dbx_core::db::elasticsearch_driver::ElasticsearchDeleteByQueryResult, String> {
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<AppState>>,
+2
View File
@@ -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,