feat(elasticsearch): add mapping, settings, stats and clear actions
This commit is contained in:
1 parent
a84cb637ba
commit
8f3134dce8
32 files changed
+1262
-3
No files matched your search
@@ -88,6 +88,7 @@ import type { GridNewRowMeta } from "@/lib/dataGrid/gridNewRowPlacement";
|
|||||||
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
|
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
|
||||||
import { documentDataGridColumnLayoutScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
|
import { documentDataGridColumnLayoutScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
|
||||||
import { documentGridColumnVisibilityScopeKey, migrateDocumentGridColumnVisibilityToLayout } from "@/lib/document/documentGridColumnVisibilityStorage";
|
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 { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore } from "@/stores/settingsStore";
|
||||||
import { useToast } from "@/composables/useToast";
|
import { useToast } from "@/composables/useToast";
|
||||||
import JsonEditNode from "./JsonEditNode.vue";
|
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 () => {
|
onMounted(async () => {
|
||||||
window.addEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
|
window.addEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
|
||||||
|
unsubscribeElasticsearchIndexCleared = subscribeElasticsearchIndexCleared(handleElasticsearchIndexCleared);
|
||||||
try {
|
try {
|
||||||
await connectionStore.ensureConnected(props.connectionId);
|
await connectionStore.ensureConnected(props.connectionId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -2078,6 +2095,8 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
|
window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
|
||||||
|
unsubscribeElasticsearchIndexCleared?.();
|
||||||
|
unsubscribeElasticsearchIndexCleared = undefined;
|
||||||
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
|
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
|
||||||
documentRequestGeneration++;
|
documentRequestGeneration++;
|
||||||
loadedDocumentQueryTotalCountRequest = undefined;
|
loadedDocumentQueryTotalCountRequest = undefined;
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ const props = withDefaults(
|
|||||||
closeOnConfirm?: boolean;
|
closeOnConfirm?: boolean;
|
||||||
cancelable?: boolean;
|
cancelable?: boolean;
|
||||||
cancelRunningLoading?: 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: "",
|
sql: "",
|
||||||
@@ -49,6 +51,7 @@ const props = withDefaults(
|
|||||||
closeOnConfirm: true,
|
closeOnConfirm: true,
|
||||||
cancelable: false,
|
cancelable: false,
|
||||||
cancelRunningLoading: false,
|
cancelRunningLoading: false,
|
||||||
|
confirmDisabled: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -71,7 +74,9 @@ const dialogOpen = computed({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function onConfirm() {
|
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;
|
if (props.closeOnConfirm) open.value = false;
|
||||||
emit("confirm");
|
emit("confirm");
|
||||||
}
|
}
|
||||||
@@ -129,7 +134,7 @@ async function copyFullCode() {
|
|||||||
{{ t("dangerDialog.cancelRunning") }}
|
{{ t("dangerDialog.cancelRunning") }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button v-else variant="outline" :disabled="loading" @click="open = false">{{ t("dangerDialog.cancel") }}</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" />
|
<Loader2 v-if="loading" class="h-3.5 w-3.5 animate-spin" />
|
||||||
{{ confirmLabel || t("dangerDialog.confirm") }}
|
{{ confirmLabel || t("dangerDialog.confirm") }}
|
||||||
</Button>
|
</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 { useSettingsStore } from "@/stores/settingsStore";
|
||||||
import { useToast } from "@/composables/useToast";
|
import { useToast } from "@/composables/useToast";
|
||||||
import type { ObjectSourceKind, QueryTab, TableInfo, TableNameFilter, TreeNode, TreeNodeType } from "@/types/database";
|
import type { ObjectSourceKind, QueryTab, TableInfo, TableNameFilter, TreeNode, TreeNodeType } from "@/types/database";
|
||||||
|
import type { ElasticsearchIndexMetadataKind } from "@/lib/backend/tauri";
|
||||||
import {
|
import {
|
||||||
createSidebarSearchSubtreePreserver,
|
createSidebarSearchSubtreePreserver,
|
||||||
filterSidebarSearchRootsByConnectionState,
|
filterSidebarSearchRootsByConnectionState,
|
||||||
@@ -79,7 +80,7 @@ import { createSidebarActionTarget, findSidebarActionTarget, matchesSidebarActio
|
|||||||
import { syncSidebarTreeNodeExpansion } from "@/lib/sidebar/sidebarTreeExpansion";
|
import { syncSidebarTreeNodeExpansion } from "@/lib/sidebar/sidebarTreeExpansion";
|
||||||
import type { SidebarDangerDialogOption, SidebarDangerDialogRequest } from "@/lib/sidebar/sidebarDangerDialog";
|
import type { SidebarDangerDialogOption, SidebarDangerDialogRequest } from "@/lib/sidebar/sidebarDangerDialog";
|
||||||
import { resetSidebarTreeDialogState, sidebarDangerRunningExecutionId } from "./sidebarTreeDialogState";
|
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 { sortConnectionListForDisplay } from "@/lib/sidebar/connectionListSort";
|
||||||
import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay";
|
import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay";
|
||||||
import { alignedSidebarCommentLabelWidths, isSidebarCommentAlignableNode, sidebarTreeNaturalContentWidth, sidebarTreeNodeComment, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
|
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 sidebarTreeRuntimeInitialNode: TreeNode = { id: "__sidebar-runtime__", label: "", type: "connection-group" };
|
||||||
const sidebarDdlTarget = ref<TreeNode | null>(null);
|
const sidebarDdlTarget = ref<TreeNode | null>(null);
|
||||||
const sidebarDdlOpen = ref(false);
|
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 sidebarObjectSourceTarget = ref<{ node: TreeNode; initialEditing: boolean } | null>(null);
|
||||||
const sidebarObjectSourceOpen = ref(false);
|
const sidebarObjectSourceOpen = ref(false);
|
||||||
const sidebarProcedureTarget = ref<TreeNode | null>(null);
|
const sidebarProcedureTarget = ref<TreeNode | null>(null);
|
||||||
@@ -1921,6 +1924,7 @@ async function openSidebarExtensionDetails(node: TreeNode) {
|
|||||||
function beginSidebarAction(): number {
|
function beginSidebarAction(): number {
|
||||||
sidebarActionGeneration += 1;
|
sidebarActionGeneration += 1;
|
||||||
sidebarDdlOpen.value = false;
|
sidebarDdlOpen.value = false;
|
||||||
|
sidebarElasticsearchIndexMetadataOpen.value = false;
|
||||||
sidebarObjectSourceOpen.value = false;
|
sidebarObjectSourceOpen.value = false;
|
||||||
sidebarProcedureOpen.value = false;
|
sidebarProcedureOpen.value = false;
|
||||||
sidebarVisibleDatabasesOpen.value = false;
|
sidebarVisibleDatabasesOpen.value = false;
|
||||||
@@ -1928,6 +1932,7 @@ function beginSidebarAction(): number {
|
|||||||
sidebarVisibleNacosNamespacesOpen.value = false;
|
sidebarVisibleNacosNamespacesOpen.value = false;
|
||||||
sidebarTableNameFilterOpen.value = false;
|
sidebarTableNameFilterOpen.value = false;
|
||||||
sidebarDdlTarget.value = null;
|
sidebarDdlTarget.value = null;
|
||||||
|
sidebarElasticsearchIndexMetadataTarget.value = null;
|
||||||
sidebarObjectSourceTarget.value = null;
|
sidebarObjectSourceTarget.value = null;
|
||||||
sidebarProcedureTarget.value = null;
|
sidebarProcedureTarget.value = null;
|
||||||
sidebarVisibleDatabasesTarget.value = null;
|
sidebarVisibleDatabasesTarget.value = null;
|
||||||
@@ -1958,6 +1963,13 @@ function openSidebarDdlForSelection(): boolean {
|
|||||||
return true;
|
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) {
|
function openSidebarObjectSource(node: TreeNode, initialEditing: boolean) {
|
||||||
if (!node.connectionId || !node.database || !objectSourceTargetForTreeNode(node)) return;
|
if (!node.connectionId || !node.database || !objectSourceTargetForTreeNode(node)) return;
|
||||||
// TYPE/TYPE_BODY only have a source implementation on Xugu; PostgreSQL-family
|
// TYPE/TYPE_BODY only have a source implementation on Xugu; PostgreSQL-family
|
||||||
@@ -2114,6 +2126,10 @@ watch(sidebarDdlOpen, (open) => {
|
|||||||
if (!open) sidebarDdlTarget.value = null;
|
if (!open) sidebarDdlTarget.value = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(sidebarElasticsearchIndexMetadataOpen, (open) => {
|
||||||
|
if (!open) sidebarElasticsearchIndexMetadataTarget.value = null;
|
||||||
|
});
|
||||||
|
|
||||||
watch(sidebarObjectSourceOpen, (open) => {
|
watch(sidebarObjectSourceOpen, (open) => {
|
||||||
if (!open) sidebarObjectSourceTarget.value = null;
|
if (!open) sidebarObjectSourceTarget.value = null;
|
||||||
});
|
});
|
||||||
@@ -2356,6 +2372,7 @@ onUnmounted(() => {
|
|||||||
sidebarContextMenuTarget.value = null;
|
sidebarContextMenuTarget.value = null;
|
||||||
sidebarContextMenuItems.value = [];
|
sidebarContextMenuItems.value = [];
|
||||||
sidebarDdlTarget.value = null;
|
sidebarDdlTarget.value = null;
|
||||||
|
sidebarElasticsearchIndexMetadataTarget.value = null;
|
||||||
sidebarObjectSourceTarget.value = null;
|
sidebarObjectSourceTarget.value = null;
|
||||||
sidebarProcedureTarget.value = null;
|
sidebarProcedureTarget.value = null;
|
||||||
sidebarVisibleDatabasesTarget.value = null;
|
sidebarVisibleDatabasesTarget.value = null;
|
||||||
@@ -2394,6 +2411,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
|
|||||||
@search-toggle="onSearchToggle"
|
@search-toggle="onSearchToggle"
|
||||||
@node-toggled="onNodeToggled"
|
@node-toggled="onNodeToggled"
|
||||||
@open-ddl="openSidebarDdl"
|
@open-ddl="openSidebarDdl"
|
||||||
|
@open-elasticsearch-index-metadata="openSidebarElasticsearchIndexMetadata"
|
||||||
@open-object-source="openSidebarObjectSource"
|
@open-object-source="openSidebarObjectSource"
|
||||||
@open-procedure="openSidebarProcedure"
|
@open-procedure="openSidebarProcedure"
|
||||||
@open-settings="openSidebarSettings"
|
@open-settings="openSidebarSettings"
|
||||||
@@ -2598,6 +2616,14 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
|
|||||||
:format-dialect="sqlFormatDialectForDbType(sidebarDdlDatabaseType)"
|
: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
|
<SidebarObjectSourceDialog
|
||||||
v-if="sidebarObjectSourceTarget && sidebarObjectSourceType"
|
v-if="sidebarObjectSourceTarget && sidebarObjectSourceType"
|
||||||
v-model:open="sidebarObjectSourceOpen"
|
v-model:open="sidebarObjectSourceOpen"
|
||||||
@@ -2684,6 +2710,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
|
|||||||
:details-text="sidebarDangerDialogRequest.detailsText"
|
:details-text="sidebarDangerDialogRequest.detailsText"
|
||||||
:confirm-label="sidebarDangerDialogRequest.confirmLabel"
|
:confirm-label="sidebarDangerDialogRequest.confirmLabel"
|
||||||
:loading="sidebarDangerDialogConfirming || sidebarDangerDialogRequest.loading"
|
:loading="sidebarDangerDialogConfirming || sidebarDangerDialogRequest.loading"
|
||||||
|
:confirm-disabled="sidebarDangerDialogRequest.confirmDisabled"
|
||||||
:close-on-confirm="false"
|
:close-on-confirm="false"
|
||||||
:cancelable="!!sidebarDangerDialogRequest.cancelRunning"
|
:cancelable="!!sidebarDangerDialogRequest.cancelRunning"
|
||||||
:cancel-running-loading="sidebarDangerDialogCancelling"
|
:cancel-running-loading="sidebarDangerDialogCancelling"
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { useSidebarTreeToolRuntime } from "@/composables/useSidebarTreeToolRunti
|
|||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { translateBackendError } from "@/i18n/backend-errors";
|
import { translateBackendError } from "@/i18n/backend-errors";
|
||||||
import {
|
import {
|
||||||
|
BarChart3,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
Braces,
|
||||||
Database,
|
Database,
|
||||||
ChevronsDown,
|
ChevronsDown,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
@@ -70,6 +72,7 @@ import { useToast } from "@/composables/useToast";
|
|||||||
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
|
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
|
||||||
import type { ColumnInfo, DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
|
import type { ColumnInfo, DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
|
||||||
import * as api from "@/lib/backend/api";
|
import * as api from "@/lib/backend/api";
|
||||||
|
import type { ElasticsearchIndexMetadataKind } from "@/lib/backend/tauri";
|
||||||
import { queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
|
import { queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
|
||||||
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
|
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
|
||||||
import { connectionUsesVisibleSchemaFilter } from "@/lib/database/visibleDatabases";
|
import { connectionUsesVisibleSchemaFilter } from "@/lib/database/visibleDatabases";
|
||||||
@@ -82,6 +85,7 @@ import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting"
|
|||||||
import { isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers";
|
import { isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers";
|
||||||
import { isXuguSyntheticTreeNode } from "@/lib/sidebar/xuguPublicSynonyms";
|
import { isXuguSyntheticTreeNode } from "@/lib/sidebar/xuguPublicSynonyms";
|
||||||
import { buildXuguSchedulerJobSql, type XuguSchedulerJobAction } from "@/lib/database/xuguSchedulerJobSql";
|
import { buildXuguSchedulerJobSql, type XuguSchedulerJobAction } from "@/lib/database/xuguSchedulerJobSql";
|
||||||
|
import { elasticsearchClearIndexPreview, isElasticsearchClearConfirmed, isElasticsearchIndexPattern } from "@/lib/sidebar/elasticsearchIndexActions";
|
||||||
import { mysqlObjectTemplateForGroup } from "@/lib/sidebar/mysqlObjectTemplates";
|
import { mysqlObjectTemplateForGroup } from "@/lib/sidebar/mysqlObjectTemplates";
|
||||||
import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate } from "@/lib/table/tableSqlTemplates";
|
import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate } from "@/lib/table/tableSqlTemplates";
|
||||||
import { qualifiedTableName } from "@/lib/table/tableSelectSql";
|
import { qualifiedTableName } from "@/lib/table/tableSelectSql";
|
||||||
@@ -296,6 +300,9 @@ import {
|
|||||||
mongoIndexManagerSelectedName,
|
mongoIndexManagerSelectedName,
|
||||||
mongoIndexManagerMode,
|
mongoIndexManagerMode,
|
||||||
mongoEditIndexOriginalName,
|
mongoEditIndexOriginalName,
|
||||||
|
showClearElasticsearchIndexConfirm,
|
||||||
|
clearElasticsearchIndexLoading,
|
||||||
|
clearElasticsearchIndexTypedName,
|
||||||
showFlushRedisDbConfirm,
|
showFlushRedisDbConfirm,
|
||||||
showCreateSchemaDialog,
|
showCreateSchemaDialog,
|
||||||
createSchemaName,
|
createSchemaName,
|
||||||
@@ -393,6 +400,7 @@ const emit = defineEmits<{
|
|||||||
"search-toggle": [node: TreeNode];
|
"search-toggle": [node: TreeNode];
|
||||||
"context-menu": [event: MouseEvent, node: TreeNode, items: ContextMenuItem[]];
|
"context-menu": [event: MouseEvent, node: TreeNode, items: ContextMenuItem[]];
|
||||||
"open-ddl": [node: TreeNode];
|
"open-ddl": [node: TreeNode];
|
||||||
|
"open-elasticsearch-index-metadata": [node: TreeNode, kind: ElasticsearchIndexMetadataKind];
|
||||||
"open-object-source": [node: TreeNode, initialEditing: boolean];
|
"open-object-source": [node: TreeNode, initialEditing: boolean];
|
||||||
"open-procedure": [node: TreeNode];
|
"open-procedure": [node: TreeNode];
|
||||||
"open-settings": [initialTab: string];
|
"open-settings": [initialTab: string];
|
||||||
@@ -527,6 +535,9 @@ const {
|
|||||||
dropMilvusCollection,
|
dropMilvusCollection,
|
||||||
dropMongoIndex,
|
dropMongoIndex,
|
||||||
dropAllMongoIndexes,
|
dropAllMongoIndexes,
|
||||||
|
canManageElasticsearchIndex,
|
||||||
|
clearElasticsearchIndex,
|
||||||
|
confirmClearElasticsearchIndex,
|
||||||
flushRedisDb,
|
flushRedisDb,
|
||||||
prepareRedisDatabaseAliasDialog,
|
prepareRedisDatabaseAliasDialog,
|
||||||
confirmRedisDatabaseAlias,
|
confirmRedisDatabaseAlias,
|
||||||
@@ -1918,6 +1929,10 @@ async function openDdl() {
|
|||||||
emit("open-ddl", targets[0]!);
|
emit("open-ddl", targets[0]!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openElasticsearchIndexMetadata(kind: ElasticsearchIndexMetadataKind) {
|
||||||
|
emit("open-elasticsearch-index-metadata", createSidebarActionTarget(activeNode.value), kind);
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const node = activeNode.value;
|
const node = activeNode.value;
|
||||||
try {
|
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, () =>
|
routeDangerDialog(showFlushRedisDbConfirm, () =>
|
||||||
dangerRequest({
|
dangerRequest({
|
||||||
title: t("redis.flushDb"),
|
title: t("redis.flushDb"),
|
||||||
@@ -5428,6 +5488,14 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
|||||||
if (canRenameMongoCollection.value) {
|
if (canRenameMongoCollection.value) {
|
||||||
items.push({ label: t("contextMenu.renameObject"), action: openRenameMongoCollectionDialog, icon: Pencil, shortcut: shortcutRename });
|
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) {
|
if (canDropMilvusCollection.value) {
|
||||||
items.push({ label: "", separator: true });
|
items.push({ label: "", separator: true });
|
||||||
items.push({ label: t("contextMenu.dropCollection"), action: dropMilvusCollection, icon: Trash2, shortcut: shortcutDelete, variant: "destructive" as const });
|
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 SidebarVisibleSchemasDialog = lazySidebarDialog(() => import("@/components/sidebar/VisibleSchemasDialog.vue"));
|
||||||
export const SidebarDdlViewDialog = lazySidebarDialog(() => import("@/components/objects/DdlViewDialog.vue"));
|
export const SidebarDdlViewDialog = lazySidebarDialog(() => import("@/components/objects/DdlViewDialog.vue"));
|
||||||
export const SidebarObjectSourceDialog = lazySidebarDialog(() => import("@/components/objects/ObjectSourceDialog.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 SidebarProcedureExecutionDialog = lazySidebarDialog(() => import("@/components/objects/ProcedureExecutionDialog.vue"));
|
||||||
export const SidebarMongoIndexManagerDialog = lazySidebarDialog(() => import("@/components/sidebar/MongoIndexManagerDialog.vue"));
|
export const SidebarMongoIndexManagerDialog = lazySidebarDialog(() => import("@/components/sidebar/MongoIndexManagerDialog.vue"));
|
||||||
@@ -163,6 +163,10 @@ export function resetMongoIndexManager() {
|
|||||||
mongoIndexManagerMode.value = "view";
|
mongoIndexManagerMode.value = "view";
|
||||||
mongoEditIndexOriginalName.value = "";
|
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 showFlushRedisDbConfirm = ref(false);
|
||||||
export const showRedisDatabaseAliasDialog = ref(false);
|
export const showRedisDatabaseAliasDialog = ref(false);
|
||||||
export const redisDatabaseAliasInput = ref("");
|
export const redisDatabaseAliasInput = ref("");
|
||||||
@@ -213,6 +217,7 @@ const openFlags = [
|
|||||||
showDropAllMongoIndexesConfirm,
|
showDropAllMongoIndexesConfirm,
|
||||||
showCreateMongoIndexDialog,
|
showCreateMongoIndexDialog,
|
||||||
showMongoIndexManagerDialog,
|
showMongoIndexManagerDialog,
|
||||||
|
showClearElasticsearchIndexConfirm,
|
||||||
showFlushRedisDbConfirm,
|
showFlushRedisDbConfirm,
|
||||||
showRedisDatabaseAliasDialog,
|
showRedisDatabaseAliasDialog,
|
||||||
showCreateSchemaDialog,
|
showCreateSchemaDialog,
|
||||||
@@ -232,6 +237,8 @@ export function resetSidebarTreeDialogState() {
|
|||||||
createDatabasePreviewSql.value = "";
|
createDatabasePreviewSql.value = "";
|
||||||
createDatabaseAuthorizationResults.value = [];
|
createDatabaseAuthorizationResults.value = [];
|
||||||
createDatabaseAuthorizationApplying.value = false;
|
createDatabaseAuthorizationApplying.value = false;
|
||||||
|
clearElasticsearchIndexLoading.value = false;
|
||||||
|
clearElasticsearchIndexTypedName.value = "";
|
||||||
redisDatabaseAliasInput.value = "";
|
redisDatabaseAliasInput.value = "";
|
||||||
redisDatabaseAliasSaving.value = false;
|
redisDatabaseAliasSaving.value = false;
|
||||||
cloneMongoCollectionName.value = "";
|
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 MongoCreateIndexRequest,
|
||||||
type MongoIndexSpecSnapshot,
|
type MongoIndexSpecSnapshot,
|
||||||
} from "@/lib/sidebar/mongoCollectionMutation";
|
} from "@/lib/sidebar/mongoCollectionMutation";
|
||||||
|
import { elasticsearchClearIndexPreview, isElasticsearchProtocolIndex, isPartialElasticsearchClear, notifyElasticsearchIndexCleared } from "@/lib/sidebar/elasticsearchIndexActions";
|
||||||
import { supportsMongoAllDriverMutations, supportsMongoIndexMutations, supportsNativeMongoDriverMutations } from "@/lib/mongo/mongoCapabilities";
|
import { supportsMongoAllDriverMutations, supportsMongoIndexMutations, supportsNativeMongoDriverMutations } from "@/lib/mongo/mongoCapabilities";
|
||||||
import { runMongoSidebarMutation } from "@/lib/sidebar/runMongoSidebarMutation";
|
import { runMongoSidebarMutation } from "@/lib/sidebar/runMongoSidebarMutation";
|
||||||
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
|
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
|
||||||
@@ -60,6 +61,8 @@ import {
|
|||||||
dropAllMongoIndexesLoading,
|
dropAllMongoIndexesLoading,
|
||||||
showDropDatabaseConfirm,
|
showDropDatabaseConfirm,
|
||||||
dropDatabaseLoading,
|
dropDatabaseLoading,
|
||||||
|
showClearElasticsearchIndexConfirm,
|
||||||
|
clearElasticsearchIndexLoading,
|
||||||
showFlushRedisDbConfirm,
|
showFlushRedisDbConfirm,
|
||||||
showRedisDatabaseAliasDialog,
|
showRedisDatabaseAliasDialog,
|
||||||
redisDatabaseAliasInput,
|
redisDatabaseAliasInput,
|
||||||
@@ -148,6 +151,13 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
|
|||||||
return canMutateMongoCollectionNode(node) && isCloneableMongoCollection(node.label, mongoCollectionKindFromNode(node));
|
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 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 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));
|
const canRenameMongoCollection = computed(() => canRenameMongoCollectionNode(activeNode.value));
|
||||||
@@ -898,6 +908,49 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
|
|||||||
showFlushRedisDbConfirm.value = true;
|
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() {
|
function prepareRedisDatabaseAliasDialog() {
|
||||||
const node = activeNode.value;
|
const node = activeNode.value;
|
||||||
redisDatabaseAliasInput.value = node.connectionId && node.database != null ? connectionStore.getRedisDatabaseAlias(node.connectionId, node.database) || "" : "";
|
redisDatabaseAliasInput.value = node.connectionId && node.database != null ? connectionStore.getRedisDatabaseAlias(node.connectionId, node.database) || "" : "";
|
||||||
@@ -1190,6 +1243,9 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
|
|||||||
dropMongoCollection,
|
dropMongoCollection,
|
||||||
dropMongoIndex,
|
dropMongoIndex,
|
||||||
dropAllMongoIndexes,
|
dropAllMongoIndexes,
|
||||||
|
canManageElasticsearchIndex,
|
||||||
|
clearElasticsearchIndex,
|
||||||
|
confirmClearElasticsearchIndex,
|
||||||
flushRedisDb,
|
flushRedisDb,
|
||||||
prepareRedisDatabaseAliasDialog,
|
prepareRedisDatabaseAliasDialog,
|
||||||
confirmRedisDatabaseAlias,
|
confirmRedisDatabaseAlias,
|
||||||
|
|||||||
@@ -3268,6 +3268,17 @@ export default {
|
|||||||
attachSqliteDatabaseDesktopOnly: "Attaching SQLite database files is only available in the desktop app",
|
attachSqliteDatabaseDesktopOnly: "Attaching SQLite database files is only available in the desktop app",
|
||||||
dropDatabaseSuccess: 'Database "{name}" dropped',
|
dropDatabaseSuccess: 'Database "{name}" dropped',
|
||||||
dropCollection: "Drop Collection",
|
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",
|
cloneCollection: "Clone Collection",
|
||||||
cloneCollectionTitle: "Clone as New Collection",
|
cloneCollectionTitle: "Clone as New Collection",
|
||||||
cloneCollectionNamePlaceholder: "New collection name",
|
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",
|
attachSqliteDatabaseDesktopOnly: "Adjuntar archivos de base de datos SQLite solo está disponible en la app de escritorio",
|
||||||
dropDatabaseSuccess: 'Base de datos "{name}" eliminada',
|
dropDatabaseSuccess: 'Base de datos "{name}" eliminada',
|
||||||
dropCollection: "Eliminar colección",
|
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",
|
cloneCollection: "Clonar colección",
|
||||||
cloneCollectionTitle: "Clonar como nueva colección",
|
cloneCollectionTitle: "Clonar como nueva colección",
|
||||||
cloneCollectionNamePlaceholder: "Nombre de la 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",
|
attachSqliteDatabaseDesktopOnly: "Il collegamento di file di database SQLite è disponibile solo nell'applicazione desktop",
|
||||||
dropDatabaseSuccess: 'Database "{name}" eliminato',
|
dropDatabaseSuccess: 'Database "{name}" eliminato',
|
||||||
dropCollection: "Elimina Collection",
|
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",
|
cloneCollection: "Clona raccolta",
|
||||||
cloneCollectionTitle: "Clona come nuova raccolta",
|
cloneCollectionTitle: "Clona come nuova raccolta",
|
||||||
cloneCollectionNamePlaceholder: "Nome della nuova raccolta",
|
cloneCollectionNamePlaceholder: "Nome della nuova raccolta",
|
||||||
|
|||||||
@@ -3162,6 +3162,17 @@ export default withEnglishFallback({
|
|||||||
attachSqliteDatabaseDesktopOnly: "SQLiteデータベースファイルのアタッチはデスクトップアプリでのみ利用可能です",
|
attachSqliteDatabaseDesktopOnly: "SQLiteデータベースファイルのアタッチはデスクトップアプリでのみ利用可能です",
|
||||||
dropDatabaseSuccess: "データベース「{name}」を削除しました",
|
dropDatabaseSuccess: "データベース「{name}」を削除しました",
|
||||||
dropCollection: "コレクションを削除",
|
dropCollection: "コレクションを削除",
|
||||||
|
elasticsearchViewMapping: "マッピングを表示",
|
||||||
|
elasticsearchViewSettings: "インデックス設定を表示",
|
||||||
|
elasticsearchViewStats: "インデックス統計を表示",
|
||||||
|
elasticsearchClearIndex: "インデックスデータをクリア",
|
||||||
|
elasticsearchClearIndexMessage: "インデックス {index} の全ドキュメントを削除します。マッピング・設定・エイリアスは保持されます。続行しますか?",
|
||||||
|
elasticsearchClearIndexPatternMessage: "{index} は集約されたワイルドカードパターンであり、単一のインデックスではありません。一致するすべてのインデックスの全ドキュメントを削除します。マッピング・設定・エイリアスは保持されます。続行しますか?",
|
||||||
|
elasticsearchClearIndexTypeToConfirm: "確認のため {index} を入力してください",
|
||||||
|
elasticsearchClearIndexDetails: "_delete_by_query でドキュメントを削除します。インデックス自体は削除されません。",
|
||||||
|
elasticsearchClearIndexConfirm: "データをクリア",
|
||||||
|
elasticsearchClearIndexDone: "{index} をクリアしました: {deleted} 件のドキュメントを削除",
|
||||||
|
elasticsearchClearIndexPartial: "{index} は完全にはクリアされていません: {total} 件中 {deleted} 件を削除。競合やシャード障害を確認して再実行してください。",
|
||||||
cloneCollection: "コレクションを複製",
|
cloneCollection: "コレクションを複製",
|
||||||
cloneCollectionTitle: "新しいコレクションとして複製",
|
cloneCollectionTitle: "新しいコレクションとして複製",
|
||||||
cloneCollectionNamePlaceholder: "新しいコレクション名",
|
cloneCollectionNamePlaceholder: "新しいコレクション名",
|
||||||
|
|||||||
@@ -3033,6 +3033,17 @@ export default withEnglishFallback({
|
|||||||
attachSqliteDatabaseDesktopOnly: "SQLite 데이터베이스 파일 연결은 데스크톱 앱에서만 사용할 수 있습니다",
|
attachSqliteDatabaseDesktopOnly: "SQLite 데이터베이스 파일 연결은 데스크톱 앱에서만 사용할 수 있습니다",
|
||||||
dropDatabaseSuccess: '데이터베이스 "{name}" 삭제됨',
|
dropDatabaseSuccess: '데이터베이스 "{name}" 삭제됨',
|
||||||
dropCollection: "컬렉션 삭제",
|
dropCollection: "컬렉션 삭제",
|
||||||
|
elasticsearchViewMapping: "매핑 보기",
|
||||||
|
elasticsearchViewSettings: "인덱스 설정 보기",
|
||||||
|
elasticsearchViewStats: "인덱스 통계 보기",
|
||||||
|
elasticsearchClearIndex: "인덱스 데이터 비우기",
|
||||||
|
elasticsearchClearIndexMessage: "인덱스 {index}의 모든 문서를 삭제합니다. 매핑, 설정, 별칭은 유지됩니다. 계속할까요?",
|
||||||
|
elasticsearchClearIndexPatternMessage: "{index}는 단일 인덱스가 아니라 그룹화된 와일드카드 패턴입니다. 일치하는 모든 인덱스의 문서가 삭제됩니다. 매핑, 설정, 별칭은 유지됩니다. 계속할까요?",
|
||||||
|
elasticsearchClearIndexTypeToConfirm: "확인하려면 {index}를 입력하세요",
|
||||||
|
elasticsearchClearIndexDetails: "_delete_by_query로 문서를 삭제하며 인덱스 자체는 삭제되지 않습니다.",
|
||||||
|
elasticsearchClearIndexConfirm: "데이터 비우기",
|
||||||
|
elasticsearchClearIndexDone: "{index} 비움: 문서 {deleted}건 삭제",
|
||||||
|
elasticsearchClearIndexPartial: "{index}가 완전히 비워지지 않았습니다: {total}건 중 {deleted}건 삭제. 충돌 또는 샤드 실패를 확인한 뒤 다시 실행하세요.",
|
||||||
cloneCollection: "컬렉션 복제",
|
cloneCollection: "컬렉션 복제",
|
||||||
cloneCollectionTitle: "새 컬렉션으로 복제",
|
cloneCollectionTitle: "새 컬렉션으로 복제",
|
||||||
cloneCollectionNamePlaceholder: "새 컬렉션 이름",
|
cloneCollectionNamePlaceholder: "새 컬렉션 이름",
|
||||||
|
|||||||
@@ -3138,6 +3138,17 @@ export default withEnglishFallback({
|
|||||||
attachSqliteDatabaseDesktopOnly: "Anexar arquivos de banco de dados SQLite só está disponível no aplicativo desktop",
|
attachSqliteDatabaseDesktopOnly: "Anexar arquivos de banco de dados SQLite só está disponível no aplicativo desktop",
|
||||||
dropDatabaseSuccess: 'Banco de dados "{name}" removido',
|
dropDatabaseSuccess: 'Banco de dados "{name}" removido',
|
||||||
dropCollection: "Remover coleção",
|
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",
|
cloneCollection: "Clonar coleção",
|
||||||
cloneCollectionTitle: "Clonar como nova coleção",
|
cloneCollectionTitle: "Clonar como nova coleção",
|
||||||
cloneCollectionNamePlaceholder: "Nome da nova coleção",
|
cloneCollectionNamePlaceholder: "Nome da nova coleção",
|
||||||
|
|||||||
@@ -3189,6 +3189,17 @@ export default withEnglishFallback({
|
|||||||
attachSqliteDatabaseDesktopOnly: "附加 SQLite 数据库文件仅支持桌面端",
|
attachSqliteDatabaseDesktopOnly: "附加 SQLite 数据库文件仅支持桌面端",
|
||||||
dropDatabaseSuccess: "数据库「{name}」已删除",
|
dropDatabaseSuccess: "数据库「{name}」已删除",
|
||||||
dropCollection: "删除集合",
|
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: "克隆集合",
|
cloneCollection: "克隆集合",
|
||||||
cloneCollectionTitle: "克隆为新集合",
|
cloneCollectionTitle: "克隆为新集合",
|
||||||
cloneCollectionNamePlaceholder: "新集合名",
|
cloneCollectionNamePlaceholder: "新集合名",
|
||||||
|
|||||||
@@ -3139,6 +3139,17 @@ export default withEnglishFallback({
|
|||||||
attachSqliteDatabaseDesktopOnly: "附加 SQLite 資料庫檔案僅支援桌面端",
|
attachSqliteDatabaseDesktopOnly: "附加 SQLite 資料庫檔案僅支援桌面端",
|
||||||
dropDatabaseSuccess: "資料庫「{name}」已刪除",
|
dropDatabaseSuccess: "資料庫「{name}」已刪除",
|
||||||
dropCollection: "刪除集合",
|
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: "複製集合",
|
cloneCollection: "複製集合",
|
||||||
cloneCollectionTitle: "複製為新集合",
|
cloneCollectionTitle: "複製為新集合",
|
||||||
cloneCollectionNamePlaceholder: "新集合名稱",
|
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
|
// Elasticsearch
|
||||||
export const elasticsearchListIndices = forward("elasticsearchListIndices");
|
export const elasticsearchListIndices = forward("elasticsearchListIndices");
|
||||||
|
export const elasticsearchGetIndexMetadata = forward("elasticsearchGetIndexMetadata");
|
||||||
|
export const elasticsearchDeleteAllDocuments = forward("elasticsearchDeleteAllDocuments");
|
||||||
export const vectorListCollections = forward("vectorListCollections");
|
export const vectorListCollections = forward("vectorListCollections");
|
||||||
|
|
||||||
// History
|
// History
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ import type {
|
|||||||
PromptTemplate,
|
PromptTemplate,
|
||||||
SshPromptResolution,
|
SshPromptResolution,
|
||||||
MeilisearchIndexOverview,
|
MeilisearchIndexOverview,
|
||||||
|
ElasticsearchIndexMetadataKind,
|
||||||
|
ElasticsearchDeleteByQueryResult,
|
||||||
} from "@/lib/backend/tauri";
|
} from "@/lib/backend/tauri";
|
||||||
import type { QueryEditability } from "@/lib/sql/sqlAnalysis";
|
import type { QueryEditability } from "@/lib/sql/sqlAnalysis";
|
||||||
import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
|
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> {
|
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", {
|
return post("/api/mongo/count-documents", {
|
||||||
connectionId,
|
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> {
|
export async function mongoCountDocuments(connectionId: string, database: string, collection: string, filter?: string, mode?: "accurate" | "legacy", executionId?: string): Promise<number> {
|
||||||
return invoke("mongo_count_documents", {
|
return invoke("mongo_count_documents", {
|
||||||
connectionId,
|
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;
|
details?: string;
|
||||||
detailsText?: string;
|
detailsText?: string;
|
||||||
loading?: boolean;
|
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;
|
closeOnConfirm?: boolean;
|
||||||
progress?: SidebarDangerDialogProgress;
|
progress?: SidebarDangerDialogProgress;
|
||||||
option?: SidebarDangerDialogOption;
|
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)
|
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(
|
pub async fn insert_document(
|
||||||
client: &EasysearchClient,
|
client: &EasysearchClient,
|
||||||
index: &str,
|
index: &str,
|
||||||
|
|||||||
@@ -1101,6 +1101,91 @@ pub async fn count_documents(client: &EsClient, index: &str, filter: Option<&str
|
|||||||
Ok(result.count)
|
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> {
|
fn search_response_to_document_result(result: SearchResponse) -> Result<DocumentQueryResult, String> {
|
||||||
// A 200 search response can still omit failed shards. Only expose an
|
// A 200 search response can still omit failed shards. Only expose an
|
||||||
// exact total when both the total relation and shard metadata agree.
|
// 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));
|
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]
|
#[tokio::test]
|
||||||
async fn execute_rest_head_request_returns_http_status() {
|
async fn execute_rest_head_request_returns_http_status() {
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ pub struct MongoGridFsBucketInfo {
|
|||||||
pub total_bytes: i64,
|
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 {
|
fn cmp_names(left: &str, right: &str) -> std::cmp::Ordering {
|
||||||
let left_lower = left.to_lowercase();
|
let left_lower = left.to_lowercase();
|
||||||
let right_lower = right.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 {
|
fn is_unknown_agent_method_error(error: &str, method: &str) -> bool {
|
||||||
let lower = error.to_ascii_lowercase();
|
let lower = error.to_ascii_lowercase();
|
||||||
lower.contains(method) && (lower.contains("unknown method") || lower.contains("method not found"))
|
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"));
|
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",
|
"/document-store/elasticsearch-count-documents",
|
||||||
post(routes::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/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/create-gridfs-bucket", post(routes::document_store::create_gridfs_bucket))
|
||||||
.route("/document-store/delete-gridfs-bucket", post(routes::document_store::delete_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>,
|
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)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct DocumentInsertRequest {
|
pub struct DocumentInsertRequest {
|
||||||
@@ -385,6 +400,33 @@ pub async fn elasticsearch_count_documents(
|
|||||||
Ok(Json(result))
|
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(
|
pub async fn insert_document(
|
||||||
State(state): State<Arc<WebState>>,
|
State(state): State<Arc<WebState>>,
|
||||||
Json(req): Json<DocumentInsertRequest>,
|
Json(req): Json<DocumentInsertRequest>,
|
||||||
|
|||||||
@@ -132,6 +132,26 @@ pub async fn elasticsearch_count_documents(
|
|||||||
.await
|
.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]
|
#[tauri::command]
|
||||||
pub async fn document_insert_document(
|
pub async fn document_insert_document(
|
||||||
state: State<'_, Arc<AppState>>,
|
state: State<'_, Arc<AppState>>,
|
||||||
|
|||||||
@@ -2198,6 +2198,8 @@ pub fn run() {
|
|||||||
commands::document_cmd::document_count_documents,
|
commands::document_cmd::document_count_documents,
|
||||||
commands::document_cmd::dynamodb_describe_table,
|
commands::document_cmd::dynamodb_describe_table,
|
||||||
commands::document_cmd::elasticsearch_count_documents,
|
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_list_gridfs_buckets,
|
||||||
commands::document_cmd::document_create_gridfs_bucket,
|
commands::document_cmd::document_create_gridfs_bucket,
|
||||||
commands::document_cmd::document_delete_gridfs_bucket,
|
commands::document_cmd::document_delete_gridfs_bucket,
|
||||||
|
|||||||
Reference in new issue
Block a user