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