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

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

No files matched your search

@@ -88,6 +88,7 @@ import type { GridNewRowMeta } from "@/lib/dataGrid/gridNewRowPlacement";
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
import { documentDataGridColumnLayoutScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
import { documentGridColumnVisibilityScopeKey, migrateDocumentGridColumnVisibilityToLayout } from "@/lib/document/documentGridColumnVisibilityStorage";
import { matchesElasticsearchIndexPattern, subscribeElasticsearchIndexCleared, type ElasticsearchIndexClearedDetail } from "@/lib/sidebar/elasticsearchIndexActions";
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import JsonEditNode from "./JsonEditNode.vue";
@@ -2062,8 +2063,24 @@ async function loadDynamoDbTableDescription() {
}
}
/**
* The sidebar's "clear index data" action deletes documents behind this tab's
* back, so an open browser would keep listing rows that no longer exist.
* Reload when the cleared index is the one on screen.
*/
function handleElasticsearchIndexCleared(detail: ElasticsearchIndexClearedDetail) {
if (detail.connectionId !== props.connectionId) return;
// Clearing a grouped node deletes from every index its pattern matches, so a
// tab open on any concrete index under the pattern must refresh as well.
if (detail.index !== props.collection && !matchesElasticsearchIndexPattern(detail.index, props.collection)) return;
void refreshDocuments();
}
let unsubscribeElasticsearchIndexCleared: (() => void) | undefined;
onMounted(async () => {
window.addEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
unsubscribeElasticsearchIndexCleared = subscribeElasticsearchIndexCleared(handleElasticsearchIndexCleared);
try {
await connectionStore.ensureConnected(props.connectionId);
} catch (e) {
@@ -2078,6 +2095,8 @@ onMounted(async () => {
});
onBeforeUnmount(() => {
window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
unsubscribeElasticsearchIndexCleared?.();
unsubscribeElasticsearchIndexCleared = undefined;
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
documentRequestGeneration++;
loadedDocumentQueryTotalCountRequest = undefined;
@@ -35,6 +35,8 @@ const props = withDefaults(
closeOnConfirm?: boolean;
cancelable?: boolean;
cancelRunningLoading?: boolean;
/** Holds the confirm button back until the caller's own precondition is met (e.g. the operator typed the target name). */
confirmDisabled?: boolean;
}>(),
{
sql: "",
@@ -49,6 +51,7 @@ const props = withDefaults(
closeOnConfirm: true,
cancelable: false,
cancelRunningLoading: false,
confirmDisabled: false,
},
);
@@ -71,7 +74,9 @@ const dialogOpen = computed({
});
function onConfirm() {
if (props.loading) return;
// Guard here as well as on the button: a disabled button still fires on some
// synthetic/keyboard paths, and this one gates a destructive operation.
if (props.loading || props.confirmDisabled) return;
if (props.closeOnConfirm) open.value = false;
emit("confirm");
}
@@ -129,7 +134,7 @@ async function copyFullCode() {
{{ t("dangerDialog.cancelRunning") }}
</Button>
<Button v-else variant="outline" :disabled="loading" @click="open = false">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="destructive" class="gap-1.5" :disabled="loading" @click="onConfirm">
<Button variant="destructive" class="gap-1.5" :disabled="loading || confirmDisabled" @click="onConfirm">
<Loader2 v-if="loading" class="h-3.5 w-3.5 animate-spin" />
{{ confirmLabel || t("dangerDialog.confirm") }}
</Button>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Clipboard, Loader2, RefreshCw } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
import { useToast } from "@/composables/useToast";
import { copyToClipboard } from "@/lib/common/clipboard";
import * as api from "@/lib/backend/api";
import { translateBackendError } from "@/i18n/backend-errors";
import type { ElasticsearchIndexMetadataKind } from "@/lib/backend/tauri";
const props = defineProps<{
open: boolean;
connectionId: string;
index: string;
kind: ElasticsearchIndexMetadataKind;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
}>();
const { t } = useI18n();
const { toast } = useToast();
const content = ref("");
const loading = ref(false);
const error = ref("");
const titleKey: Record<ElasticsearchIndexMetadataKind, string> = {
mapping: "contextMenu.elasticsearchViewMapping",
settings: "contextMenu.elasticsearchViewSettings",
stats: "contextMenu.elasticsearchViewStats",
};
const dialogTitle = computed(() => `${t(titleKey[props.kind])} - ${props.index}`);
async function load() {
error.value = "";
loading.value = true;
try {
const metadata = await api.elasticsearchGetIndexMetadata(props.connectionId, props.index, props.kind);
content.value = JSON.stringify(metadata, null, 2);
} catch (e: any) {
content.value = "";
error.value = translateBackendError(t, e);
} finally {
loading.value = false;
}
}
// Reload on open, and when the menu reopens the dialog for a different endpoint
// of the same index (mapping → settings) without an intervening close.
watch(
() => [props.open, props.connectionId, props.index, props.kind] as const,
([open]) => {
if (!open) return;
content.value = "";
void load();
},
{ immediate: true },
);
async function copyContent() {
if (!content.value) return;
try {
await copyToClipboard(content.value);
toast(t("connection.copied"), 2000);
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
function onClose() {
emit("update:open", false);
}
</script>
<template>
<Dialog :open="props.open" @update:open="onClose">
<DialogContent class="sm:max-w-190">
<DialogHeader>
<!-- Index names can be long enough to truncate; the title attribute keeps the full name reachable. -->
<DialogTitle class="truncate" :title="dialogTitle">{{ dialogTitle }}</DialogTitle>
</DialogHeader>
<!--
The frame keeps a definite height on purpose. The editor chain below it
is sized in percentages, and a percentage height against a min-height-only
parent collapses to auto — CodeMirror would then grow past the frame and
a long mapping would be clipped with no scrollbar.
-->
<div class="flex h-[60vh] min-h-80 flex-col overflow-hidden rounded border">
<div v-if="loading" class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
<span>{{ t("common.loading") }}</span>
</div>
<div v-else-if="error" class="flex flex-1 flex-col items-center justify-center gap-3 p-4 text-sm">
<p class="text-center break-all text-destructive">{{ error }}</p>
<Button variant="outline" size="sm" @click="load">
<RefreshCw />
{{ t("common.retry") }}
</Button>
</div>
<RedisJsonEditor v-else :model-value="content" read-only word-wrap :line-numbers="false" presentation="viewer" class="min-h-0 flex-1" />
</div>
<DialogFooter>
<Button variant="outline" @click="onClose">{{ t("common.close") }}</Button>
<Button variant="outline" :disabled="loading" @click="load">
<RefreshCw class="h-4 w-4" />
{{ t("grid.refresh") }}
</Button>
<Button variant="outline" :disabled="!content" @click="copyContent">
<Clipboard class="h-4 w-4" />
{{ t("common.copy") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -8,6 +8,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import type { ObjectSourceKind, QueryTab, TableInfo, TableNameFilter, TreeNode, TreeNodeType } from "@/types/database";
import type { ElasticsearchIndexMetadataKind } from "@/lib/backend/tauri";
import {
createSidebarSearchSubtreePreserver,
filterSidebarSearchRootsByConnectionState,
@@ -79,7 +80,7 @@ import { createSidebarActionTarget, findSidebarActionTarget, matchesSidebarActio
import { syncSidebarTreeNodeExpansion } from "@/lib/sidebar/sidebarTreeExpansion";
import type { SidebarDangerDialogOption, SidebarDangerDialogRequest } from "@/lib/sidebar/sidebarDangerDialog";
import { resetSidebarTreeDialogState, sidebarDangerRunningExecutionId } from "./sidebarTreeDialogState";
import { SidebarDangerConfirmDialog, SidebarDdlViewDialog, SidebarObjectSourceDialog, SidebarProcedureExecutionDialog, SidebarVisibleDatabasesDialog, SidebarVisibleNacosNamespacesDialog, SidebarVisibleSchemasDialog } from "./sidebarAsyncDialogs";
import { SidebarDangerConfirmDialog, SidebarDdlViewDialog, SidebarElasticsearchIndexMetadataDialog, SidebarObjectSourceDialog, SidebarProcedureExecutionDialog, SidebarVisibleDatabasesDialog, SidebarVisibleNacosNamespacesDialog, SidebarVisibleSchemasDialog } from "./sidebarAsyncDialogs";
import { sortConnectionListForDisplay } from "@/lib/sidebar/connectionListSort";
import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay";
import { alignedSidebarCommentLabelWidths, isSidebarCommentAlignableNode, sidebarTreeNaturalContentWidth, sidebarTreeNodeComment, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
@@ -133,6 +134,8 @@ const sidebarTreeRuntime = createSidebarTreeRuntime();
const sidebarTreeRuntimeInitialNode: TreeNode = { id: "__sidebar-runtime__", label: "", type: "connection-group" };
const sidebarDdlTarget = ref<TreeNode | null>(null);
const sidebarDdlOpen = ref(false);
const sidebarElasticsearchIndexMetadataTarget = ref<{ node: TreeNode; kind: ElasticsearchIndexMetadataKind } | null>(null);
const sidebarElasticsearchIndexMetadataOpen = ref(false);
const sidebarObjectSourceTarget = ref<{ node: TreeNode; initialEditing: boolean } | null>(null);
const sidebarObjectSourceOpen = ref(false);
const sidebarProcedureTarget = ref<TreeNode | null>(null);
@@ -1921,6 +1924,7 @@ async function openSidebarExtensionDetails(node: TreeNode) {
function beginSidebarAction(): number {
sidebarActionGeneration += 1;
sidebarDdlOpen.value = false;
sidebarElasticsearchIndexMetadataOpen.value = false;
sidebarObjectSourceOpen.value = false;
sidebarProcedureOpen.value = false;
sidebarVisibleDatabasesOpen.value = false;
@@ -1928,6 +1932,7 @@ function beginSidebarAction(): number {
sidebarVisibleNacosNamespacesOpen.value = false;
sidebarTableNameFilterOpen.value = false;
sidebarDdlTarget.value = null;
sidebarElasticsearchIndexMetadataTarget.value = null;
sidebarObjectSourceTarget.value = null;
sidebarProcedureTarget.value = null;
sidebarVisibleDatabasesTarget.value = null;
@@ -1958,6 +1963,13 @@ function openSidebarDdlForSelection(): boolean {
return true;
}
function openSidebarElasticsearchIndexMetadata(node: TreeNode, kind: ElasticsearchIndexMetadataKind) {
if (!node.connectionId) return;
beginSidebarAction();
sidebarElasticsearchIndexMetadataTarget.value = { node: createSidebarActionTarget(node), kind };
sidebarElasticsearchIndexMetadataOpen.value = true;
}
function openSidebarObjectSource(node: TreeNode, initialEditing: boolean) {
if (!node.connectionId || !node.database || !objectSourceTargetForTreeNode(node)) return;
// TYPE/TYPE_BODY only have a source implementation on Xugu; PostgreSQL-family
@@ -2114,6 +2126,10 @@ watch(sidebarDdlOpen, (open) => {
if (!open) sidebarDdlTarget.value = null;
});
watch(sidebarElasticsearchIndexMetadataOpen, (open) => {
if (!open) sidebarElasticsearchIndexMetadataTarget.value = null;
});
watch(sidebarObjectSourceOpen, (open) => {
if (!open) sidebarObjectSourceTarget.value = null;
});
@@ -2356,6 +2372,7 @@ onUnmounted(() => {
sidebarContextMenuTarget.value = null;
sidebarContextMenuItems.value = [];
sidebarDdlTarget.value = null;
sidebarElasticsearchIndexMetadataTarget.value = null;
sidebarObjectSourceTarget.value = null;
sidebarProcedureTarget.value = null;
sidebarVisibleDatabasesTarget.value = null;
@@ -2394,6 +2411,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
@search-toggle="onSearchToggle"
@node-toggled="onNodeToggled"
@open-ddl="openSidebarDdl"
@open-elasticsearch-index-metadata="openSidebarElasticsearchIndexMetadata"
@open-object-source="openSidebarObjectSource"
@open-procedure="openSidebarProcedure"
@open-settings="openSidebarSettings"
@@ -2598,6 +2616,14 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
:format-dialect="sqlFormatDialectForDbType(sidebarDdlDatabaseType)"
/>
<SidebarElasticsearchIndexMetadataDialog
v-if="sidebarElasticsearchIndexMetadataTarget"
v-model:open="sidebarElasticsearchIndexMetadataOpen"
:connection-id="sidebarElasticsearchIndexMetadataTarget.node.connectionId!"
:index="sidebarElasticsearchIndexMetadataTarget.node.label"
:kind="sidebarElasticsearchIndexMetadataTarget.kind"
/>
<SidebarObjectSourceDialog
v-if="sidebarObjectSourceTarget && sidebarObjectSourceType"
v-model:open="sidebarObjectSourceOpen"
@@ -2684,6 +2710,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
:details-text="sidebarDangerDialogRequest.detailsText"
:confirm-label="sidebarDangerDialogRequest.confirmLabel"
:loading="sidebarDangerDialogConfirming || sidebarDangerDialogRequest.loading"
:confirm-disabled="sidebarDangerDialogRequest.confirmDisabled"
:close-on-confirm="false"
:cancelable="!!sidebarDangerDialogRequest.cancelRunning"
:cancel-running-loading="sidebarDangerDialogCancelling"
@@ -11,7 +11,9 @@ import { useSidebarTreeToolRuntime } from "@/composables/useSidebarTreeToolRunti
import { useI18n } from "vue-i18n";
import { translateBackendError } from "@/i18n/backend-errors";
import {
BarChart3,
BookOpen,
Braces,
Database,
ChevronsDown,
FolderOpen,
@@ -70,6 +72,7 @@ import { useToast } from "@/composables/useToast";
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
import type { ColumnInfo, DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
import * as api from "@/lib/backend/api";
import type { ElasticsearchIndexMetadataKind } from "@/lib/backend/tauri";
import { queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
import { connectionUsesVisibleSchemaFilter } from "@/lib/database/visibleDatabases";
@@ -82,6 +85,7 @@ import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting"
import { isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers";
import { isXuguSyntheticTreeNode } from "@/lib/sidebar/xuguPublicSynonyms";
import { buildXuguSchedulerJobSql, type XuguSchedulerJobAction } from "@/lib/database/xuguSchedulerJobSql";
import { elasticsearchClearIndexPreview, isElasticsearchClearConfirmed, isElasticsearchIndexPattern } from "@/lib/sidebar/elasticsearchIndexActions";
import { mysqlObjectTemplateForGroup } from "@/lib/sidebar/mysqlObjectTemplates";
import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate } from "@/lib/table/tableSqlTemplates";
import { qualifiedTableName } from "@/lib/table/tableSelectSql";
@@ -296,6 +300,9 @@ import {
mongoIndexManagerSelectedName,
mongoIndexManagerMode,
mongoEditIndexOriginalName,
showClearElasticsearchIndexConfirm,
clearElasticsearchIndexLoading,
clearElasticsearchIndexTypedName,
showFlushRedisDbConfirm,
showCreateSchemaDialog,
createSchemaName,
@@ -393,6 +400,7 @@ const emit = defineEmits<{
"search-toggle": [node: TreeNode];
"context-menu": [event: MouseEvent, node: TreeNode, items: ContextMenuItem[]];
"open-ddl": [node: TreeNode];
"open-elasticsearch-index-metadata": [node: TreeNode, kind: ElasticsearchIndexMetadataKind];
"open-object-source": [node: TreeNode, initialEditing: boolean];
"open-procedure": [node: TreeNode];
"open-settings": [initialTab: string];
@@ -527,6 +535,9 @@ const {
dropMilvusCollection,
dropMongoIndex,
dropAllMongoIndexes,
canManageElasticsearchIndex,
clearElasticsearchIndex,
confirmClearElasticsearchIndex,
flushRedisDb,
prepareRedisDatabaseAliasDialog,
confirmRedisDatabaseAlias,
@@ -1918,6 +1929,10 @@ async function openDdl() {
emit("open-ddl", targets[0]!);
}
function openElasticsearchIndexMetadata(kind: ElasticsearchIndexMetadataKind) {
emit("open-elasticsearch-index-metadata", createSidebarActionTarget(activeNode.value), kind);
}
async function refresh() {
const node = activeNode.value;
try {
@@ -4555,6 +4570,51 @@ routeDangerDialog(showDropAllMongoIndexesConfirm, () =>
}),
);
routeDangerDialog(showClearElasticsearchIndexConfirm, () => {
// Pin the label for the life of this dialog. `activeNode` follows the tree
// selection, which the confirmation must not, and the typed-name gate below
// has to compare against the index the operator actually opened.
const index = activeNode.value.label;
const isPattern = isElasticsearchIndexPattern(index);
clearElasticsearchIndexTypedName.value = "";
return dangerRequest({
title: t("contextMenu.elasticsearchClearIndex"),
// A grouped node is a wildcard covering many indexes, so it gets its own
// wording rather than one that reads as a single index.
message: t(isPattern ? "contextMenu.elasticsearchClearIndexPatternMessage" : "contextMenu.elasticsearchClearIndexMessage", { index }),
detailsText: t("contextMenu.elasticsearchClearIndexDetails"),
sql: elasticsearchClearIndexPreview(index),
confirmLabel: t("contextMenu.elasticsearchClearIndexConfirm"),
get loading() {
return clearElasticsearchIndexLoading.value;
},
// A wildcard node clears every index it matches, so the pattern has to be
// typed back before the button unlocks. Concrete index names skip this.
...(isPattern
? {
textInput: {
value: "",
label: t("contextMenu.elasticsearchClearIndexTypeToConfirm", { index }),
placeholder: index,
onInput(value: string) {
clearElasticsearchIndexTypedName.value = value;
},
},
}
: {}),
// Declared directly on this literal, never inside the spread above: object
// spread evaluates an accessor once and copies the resulting value, which
// would freeze the gate shut at its construction-time answer. Concrete
// index names need no gate, and `isElasticsearchClearConfirmed` already
// returns true for them, so this stays unlocked for them.
get confirmDisabled() {
return !isElasticsearchClearConfirmed(index, clearElasticsearchIndexTypedName.value);
},
closeOnConfirm: false,
confirm: confirmClearElasticsearchIndex,
});
});
routeDangerDialog(showFlushRedisDbConfirm, () =>
dangerRequest({
title: t("redis.flushDb"),
@@ -5428,6 +5488,14 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
if (canRenameMongoCollection.value) {
items.push({ label: t("contextMenu.renameObject"), action: openRenameMongoCollectionDialog, icon: Pencil, shortcut: shortcutRename });
}
if (canManageElasticsearchIndex.value) {
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.elasticsearchViewMapping"), action: () => openElasticsearchIndexMetadata("mapping"), icon: Braces });
items.push({ label: t("contextMenu.elasticsearchViewSettings"), action: () => openElasticsearchIndexMetadata("settings"), icon: Settings2 });
items.push({ label: t("contextMenu.elasticsearchViewStats"), action: () => openElasticsearchIndexMetadata("stats"), icon: BarChart3 });
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.elasticsearchClearIndex"), action: clearElasticsearchIndex, icon: Eraser, variant: "destructive" as const });
}
if (canDropMilvusCollection.value) {
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.dropCollection"), action: dropMilvusCollection, icon: Trash2, shortcut: shortcutDelete, variant: "destructive" as const });
@@ -0,0 +1,64 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const runtimeHostSource = readFileSync(new URL("../SidebarTreeRuntimeHost.vue", import.meta.url), "utf8");
const connectionTreeSource = readFileSync(new URL("../ConnectionTree.vue", import.meta.url), "utf8");
const dangerDialogSource = readFileSync(new URL("../../editor/DangerConfirmDialog.vue", import.meta.url), "utf8");
/**
* `isElasticsearchClearConfirmed` is unit-tested on its own, but a correct pure
* function is worthless if the dialog never consults it. These assertions pin
* the wiring that connects the two, end to end through the shared dialog.
*/
describe("Elasticsearch clear-index wildcard guard wiring", () => {
it("gates the confirm button on the typed pattern for wildcard nodes only", () => {
const clearRoute = runtimeHostSource.slice(runtimeHostSource.indexOf("routeDangerDialog(showClearElasticsearchIndexConfirm"));
expect(clearRoute).toContain("const isPattern = isElasticsearchIndexPattern(index)");
expect(clearRoute).toMatch(/\.\.\.\(isPattern\s*\?\s*\{/);
});
/**
* Regression: the gate once lived inside the `...(isPattern ? {…} : {})`
* spread. Object spread *invokes* an accessor and copies the resulting value
* as a plain data property, so the getter answered once at construction time
* — while the input was still empty — and the confirm button stayed disabled
* no matter what was typed. The wording read correctly and the getter was
* present in the source the whole time, so only its position catches this.
*/
it("declares confirmDisabled outside the spread, so it stays a live getter", () => {
const clearRoute = runtimeHostSource.slice(runtimeHostSource.indexOf("routeDangerDialog(showClearElasticsearchIndexConfirm"), runtimeHostSource.indexOf("routeDangerDialog(showFlushRedisDbConfirm"));
const spreadStart = clearRoute.indexOf("...(isPattern");
const spreadEnd = clearRoute.indexOf(": {}),", spreadStart);
expect(spreadStart).toBeGreaterThan(-1);
expect(spreadEnd).toBeGreaterThan(spreadStart);
// Nothing spread into the request may be an accessor.
expect(clearRoute.slice(spreadStart, spreadEnd)).not.toMatch(/\bget\s+\w+\s*\(/);
// It has to sit directly on the request literal instead.
expect(clearRoute.slice(spreadEnd)).toMatch(/get confirmDisabled\(\) \{\s*return !isElasticsearchClearConfirmed\(index, clearElasticsearchIndexTypedName\.value\);/);
});
it("pins the index label for the life of the dialog instead of following the tree selection", () => {
const clearRoute = runtimeHostSource.slice(runtimeHostSource.indexOf("routeDangerDialog(showClearElasticsearchIndexConfirm"), runtimeHostSource.indexOf("routeDangerDialog(showFlushRedisDbConfirm"));
expect(clearRoute).toContain("const index = activeNode.value.label;");
// Reading activeNode again inside the request would reintroduce the drift.
expect(clearRoute.match(/activeNode\.value/g)).toHaveLength(1);
});
it("clears any previously typed name when the dialog is opened again", () => {
const clearRoute = runtimeHostSource.slice(runtimeHostSource.indexOf("routeDangerDialog(showClearElasticsearchIndexConfirm"), runtimeHostSource.indexOf("routeDangerDialog(showFlushRedisDbConfirm"));
expect(clearRoute).toContain('clearElasticsearchIndexTypedName.value = "";');
});
it("forwards confirmDisabled from the sidebar request to the shared danger dialog", () => {
expect(connectionTreeSource).toContain(':confirm-disabled="sidebarDangerDialogRequest.confirmDisabled"');
});
it("honours confirmDisabled on both the button and the confirm handler", () => {
expect(dangerDialogSource).toContain(':disabled="loading || confirmDisabled"');
expect(dangerDialogSource).toMatch(/function onConfirm\(\) \{[\s\S]*?if \(props\.loading \|\| props\.confirmDisabled\) return;/);
});
it("leaves confirmDisabled off by default so existing danger dialogs are unaffected", () => {
expect(dangerDialogSource).toContain("confirmDisabled: false,");
});
});
@@ -22,5 +22,6 @@ export const SidebarVisibleNacosNamespacesDialog = lazySidebarDialog(() => impor
export const SidebarVisibleSchemasDialog = lazySidebarDialog(() => import("@/components/sidebar/VisibleSchemasDialog.vue"));
export const SidebarDdlViewDialog = lazySidebarDialog(() => import("@/components/objects/DdlViewDialog.vue"));
export const SidebarObjectSourceDialog = lazySidebarDialog(() => import("@/components/objects/ObjectSourceDialog.vue"));
export const SidebarElasticsearchIndexMetadataDialog = lazySidebarDialog(() => import("@/components/objects/ElasticsearchIndexMetadataDialog.vue"));
export const SidebarProcedureExecutionDialog = lazySidebarDialog(() => import("@/components/objects/ProcedureExecutionDialog.vue"));
export const SidebarMongoIndexManagerDialog = lazySidebarDialog(() => import("@/components/sidebar/MongoIndexManagerDialog.vue"));
@@ -163,6 +163,10 @@ export function resetMongoIndexManager() {
mongoIndexManagerMode.value = "view";
mongoEditIndexOriginalName.value = "";
}
export const showClearElasticsearchIndexConfirm = ref(false);
export const clearElasticsearchIndexLoading = ref(false);
/** Name typed back by the operator before a wildcard index node may be cleared. */
export const clearElasticsearchIndexTypedName = ref("");
export const showFlushRedisDbConfirm = ref(false);
export const showRedisDatabaseAliasDialog = ref(false);
export const redisDatabaseAliasInput = ref("");
@@ -213,6 +217,7 @@ const openFlags = [
showDropAllMongoIndexesConfirm,
showCreateMongoIndexDialog,
showMongoIndexManagerDialog,
showClearElasticsearchIndexConfirm,
showFlushRedisDbConfirm,
showRedisDatabaseAliasDialog,
showCreateSchemaDialog,
@@ -232,6 +237,8 @@ export function resetSidebarTreeDialogState() {
createDatabasePreviewSql.value = "";
createDatabaseAuthorizationResults.value = [];
createDatabaseAuthorizationApplying.value = false;
clearElasticsearchIndexLoading.value = false;
clearElasticsearchIndexTypedName.value = "";
redisDatabaseAliasInput.value = "";
redisDatabaseAliasSaving.value = false;
cloneMongoCollectionName.value = "";