feat(nacos): namespace production protection and sidebar visibility

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-08 10:55:21 +08:00
1 parent 9057be0e78
commit efd0c381ad
25 files changed
+492 -26

No files matched your search

@@ -65,7 +65,7 @@ import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } f
import { driverInstallProgressChannel, driverInstallProgressPercent, isDriverInstallProgressForOperation, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
import { requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityConfig, sqlServerUsesLegacyCompatibility, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
import { normalizeNacosEndpoint, normalizeNacosMetricsUrl } from "@/lib/nacos/nacosAdmin";
import { normalizeNacosNamespaceSelection, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
import { nacosNamespaceIdentity, normalizeNacosNamespaceSelection, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
import {
ArrowLeft,
ArrowDown,
@@ -3068,6 +3068,20 @@ const filteredProductionDatabaseNames = computed(() => {
});
const productionDatabaseSelectedCount = computed(() => productionDatabaseSelection.value.size);
const productionDatabaseCanSave = computed(() => productionDatabaseNames.value.length > 0 && productionDatabaseSelection.value.size > 0);
const usesNacosProductionNamespaces = computed(() => form.value.db_type === "nacos");
const productionDisabledDescriptionKey = computed(() => (usesNacosProductionNamespaces.value ? "production.namespaceDisabledDescription" : "production.disabledDescription"));
const productionConnectionDescriptionKey = computed(() => (usesNacosProductionNamespaces.value ? "production.namespaceConnectionDescription" : "production.connectionDescription"));
const productionScopeAllLabelKey = computed(() => (usesNacosProductionNamespaces.value ? "production.allNamespaces" : "production.allDatabases"));
const productionScopeSelectedLabelKey = computed(() => (usesNacosProductionNamespaces.value ? "production.selectedNamespaces" : "production.selectedDatabases"));
const productionScopeResourceLabelKey = computed(() => (usesNacosProductionNamespaces.value ? "production.namespaces" : "production.databases"));
const productionScopeDescriptionKey = computed(() => (usesNacosProductionNamespaces.value ? "production.namespaceDescription" : "production.databaseDescription"));
const productionScopePickerLabelKey = computed(() => (usesNacosProductionNamespaces.value ? "production.selectNamespaces" : "production.selectDatabases"));
const productionPickerTitleKey = computed(() => (usesNacosProductionNamespaces.value ? "production.namespacePickerTitle" : "production.databasePickerTitle"));
const productionPickerDescriptionKey = computed(() => (usesNacosProductionNamespaces.value ? "production.namespacePickerDescription" : "production.databasePickerDescription"));
const productionPickerSearchPlaceholderKey = computed(() => (usesNacosProductionNamespaces.value ? "production.namespaceSearchPlaceholder" : "production.databaseSearchPlaceholder"));
const productionPickerSelectionRequiredKey = computed(() => (usesNacosProductionNamespaces.value ? "production.namespaceSelectionRequired" : "production.databaseSelectionRequired"));
const productionPickerLoadFailedKey = computed(() => (usesNacosProductionNamespaces.value ? "production.namespaceLoadFailed" : "production.databaseLoadFailed"));
const productionPickerEmptyKey = computed(() => (usesNacosProductionNamespaces.value ? "production.noNamespacesAvailable" : "production.noDatabasesAvailable"));
const productionDatabaseSummary = computed(() => {
const selected = form.value.production_databases?.length || 0;
if (!selected) return t("production.noDatabasesSelected");
@@ -4242,6 +4256,15 @@ async function loadVisibleDatabaseNames(connectionId: string, config: Connection
}
function normalizeProductionDatabaseSelection(selectedNames: Iterable<string>, databaseNames: string[]): string[] {
if (form.value.db_type === "nacos") {
const available = new Map(databaseNames.map((name) => [nacosNamespaceIdentity(name), name]));
const selected = new Set<string>();
for (const name of selectedNames) {
const canonicalName = available.get(nacosNamespaceIdentity(name));
if (canonicalName !== undefined) selected.add(canonicalName);
}
return [...selected];
}
const available = new Map(databaseNames.map((name) => [name.toLowerCase(), name]));
const selected = new Set<string>();
for (const name of selectedNames) {
@@ -4258,6 +4281,9 @@ function initialProductionDatabaseSelection(databaseNames: string[]): string[] {
}
async function loadProductionDatabaseNames(connectionId: string, config: ConnectionConfig): Promise<string[]> {
if (config.db_type === "nacos") {
return normalizeNacosNamespacesForDisplay(await api.nacosListNamespaces(connectionId)).map((namespace) => namespace.namespace);
}
if (config.db_type === "redis") {
return (await api.redisListDatabases(connectionId)).map((database) => String(database.db));
}
@@ -7427,25 +7453,25 @@ function openExternalUrl(url: string) {
<Label class="text-sm font-medium">{{ t("production.enable") }}</Label>
<Switch :model-value="productionProtectionEnabled" @update:model-value="setProductionProtectionEnabled" />
</div>
<p v-if="!productionProtectionEnabled" class="text-xs leading-5 text-muted-foreground">{{ t("production.disabledDescription") }}</p>
<p v-if="!productionProtectionEnabled" class="text-xs leading-5 text-muted-foreground">{{ t(productionDisabledDescriptionKey) }}</p>
<template v-else>
<Label class="text-xs font-medium">{{ t("production.scope") }}</Label>
<Tabs v-model="productionScope" class="w-full">
<TabsList class="grid h-8 w-full grid-cols-2">
<TabsTrigger value="connection" class="text-xs">{{ t("production.allDatabases") }}</TabsTrigger>
<TabsTrigger value="databases" class="text-xs" :disabled="!canSelectProductionDatabases" :title="canSelectProductionDatabases ? undefined : t('production.singleDatabaseScopeHint')">{{ t("production.selectedDatabases") }}</TabsTrigger>
<TabsTrigger value="connection" class="text-xs">{{ t(productionScopeAllLabelKey) }}</TabsTrigger>
<TabsTrigger value="databases" class="text-xs" :disabled="!canSelectProductionDatabases" :title="canSelectProductionDatabases ? undefined : t('production.singleDatabaseScopeHint')">{{ t(productionScopeSelectedLabelKey) }}</TabsTrigger>
</TabsList>
</Tabs>
<p class="text-xs leading-5 text-muted-foreground">{{ productionScope === "connection" ? t("production.connectionDescription") : t("production.databaseDescription") }}</p>
<p class="text-xs leading-5 text-muted-foreground">{{ productionScope === "connection" ? t(productionConnectionDescriptionKey) : t(productionScopeDescriptionKey) }}</p>
<div v-if="productionScope === 'databases'" class="grid gap-1.5">
<div class="flex items-center justify-between gap-3">
<Label class="text-xs font-medium">{{ t("production.databases") }}</Label>
<Label class="text-xs font-medium">{{ t(productionScopeResourceLabelKey) }}</Label>
<span class="text-xs text-muted-foreground">{{ productionDatabaseSummary }}</span>
</div>
<Button type="button" variant="outline" size="sm" class="justify-start" :disabled="isTesting || isSaving || isLoadingProductionDatabases || !hasRequiredConnectionTarget" @click="openProductionDatabasesPicker">
<Loader2 v-if="isLoadingProductionDatabases" class="mr-1.5 h-4 w-4 animate-spin" />
<ListFilter v-else class="mr-1.5 h-4 w-4" />
{{ t("production.selectDatabases") }}
{{ t(productionScopePickerLabelKey) }}
</Button>
</div>
</template>
@@ -7958,15 +7984,15 @@ function openExternalUrl(url: string) {
<Dialog v-model:open="showProductionDatabasesDialog">
<DialogContent class="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{{ t("production.databasePickerTitle") }}</DialogTitle>
<DialogTitle>{{ t(productionPickerTitleKey) }}</DialogTitle>
<p class="text-sm text-muted-foreground">
{{ t("production.databasePickerDescription", { connection: form.name || selectedProfile().label }) }}
{{ t(productionPickerDescriptionKey, { connection: form.name || selectedProfile().label }) }}
</p>
</DialogHeader>
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
<Input v-model="productionDatabaseSearchText" :placeholder="t('production.databaseSearchPlaceholder')" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoadingProductionDatabases || !!productionDatabaseError" />
<Input v-model="productionDatabaseSearchText" :placeholder="t(productionPickerSearchPlaceholderKey)" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoadingProductionDatabases || !!productionDatabaseError" />
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
@@ -7981,7 +8007,7 @@ function openExternalUrl(url: string) {
</div>
</div>
<p v-if="!isLoadingProductionDatabases && !productionDatabaseError && !productionDatabaseCanSave" class="text-xs text-destructive">
{{ t("production.databaseSelectionRequired") }}
{{ t(productionPickerSelectionRequiredKey) }}
</p>
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
@@ -7990,14 +8016,14 @@ function openExternalUrl(url: string) {
{{ t("common.loading") }}
</div>
<div v-else-if="productionDatabaseError" class="flex h-full flex-col items-start justify-center gap-3 p-3 text-sm text-destructive">
<p>{{ t("production.databaseLoadFailed", { message: productionDatabaseError }) }}</p>
<p>{{ t(productionPickerLoadFailedKey, { message: productionDatabaseError }) }}</p>
<Button type="button" variant="outline" size="sm" @click="reloadProductionDatabases">
<RefreshCw class="mr-1.5 h-3.5 w-3.5" />
{{ t("production.retry") }}
</Button>
</div>
<div v-else-if="!filteredProductionDatabaseNames.length" class="p-3 text-sm text-muted-foreground">
{{ productionDatabaseNames.length ? t("grid.noSearchResults") : t("production.noDatabasesAvailable") }}
{{ productionDatabaseNames.length ? t("grid.noSearchResults") : t(productionPickerEmptyKey) }}
</div>
<template v-else>
<button
@@ -6,6 +6,7 @@ import type { EditorView } from "@codemirror/view";
import { Archive, ArrowLeftRight, CheckCircle2, ChevronDown, Clipboard, Download, FileClock, FileInput, FileText, Loader2, Network, Plus, RefreshCw, Save, Search, Send, Server, Trash2, X } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@@ -49,6 +50,8 @@ import { editorFontTheme, loadEditorTheme } from "@/lib/editor/editorThemes";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useSettingsStore } from "@/stores/settingsStore";
import { useTheme } from "@/composables/useTheme";
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import type {
NacosBatchPreview,
NacosBatchReport,
@@ -237,6 +240,7 @@ let configListResizeObserver: ResizeObserver | null = null;
const { gridTemplateColumns: configListGridTemplate, minWidth: configListMinWidth, resizingColumnIndex: configListResizingColumnIndex, onResizeStart: onConfigListColumnResizeStart } = useNacosConfigListColumnResize(configListViewportWidth);
const namespace = computed(() => props.namespace ?? connectionInfo.value?.namespace ?? "");
const nacosProductionContext = computed(() => productionContextForDatabase(connectionStore.getConfig(props.connectionId), namespace.value));
const batchTargetConnections = computed<NacosConfigTransferTarget[]>(() =>
connectionStore.connections
.filter((connection) => connection.db_type === "nacos" && !connection.read_only)
@@ -246,6 +250,17 @@ const batchTargetConnections = computed<NacosConfigTransferTarget[]>(() =>
}),
);
const supportsConfigHistory = computed(() => connectionInfo.value?.capabilities.supportsConfigHistory !== false);
async function confirmNacosMutation(reviewText: string, targetConnectionId = props.connectionId, targetNamespace = namespace.value): Promise<boolean> {
const confirmed = await executeWithProductionContextGuard({
connection: connectionStore.getConfig(targetConnectionId),
database: targetNamespace,
reviewText,
source: t("production.sourceAdmin"),
execute: async () => true,
});
return confirmed === true;
}
function operationCapability(capability: NacosOperationCapability | boolean | undefined, legacySupported = true): NacosOperationCapability {
if (typeof capability === "boolean") return { supported: capability, reason: capability ? undefined : "notVerified" };
return capability ?? { supported: legacySupported, reason: legacySupported ? undefined : "notVerified" };
@@ -1189,6 +1204,10 @@ async function previewBatch(payload: { scope: NacosConfigSelectionScope; targetC
async function applyBatch(payload: { scope: NacosConfigSelectionScope; targetConnectionId: string; targetNamespace: string; policy: NacosConflictPolicy }) {
if (batchLoading.value || batchReport.value || !batchPreview.value) return;
if (payload.policy === "OVERWRITE" && !window.confirm(t("nacos.overwriteConfirm"))) return;
const targetConnectionId = batchMode.value === "import" ? props.connectionId : payload.targetConnectionId;
const targetNamespace = batchMode.value === "import" ? namespace.value : payload.targetNamespace;
const operation = batchMode.value === "import" ? t("nacos.batchImport") : t("nacos.copyToNamespace");
if (!(await confirmNacosMutation(operation, targetConnectionId, targetNamespace))) return;
batchLoading.value = true;
batchError.value = "";
try {
@@ -1383,6 +1402,7 @@ function requestRollbackHistory(item: NacosConfigHistoryItem) {
async function rollbackConfigHistory() {
if (!pendingHistoryRollback.value || props.readOnly) return;
if (!(await confirmNacosMutation(t("nacos.historyRollback"), props.connectionId, pendingHistoryRollback.value.namespace || namespace.value))) return;
rollingBackHistory.value = true;
try {
await api.nacosRollbackConfig(props.connectionId, historyKeyFor(pendingHistoryRollback.value));
@@ -1441,6 +1461,7 @@ async function saveConfig() {
return;
}
const pageAtRequest = configPageNo.value;
if (!(await confirmNacosMutation(t("nacos.publish"), snapshot.connectionId, snapshot.targetKey.namespace || namespace.value))) return;
savingConfig.value = true;
configError.value = "";
configSaveNotice.value = "";
@@ -1514,6 +1535,7 @@ async function deleteConfig() {
)
)
return;
if (!(await confirmNacosMutation(t("nacos.delete"), snapshot.connectionId, snapshot.key.namespace || namespace.value))) return;
const editorSessionId = configEditorSessionId;
pendingDeleteConfig.value = null;
deletingConfig.value = true;
@@ -1859,6 +1881,7 @@ async function reconcileInstancePresence(ref: NacosInstanceRef, shouldExist: boo
async function updateInstance(instance: NacosInstanceInfo, patch: NacosInstancePatch) {
if (!selectedService.value || props.readOnly || !supportsInstanceUpdate.value) return;
const ref = instanceRef(instance);
if (!(await confirmNacosMutation(t("nacos.confirmInstanceTitle"), props.connectionId, ref.namespace || namespace.value))) return;
const key = instanceIdentity(instance);
const updateId = ++instanceUpdateSequence;
const operationToken = beginInstanceOperation(key);
@@ -1940,6 +1963,8 @@ async function submitServiceEditor() {
protectThreshold: threshold,
selector: parseOptionalJsonObject(serviceEditor.value.selector, t("nacos.selectorLabel")),
};
const operation = isCreating ? t("nacos.createNacosService") : t("nacos.editNacosService");
if (!(await confirmNacosMutation(operation, props.connectionId, req.namespace || namespace.value))) return;
const mutationId = ++serviceMutationSequence;
if (isCreating) await api.nacosCreateService(props.connectionId, req);
else await api.nacosUpdateService(props.connectionId, req);
@@ -2006,6 +2031,7 @@ async function reconcileServiceDeletion(service: NacosServiceInfo, mutationId: n
}
async function deleteService(service: NacosServiceInfo) {
if (!(await confirmNacosMutation(t("nacos.deleteNacosService"), props.connectionId, namespace.value))) return;
deletingService.value = true;
try {
const mutationId = ++serviceMutationSequence;
@@ -2047,6 +2073,7 @@ async function submitInstanceRegistration() {
weight,
metadata: parseJsonObject(registerInstance.value.metadata, t("nacos.metadataLabel")),
};
if (!(await confirmNacosMutation(t("nacos.registerInstance"), props.connectionId, registration.namespace || namespace.value))) return;
const updateId = ++instanceUpdateSequence;
await api.nacosRegisterInstance(props.connectionId, registration);
const ref: NacosInstanceRef = { ...registration, ephemeral: false };
@@ -2077,10 +2104,11 @@ async function submitInstanceRegistration() {
async function deregisterInstance(instance: NacosInstanceInfo) {
if (!selectedService.value) return;
const ref = instanceRef(instance);
if (!(await confirmNacosMutation(t("nacos.deregisterNacosInstance"), props.connectionId, ref.namespace || namespace.value))) return;
const key = instanceIdentity(instance);
const operationToken = beginInstanceOperation(key);
try {
const ref = instanceRef(instance);
const updateId = ++instanceUpdateSequence;
await api.nacosDeregisterInstance(props.connectionId, ref);
pendingInstanceDeregister.value = null;
@@ -2211,6 +2239,7 @@ onBeforeUnmount(() => {
<Badge v-if="connectionInfo?.serverVersion" variant="secondary">{{ connectionInfo.serverVersion }}</Badge>
<Badge variant="outline">{{ namespaceLabel }}</Badge>
<Badge v-if="namespaceIdLabel" variant="outline" class="max-w-72 truncate font-mono">{{ namespaceIdLabel }}</Badge>
<ProductionContextBadge v-if="nacosProductionContext.active" compact />
<Badge v-if="readOnly" variant="outline">{{ t("nacos.readOnly") }}</Badge>
</div>
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2">
@@ -121,4 +121,14 @@ describe("NacosAdminConsole config workbench layout", () => {
expect(source).toContain('t("nacos.serviceSettings")');
expect(source).toContain('t("nacos.registerInstance")');
});
it("gates every Nacos mutation behind the shared production confirmation", () => {
expect(source).toContain('import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";');
expect(source).toContain("async function confirmNacosMutation");
expect(source).toContain('<ProductionContextBadge v-if="nacosProductionContext.active" compact />');
expect(source.match(/await confirmNacosMutation\(/g)?.length).toBeGreaterThanOrEqual(9);
for (const apiCall of ["nacosApplyConfigImport", "nacosApplyConfigTransfer", "nacosRollbackConfig", "nacosPublishConfig", "nacosDeleteConfig", "nacosUpdateInstance", "nacosCreateService", "nacosDeleteService", "nacosRegisterInstance", "nacosDeregisterInstance"]) {
expect(source).toContain(apiCall);
}
});
});
@@ -45,7 +45,7 @@ import { createSidebarActionTarget, findSidebarActionTarget, matchesSidebarActio
import { syncSidebarTreeNodeExpansion } from "@/lib/sidebar/sidebarTreeExpansion";
import type { SidebarDangerDialogRequest } from "@/lib/sidebar/sidebarDangerDialog";
import { resetSidebarTreeDialogState } from "./sidebarTreeDialogState";
import { SidebarDangerConfirmDialog, SidebarDdlViewDialog, SidebarObjectSourceDialog, SidebarProcedureExecutionDialog, SidebarVisibleDatabasesDialog, SidebarVisibleSchemasDialog } from "./sidebarAsyncDialogs";
import { SidebarDangerConfirmDialog, SidebarDdlViewDialog, 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";
@@ -95,6 +95,8 @@ const sidebarVisibleDatabasesTarget = ref<TreeNode | null>(null);
const sidebarVisibleDatabasesOpen = ref(false);
const sidebarVisibleSchemasTarget = ref<TreeNode | null>(null);
const sidebarVisibleSchemasOpen = ref(false);
const sidebarVisibleNacosNamespacesTarget = ref<TreeNode | null>(null);
const sidebarVisibleNacosNamespacesOpen = ref(false);
const sidebarTableNameFilterTarget = ref<TreeNode | null>(null);
const sidebarTableNameFilterOpen = ref(false);
const tableNameFilterIncludeDraft = ref("");
@@ -1300,12 +1302,14 @@ function beginSidebarAction(): number {
sidebarProcedureOpen.value = false;
sidebarVisibleDatabasesOpen.value = false;
sidebarVisibleSchemasOpen.value = false;
sidebarVisibleNacosNamespacesOpen.value = false;
sidebarTableNameFilterOpen.value = false;
sidebarDdlTarget.value = null;
sidebarObjectSourceTarget.value = null;
sidebarProcedureTarget.value = null;
sidebarVisibleDatabasesTarget.value = null;
sidebarVisibleSchemasTarget.value = null;
sidebarVisibleNacosNamespacesTarget.value = null;
sidebarTableNameFilterTarget.value = null;
return sidebarActionGeneration;
}
@@ -1391,6 +1395,13 @@ function openSidebarVisibleSchemas(node: TreeNode) {
sidebarVisibleSchemasOpen.value = true;
}
function openSidebarVisibleNacosNamespaces(node: TreeNode) {
if (node.type !== "connection" || !node.connectionId || store.getConfig(node.connectionId)?.db_type !== "nacos") return;
beginSidebarAction();
sidebarVisibleNacosNamespacesTarget.value = createSidebarActionTarget(node);
sidebarVisibleNacosNamespacesOpen.value = true;
}
function tableNameFilterScopeForNode(node: TreeNode): string | null {
if (!node.connectionId || !node.database) return null;
return store.tableNameFilterScopeKey({
@@ -1493,6 +1504,10 @@ watch(sidebarVisibleSchemasOpen, (open) => {
if (!open) sidebarVisibleSchemasTarget.value = null;
});
watch(sidebarVisibleNacosNamespacesOpen, (open) => {
if (!open) sidebarVisibleNacosNamespacesTarget.value = null;
});
watch(sidebarTableNameFilterOpen, (open) => {
if (!open) sidebarTableNameFilterTarget.value = null;
});
@@ -1716,6 +1731,7 @@ onUnmounted(() => {
sidebarProcedureTarget.value = null;
sidebarVisibleDatabasesTarget.value = null;
sidebarVisibleSchemasTarget.value = null;
sidebarVisibleNacosNamespacesTarget.value = null;
sidebarTreeItemDialogController.value = null;
sidebarDangerDialogRequest.value = null;
resetSidebarTreeDialogState();
@@ -1755,6 +1771,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
@open-data="openSidebarData"
@open-visible-databases="openSidebarVisibleDatabases"
@open-visible-schemas="openSidebarVisibleSchemas"
@open-visible-nacos-namespaces="openSidebarVisibleNacosNamespaces"
@open-table-name-filters="openSidebarTableNameFilters"
@request-group-rename="startRenamingCreatedGroup"
@open-danger-dialog="openSidebarDangerDialog"
@@ -1996,6 +2013,8 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
:connection-name="sidebarVisibleSchemasTarget.label"
:database="sidebarVisibleSchemasTarget.database"
/>
<SidebarVisibleNacosNamespacesDialog v-if="sidebarVisibleNacosNamespacesTarget?.connectionId" v-model:open="sidebarVisibleNacosNamespacesOpen" :connection-id="sidebarVisibleNacosNamespacesTarget.connectionId" :connection-name="sidebarVisibleNacosNamespacesTarget.label" />
<Dialog v-model:open="sidebarTableNameFilterOpen">
<DialogContent class="max-w-xl">
<DialogHeader class="space-y-2">
@@ -338,6 +338,7 @@ const emit = defineEmits<{
"open-data": [node: TreeNode, requireSelection: boolean, openMode: DataTabOpenMode, runner: (node: TreeNode, request: SidebarDataOpenRequest) => Promise<void>];
"open-visible-databases": [node: TreeNode];
"open-visible-schemas": [node: TreeNode];
"open-visible-nacos-namespaces": [node: TreeNode];
"open-table-name-filters": [node: TreeNode];
"open-danger-dialog": [request: SidebarDangerDialogRequest];
"open-dialog-controller": [controller: Record<string, any> | null];
@@ -4097,6 +4098,13 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean
icon: ListFilter,
});
}
if (currentDatabaseType() === "nacos") {
items.push({
label: t("nacos.nacosVisibleNamespacesTitle"),
action: openVisibleNacosNamespacesDialog,
icon: ListFilter,
});
}
items.push({ label: t("contextMenu.editConnection"), action: editConnection, icon: Pencil, shortcut: shortcutEditConnection.value });
if (revealConnectionFilePath.value) {
items.push({
@@ -4866,9 +4874,19 @@ function handleRowKeydown(node: TreeNode, event: KeyboardEvent) {
function openPrimaryVisibleFilter(node: TreeNode) {
activateRuntimeNode(node);
if (currentDatabaseType() === "nacos") {
openVisibleNacosNamespacesDialog();
return;
}
openVisibleDatabasesDialog();
}
function openVisibleNacosNamespacesDialog() {
const node = activeNode.value;
if (node.type !== "connection" || !node.connectionId || connectionStore.getConfig(node.connectionId)?.db_type !== "nacos") return;
emit("open-visible-nacos-namespaces", node);
}
function openDataInNewTab(node: TreeNode) {
activateRuntimeNode(node);
openDataInNewTabImmediately(node);
@@ -423,14 +423,14 @@ const detailTooltip = computed(() => {
.map((h) => h.trim())
.filter(Boolean)
: [];
const visibleFilterSummary = connectionCanConfigureSidebarVisibleDatabases(config.db_type) ? connectionStore.getSidebarVisibleFilterSummary(node.connectionId) : null;
const visibleFilterSummary = connectionCanConfigureSidebarVisibleDatabases(config.db_type) || config.db_type === "nacos" ? connectionStore.getSidebarVisibleFilterSummary(node.connectionId) : null;
const visibleFilterRow: DetailTooltipRow | null =
visibleFilterSummary?.selected != null && visibleFilterSummary.total != null
? {
label: t(visibleFilterSummary.mode === "schema" ? "visibleSchemas.detailLabel" : "visibleDatabases.detailLabel"),
label: t(visibleFilterSummary.mode === "namespace" ? "nacos.nacosVisibleNamespacesDetailLabel" : visibleFilterSummary.mode === "schema" ? "visibleSchemas.detailLabel" : "visibleDatabases.detailLabel"),
value: `${visibleFilterSummary.selected}/${visibleFilterSummary.total}`,
action: () => treeRuntime.openPrimaryVisibleFilter(node),
actionLabel: t(visibleFilterSummary.mode === "schema" ? "visibleSchemas.detailActionLabel" : "visibleDatabases.detailActionLabel", { connection: config.name }),
actionLabel: t(visibleFilterSummary.mode === "namespace" ? "nacos.nacosVisibleNamespacesDetailActionLabel" : visibleFilterSummary.mode === "schema" ? "visibleSchemas.detailActionLabel" : "visibleDatabases.detailActionLabel", { connection: config.name }),
}
: null;
const rows: DetailTooltipRow[] = [
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { CheckSquare, Loader2, Search, Square } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useConnectionStore } from "@/stores/connectionStore";
import * as api from "@/lib/backend/api";
import { normalizeNacosNamespaceSelection, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
import type { NacosNamespaceInfo } from "@/types/nacos";
const props = defineProps<{
open: boolean;
connectionId: string;
connectionName: string;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
}>();
const { t } = useI18n();
const connectionStore = useConnectionStore();
const namespaces = ref<NacosNamespaceInfo[]>([]);
const selectedNamespaces = ref<Set<string>>(new Set());
const searchText = ref("");
const isLoading = ref(false);
const loadError = ref("");
const connection = computed(() => connectionStore.getConfig(props.connectionId));
const filteredNamespaces = computed(() => {
const query = searchText.value.trim().toLowerCase();
if (!query) return namespaces.value;
return namespaces.value.filter((namespace) => {
const label = nacosNamespaceLabel(namespace).toLowerCase();
return label.includes(query) || namespace.namespace.toLowerCase().includes(query);
});
});
const selectedCount = computed(() => selectedNamespaces.value.size);
const canSave = computed(() => selectedNamespaces.value.size > 0);
const showAllDisabled = computed(() => !Array.isArray(connection.value?.visible_databases));
watch(
() => props.open,
(open) => {
if (!open) return;
void loadNamespaces();
},
{ immediate: true },
);
function nacosNamespaceValue(namespace: NacosNamespaceInfo): string {
return namespace.namespace || "";
}
function nacosNamespaceLabel(namespace: NacosNamespaceInfo): string {
return namespace.namespaceShowName || namespace.namespace || "public";
}
async function loadNamespaces() {
if (isLoading.value) return;
isLoading.value = true;
loadError.value = "";
searchText.value = "";
try {
await connectionStore.ensureConnected(props.connectionId);
const fetched = normalizeNacosNamespacesForDisplay(await api.nacosListNamespaces(props.connectionId));
namespaces.value = [...fetched].sort((left, right) => nacosNamespaceLabel(left).localeCompare(nacosNamespaceLabel(right)));
connectionStore.recordPrimaryVisibleObjectNames(props.connectionId, namespaces.value.map(nacosNamespaceValue));
const configured = connection.value?.visible_databases;
const initialSelection = Array.isArray(configured) ? normalizeNacosNamespaceSelection(configured, namespaces.value) : namespaces.value.map(nacosNamespaceValue);
selectedNamespaces.value = new Set(initialSelection);
} catch (error: any) {
namespaces.value = [];
selectedNamespaces.value = new Set();
loadError.value = String(error?.message || error);
} finally {
isLoading.value = false;
}
}
function toggleNamespace(namespace: string) {
const next = new Set(selectedNamespaces.value);
if (next.has(namespace)) next.delete(namespace);
else next.add(namespace);
selectedNamespaces.value = next;
}
function selectAll() {
selectedNamespaces.value = new Set(namespaces.value.map(nacosNamespaceValue));
}
function clearSelection() {
selectedNamespaces.value = new Set();
}
async function showAll() {
await connectionStore.clearVisibleDatabases(props.connectionId);
emit("update:open", false);
}
async function saveSelection() {
if (!canSave.value) return;
const selected = normalizeNacosNamespaceSelection(selectedNamespaces.value, namespaces.value);
await connectionStore.setVisibleDatabases(props.connectionId, selected);
emit("update:open", false);
}
</script>
<template>
<Dialog :open="open" @update:open="(value: boolean) => emit('update:open', value)">
<DialogContent class="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{{ t("nacos.nacosVisibleNamespacesTitle") }}</DialogTitle>
<p class="text-sm text-muted-foreground">{{ t("nacos.nacosVisibleNamespacesDescription", { name: connectionName }) }}</p>
</DialogHeader>
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
<Input v-model="searchText" :placeholder="t('nacos.nacosSearchNamespaces')" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoading || !!loadError" />
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ t("nacos.nacosSelectedNamespaces", { selected: selectedCount, total: namespaces.length }) }}</span>
<div class="flex items-center gap-2">
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoading" @click="selectAll">{{ t("nacos.nacosSelectAll") }}</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoading" @click="clearSelection">{{ t("nacos.nacosClearSelection") }}</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoading || showAllDisabled" @click="showAll">{{ t("nacos.nacosShowAll") }}</button>
</div>
</div>
<p v-if="!isLoading && !loadError && !canSave" class="text-xs text-destructive">{{ t("nacos.nacosNamespaceSelectionRequired") }}</p>
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
<div v-if="isLoading" class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="loadError" class="p-3 text-sm text-destructive">{{ t("nacos.nacosLoadNamespacesFailed", { message: loadError }) }}</div>
<div v-else-if="!filteredNamespaces.length" class="p-3 text-sm text-muted-foreground">{{ t("grid.noSearchResults") }}</div>
<template v-else>
<button
v-for="namespace in filteredNamespaces"
:key="nacosNamespaceValue(namespace) || '__public__'"
type="button"
class="flex min-h-9 w-full min-w-0 items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
@click="toggleNamespace(nacosNamespaceValue(namespace))"
>
<CheckSquare v-if="selectedNamespaces.has(nacosNamespaceValue(namespace))" class="h-4 w-4 shrink-0 text-primary" />
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate">{{ nacosNamespaceLabel(namespace) }}</span>
<span v-if="namespace.namespace && namespace.namespace !== nacosNamespaceLabel(namespace)" class="shrink-0 truncate text-xs text-muted-foreground">{{ namespace.namespace }}</span>
</button>
</template>
</div>
<DialogFooter>
<Button variant="outline" @click="emit('update:open', false)">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="isLoading || !!loadError || !canSave" @click="saveSelection">{{ t("nacos.save") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -165,6 +165,18 @@ describe("TreeItem visible filter connection detail", () => {
expect(action?.getAttribute("aria-label")).toBe('Configure visible schemas for "Filtered connection"');
});
it("uses the namespace label for a Nacos connection", async () => {
state.config = { ...mysqlConnection(), db_type: "nacos" };
state.summary = { mode: "namespace", isActive: true, selected: 2, total: 3 };
await openConnectionTooltip();
expect(document.body.textContent).toContain("Visible namespaces");
expect(document.body.textContent).toContain("2/3");
const action = [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "2/3");
expect(action?.getAttribute("aria-label")).toBe('Configure visible namespaces for "Filtered connection"');
});
it("shows a clickable detail row when the effective selection is full", async () => {
state.summary = { mode: "database", isActive: false, selected: 3, total: 3 };
@@ -18,6 +18,7 @@ function lazySidebarDialog(loader: () => Promise<Component>) {
export const SidebarDangerConfirmDialog = lazySidebarDialog(() => import("@/components/editor/DangerConfirmDialog.vue"));
export const SidebarVisibleDatabasesDialog = lazySidebarDialog(() => import("@/components/sidebar/VisibleDatabasesDialog.vue"));
export const SidebarVisibleNacosNamespacesDialog = lazySidebarDialog(() => import("@/components/sidebar/VisibleNacosNamespacesDialog.vue"));
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"));