mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(elasticsearch): add mapping, settings, stats and clear actions
This commit is contained in:
@@ -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 });
|
||||
|
||||
apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.elasticsearchClearGuard.spec.ts
Vendored
+64
@@ -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 = "";
|
||||
|
||||
+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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "新しいコレクション名",
|
||||
|
||||
@@ -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: "새 컬렉션 이름",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "新集合名",
|
||||
|
||||
@@ -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: "新集合名稱",
|
||||
|
||||
+58
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user