feat(nacos): namespace production protection and sidebar visibility

This commit is contained in:
二丫讲梵 2026-08-08 10:55:21 +08:00 committed by GitHub
parent 9057be0e78
commit efd0c381ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 492 additions and 26 deletions

View File

@ -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

View File

@ -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">

View File

@ -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);
}
});
});

View File

@ -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">

View File

@ -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);

View File

@ -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[] = [

View File

@ -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>

View File

@ -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 };

View File

@ -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"));

View File

@ -59,6 +59,7 @@ describe("Nacos namespace creation cache invalidation", () => {
connectionStore: {
treeNodes: [],
loadNacosNamespaces: mocks.loadNacosNamespaces,
getConfig: () => undefined,
} as any,
});
@ -81,6 +82,7 @@ describe("Nacos namespace creation cache invalidation", () => {
connectionStore: {
treeNodes: [],
loadNacosNamespaces: mocks.loadNacosNamespaces,
getConfig: () => undefined,
} as any,
});

View File

@ -23,6 +23,7 @@ import {
} from "@/lib/sidebar/mongoCollectionMutation";
import { supportsMongoAllDriverMutations, supportsMongoIndexMutations, supportsNativeMongoDriverMutations } from "@/lib/mongo/mongoCapabilities";
import { runMongoSidebarMutation } from "@/lib/sidebar/runMongoSidebarMutation";
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
import { refreshLoadedMongoIndexes } from "@/lib/mongo/mongoIndexMetadata";
import {
sidebarDangerTarget,
@ -280,10 +281,19 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
const node = sidebarFormTarget.value ?? activeNode.value;
const namespaceName = createNacosNamespaceName.value.trim();
if (!node.connectionId || !namespaceName || createNacosNamespaceLoading.value) return;
const namespaceId = createNacosNamespaceId.value.trim();
const confirmed = await executeWithProductionContextGuard({
connection: connectionStore.getConfig(node.connectionId),
database: namespaceId || undefined,
reviewText: t("nacos.createNamespace"),
source: t("production.sourceSidebar"),
execute: async () => true,
});
if (confirmed !== true) return;
createNacosNamespaceLoading.value = true;
try {
await api.nacosCreateNamespace(node.connectionId, {
namespaceId: createNacosNamespaceId.value.trim() || undefined,
namespaceId: namespaceId || undefined,
namespaceName,
namespaceDesc: createNacosNamespaceDesc.value.trim() || namespaceName,
});
@ -311,6 +321,14 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
const namespaceId = node.nacosNamespace?.trim() || "";
const namespaceName = editNacosNamespaceName.value.trim();
if (!node.connectionId || !namespaceId || !namespaceName || editNacosNamespaceLoading.value) return;
const confirmed = await executeWithProductionContextGuard({
connection: connectionStore.getConfig(node.connectionId),
database: namespaceId,
reviewText: t("nacos.editNamespace"),
source: t("production.sourceSidebar"),
execute: async () => true,
});
if (confirmed !== true) return;
editNacosNamespaceLoading.value = true;
try {
await api.nacosUpdateNamespace(node.connectionId, {

View File

@ -4032,14 +4032,21 @@ export default {
connection: "Production connection",
enable: "Enable production safeguards",
disabledDescription: "Enable this to protect every database or selected databases.",
namespaceDisabledDescription: "Enable this to protect every namespace or selected namespaces.",
scope: "Protection scope",
allDatabases: "All databases",
selectedDatabases: "Selected databases",
allNamespaces: "All namespaces",
selectedNamespaces: "Selected namespaces",
singleDatabaseScopeHint: "This database type supports connection-level production safeguards only.",
databases: "Production databases",
namespaces: "Production namespaces",
connectionDescription: "Every database on this connection uses production safeguards.",
namespaceConnectionDescription: "Every namespace on this connection uses production safeguards.",
databaseDescription: "Choose the databases that need production safeguards. The first selection includes all databases.",
namespaceDescription: "Choose the namespaces that need production safeguards. The first selection includes all namespaces.",
selectDatabases: "Select production databases",
selectNamespaces: "Select production namespaces",
noDatabasesSelected: "No databases selected",
databasesConfiguredCount: "{count} selected",
databasesSelectedCount: "{selected}/{total} selected",
@ -4049,6 +4056,12 @@ export default {
databaseSelectionRequired: "Select at least one database.",
databaseLoadFailed: "Could not load databases: {message}",
noDatabasesAvailable: "No databases are available to select.",
namespacePickerTitle: "Select production namespaces",
namespacePickerDescription: 'Choose the namespaces on "{connection}" that need production safeguards.',
namespaceSearchPlaceholder: "Search namespaces...",
namespaceSelectionRequired: "Select at least one namespace.",
namespaceLoadFailed: "Could not load namespaces: {message}",
noNamespacesAvailable: "No namespaces are available to select.",
retry: "Retry",
confirmTitle: "Confirm production write",
confirmMessage: "This operation changes a production database and requires explicit confirmation.",
@ -7105,6 +7118,8 @@ export default {
nacosRnacosDisabledHint: "When disabled, only the service address is used; basic configuration and service management remain available.",
nacosTlsHint: "Enable only for a trusted self-signed HTTPS certificate.",
nacosVisibleNamespacesTitle: "Choose visible namespaces",
nacosVisibleNamespacesDetailLabel: "Visible namespaces",
nacosVisibleNamespacesDetailActionLabel: 'Configure visible namespaces for "{connection}"',
nacosVisibleNamespacesDescription: "Choose the namespaces to show in the sidebar for {name}.",
nacosSearchNamespaces: "Search namespaces...",
nacosSelectedNamespaces: "{selected}/{total} selected",

View File

@ -5825,6 +5825,8 @@ export default withEnglishFallback({
nacosRnacosDisabledHint: "Si está deshabilitado, solo se usa la dirección del servicio; la configuración y la administración básica de servicios siguen disponibles.",
nacosTlsHint: "Actívalo solo para un certificado HTTPS autofirmado de confianza.",
nacosVisibleNamespacesTitle: "Elegir namespaces visibles",
nacosVisibleNamespacesDetailLabel: "Namespaces visibles",
nacosVisibleNamespacesDetailActionLabel: 'Configurar los namespaces visibles de "{connection}"',
nacosVisibleNamespacesDescription: "Elige los namespaces que se mostrarán en la barra lateral de {name}.",
nacosSearchNamespaces: "Buscar namespaces...",
nacosSelectedNamespaces: "{selected}/{total} seleccionados",
@ -6693,6 +6695,19 @@ export default withEnglishFallback({
databaseSelectionRequired: "Seleccione al menos una base de datos.",
databaseLoadFailed: "No se pudo cargar la lista de bases de datos: {message}",
noDatabasesAvailable: "No se encontraron bases de datos para seleccionar.",
namespaceDisabledDescription: "Al activar, puede elegir proteger todos los espacios de nombres o espacios de nombres específicos.",
allNamespaces: "Todos los espacios de nombres",
selectedNamespaces: "Espacios de nombres seleccionados",
namespaces: "Espacios de nombres de producción",
namespaceConnectionDescription: "Todos los espacios de nombres de esta conexión tendrán habilitada la protección de producción.",
namespaceDescription: "Seleccione de la conexión los espacios de nombres que necesiten protección de producción. La primera vez, se seleccionan todos por defecto.",
selectNamespaces: "Seleccionar espacios de nombres de producción",
namespacePickerTitle: "Seleccionar espacios de nombres de producción",
namespacePickerDescription: "Seleccione los espacios de nombres en {connection} que necesiten protección de producción.",
namespaceSearchPlaceholder: "Buscar espacios de nombres...",
namespaceSelectionRequired: "Seleccione al menos un espacio de nombres.",
namespaceLoadFailed: "No se pudo cargar la lista de espacios de nombres: {message}",
noNamespacesAvailable: "No se encontraron espacios de nombres para seleccionar.",
retry: "Reintentar",
confirmTitle: "Confirmar escritura en el entorno de producción",
confirmMessage: "Esta operación modificará la base de datos de producción. Debe confirmar explícitamente para continuar.",

View File

@ -5825,6 +5825,8 @@ export default withEnglishFallback({
nacosRnacosDisabledHint: "Quando è disabilitato viene usato solo l'indirizzo del servizio; la configurazione e la gestione di base dei servizi restano disponibili.",
nacosTlsHint: "Abilita solo per un certificato HTTPS autofirmato attendibile.",
nacosVisibleNamespacesTitle: "Scegli i namespace visibili",
nacosVisibleNamespacesDetailLabel: "Namespace visibili",
nacosVisibleNamespacesDetailActionLabel: 'Configura i namespace visibili per "{connection}"',
nacosVisibleNamespacesDescription: "Scegli i namespace da mostrare nella barra laterale di {name}.",
nacosSearchNamespaces: "Cerca namespace...",
nacosSelectedNamespaces: "{selected}/{total} selezionati",
@ -6693,6 +6695,19 @@ export default withEnglishFallback({
databaseSelectionRequired: "Selezionare almeno un database.",
databaseLoadFailed: "Impossibile caricare l'elenco dei database: {message}",
noDatabasesAvailable: "Nessun database selezionabile trovato.",
namespaceDisabledDescription: "Dopo l'attivazione, è possibile scegliere di proteggere tutti i namespace o namespace specifici.",
allNamespaces: "Tutti i namespace",
selectedNamespaces: "Namespace selezionati",
namespaces: "Namespace di produzione",
namespaceConnectionDescription: "Tutti i namespace di questa connessione avranno la protezione di produzione abilitata.",
namespaceDescription: "Seleziona i namespace che necessitano di protezione di produzione dalla connessione. Alla prima apertura, sono selezionati tutti per impostazione predefinita.",
selectNamespaces: "Seleziona namespace di produzione",
namespacePickerTitle: "Seleziona namespace di produzione",
namespacePickerDescription: "Seleziona i namespace in «{connection}» che necessitano di protezione di produzione.",
namespaceSearchPlaceholder: "Cerca namespace...",
namespaceSelectionRequired: "Selezionare almeno un namespace.",
namespaceLoadFailed: "Impossibile caricare l'elenco dei namespace: {message}",
noNamespacesAvailable: "Nessun namespace selezionabile trovato.",
retry: "Riprova",
confirmTitle: "Conferma scrittura in ambiente di produzione",
confirmMessage: "Questa operazione modificherà il database di produzione; è necessario confermare esplicitamente per procedere.",

View File

@ -5880,6 +5880,8 @@ export default withEnglishFallback({
nacosRnacosDisabledHint: "無効の場合はサービスアドレスだけを使用します。基本的な設定とサービス管理は引き続き利用できます。",
nacosTlsHint: "信頼できる自己署名 HTTPS 証明書の場合だけ有効にしてください。",
nacosVisibleNamespacesTitle: "表示する名前空間を選択",
nacosVisibleNamespacesDetailLabel: "表示する名前空間",
nacosVisibleNamespacesDetailActionLabel: "「{connection}」の表示する名前空間を設定",
nacosVisibleNamespacesDescription: "「{name}」のサイドバーに表示する名前空間を選択します。",
nacosSearchNamespaces: "名前空間を検索...",
nacosSelectedNamespaces: "{selected}/{total} 件を選択",
@ -6748,6 +6750,19 @@ export default withEnglishFallback({
databaseSelectionRequired: "少なくとも1つのデータベースを選択してください。",
databaseLoadFailed: "データベースリストを読み込めませんでした:{message}",
noDatabasesAvailable: "選択可能なデータベースが見つかりません。",
namespaceDisabledDescription: "有効にすると、すべての名前空間または指定された名前空間を保護するように選択できます。",
allNamespaces: "すべての名前空間",
selectedNamespaces: "名前空間を選択",
namespaces: "本番名前空間",
namespaceConnectionDescription: "この接続上のすべての名前空間で本番保護が有効になります。",
namespaceDescription: "接続から本番保護が必要な名前空間を選択します。初回起動時はデフォルトで全選択されています。",
selectNamespaces: "本番名前空間を選択",
namespacePickerTitle: "本番名前空間を選択",
namespacePickerDescription: "「{connection}」の中で本番保護が必要な名前空間を選択します。",
namespaceSearchPlaceholder: "名前空間を検索...",
namespaceSelectionRequired: "少なくとも1つの名前空間を選択してください。",
namespaceLoadFailed: "名前空間リストを読み込めませんでした:{message}",
noNamespacesAvailable: "選択可能な名前空間が見つかりません。",
retry: "再試行",
confirmTitle: "本番環境への書き込みを確認",
confirmMessage: "この操作は本番データベースを変更します。続行するには明示的な確認が必要です。",

View File

@ -3588,6 +3588,19 @@ export default withEnglishFallback({
databaseSelectionRequired: "최소 한 개의 데이터베이스를 선택하세요.",
databaseLoadFailed: "데이터베이스를 불러올 수 없습니다: {message}",
noDatabasesAvailable: "선택할 수 있는 데이터베이스가 없습니다.",
namespaceDisabledDescription: "활성화하면 모든 네임스페이스 또는 선택한 네임스페이스를 보호하도록 선택할 수 있습니다.",
allNamespaces: "모든 네임스페이스",
selectedNamespaces: "선택한 네임스페이스",
namespaces: "프로덕션 네임스페이스",
namespaceConnectionDescription: "이 연결의 모든 네임스페이스에 프로덕션 보호가 활성화됩니다.",
namespaceDescription: "연결에서 프로덕션 보호가 필요한 네임스페이스를 선택하세요. 첫 번째 선택에는 모든 네임스페이스가 포함됩니다.",
selectNamespaces: "프로덕션 네임스페이스 선택",
namespacePickerTitle: "프로덕션 네임스페이스 선택",
namespacePickerDescription: "「{connection}」에서 프로덕션 보호가 필요한 네임스페이스를 선택하세요.",
namespaceSearchPlaceholder: "네임스페이스 검색...",
namespaceSelectionRequired: "최소 한 개의 네임스페이스를 선택하세요.",
namespaceLoadFailed: "네임스페이스 목록을 불러올 수 없습니다: {message}",
noNamespacesAvailable: "선택할 수 있는 네임스페이스가 없습니다.",
retry: "다시 시도",
confirmTitle: "프로덕션 쓰기 확인",
confirmMessage: "이 작업은 프로덕션 데이터베이스를 변경하므로 명시적인 확인이 필요합니다.",
@ -6620,6 +6633,8 @@ export default withEnglishFallback({
nacosRnacosDisabledHint: "비활성화하면 서비스 주소만 사용하며, 기본 구성 및 서비스 관리는 계속 사용할 수 있습니다.",
nacosTlsHint: "신뢰할 수 있는 자체 서명 HTTPS 인증서에만 활성화하세요.",
nacosVisibleNamespacesTitle: "표시할 네임스페이스 선택",
nacosVisibleNamespacesDetailLabel: "표시할 네임스페이스",
nacosVisibleNamespacesDetailActionLabel: '"{connection}"의 표시할 네임스페이스 구성',
nacosVisibleNamespacesDescription: "{name}의 사이드바에 표시할 네임스페이스를 선택합니다.",
nacosSearchNamespaces: "네임스페이스 검색...",
nacosSelectedNamespaces: "{selected}/{total}개 선택됨",

View File

@ -5827,6 +5827,8 @@ export default withEnglishFallback({
nacosRnacosDisabledHint: "Quando desativado, somente o endereço do serviço é usado; a configuração e o gerenciamento básico de serviços continuam disponíveis.",
nacosTlsHint: "Ative somente para um certificado HTTPS autoassinado confiável.",
nacosVisibleNamespacesTitle: "Escolher namespaces visíveis",
nacosVisibleNamespacesDetailLabel: "Namespaces visíveis",
nacosVisibleNamespacesDetailActionLabel: 'Configurar namespaces visíveis para "{connection}"',
nacosVisibleNamespacesDescription: "Escolha os namespaces que serão exibidos na barra lateral de {name}.",
nacosSearchNamespaces: "Pesquisar namespaces...",
nacosSelectedNamespaces: "{selected}/{total} selecionados",
@ -6695,6 +6697,19 @@ export default withEnglishFallback({
databaseSelectionRequired: "Por favor, selecione pelo menos um banco de dados.",
databaseLoadFailed: "Não foi possível carregar a lista de bancos de dados: {message}",
noDatabasesAvailable: "Nenhum banco de dados disponível encontrado.",
namespaceDisabledDescription: "Após ativar, é possível escolher proteger todos os namespaces ou namespaces específicos.",
allNamespaces: "Todos os namespaces",
selectedNamespaces: "Namespaces selecionados",
namespaces: "Namespaces de produção",
namespaceConnectionDescription: "Todos os namespaces desta conexão terão a proteção de produção habilitada.",
namespaceDescription: "Selecione os namespaces da conexão que precisam de proteção de produção. Na primeira abertura, todos são selecionados por padrão.",
selectNamespaces: "Selecionar namespaces de produção",
namespacePickerTitle: "Selecionar namespaces de produção",
namespacePickerDescription: "Selecione os namespaces em {connection} que precisam de proteção de produção.",
namespaceSearchPlaceholder: "Buscar namespaces...",
namespaceSelectionRequired: "Por favor, selecione pelo menos um namespace.",
namespaceLoadFailed: "Não foi possível carregar a lista de namespaces: {message}",
noNamespacesAvailable: "Nenhum namespace disponível encontrado.",
retry: "Tentar novamente",
confirmTitle: "Confirmar gravação em ambiente de produção",
confirmMessage: "Esta operação irá modificar o banco de dados de produção. É necessário confirmar explicitamente para continuar.",

View File

@ -4030,14 +4030,21 @@ export default withEnglishFallback({
connection: "生产连接",
enable: "启用生产环境保护",
disabledDescription: "开启后可选择保护全部数据库或指定数据库。",
namespaceDisabledDescription: "开启后可选择保护全部命名空间或指定命名空间。",
scope: "保护范围",
allDatabases: "全部数据库",
selectedDatabases: "选择数据库",
allNamespaces: "全部命名空间",
selectedNamespaces: "选择命名空间",
singleDatabaseScopeHint: "此数据库类型仅支持连接级生产环境保护。",
databases: "生产数据库",
namespaces: "生产命名空间",
connectionDescription: "此连接上的所有数据库都会启用生产保护。",
namespaceConnectionDescription: "此连接上的所有命名空间都会启用生产保护。",
databaseDescription: "从连接中选择需要生产保护的数据库。首次打开默认全选。",
namespaceDescription: "从连接中选择需要生产保护的命名空间。首次打开默认全选。",
selectDatabases: "选择生产数据库",
selectNamespaces: "选择生产命名空间",
noDatabasesSelected: "尚未选择",
databasesConfiguredCount: "已选择 {count} 个",
databasesSelectedCount: "已选择 {selected}/{total}",
@ -4047,8 +4054,14 @@ export default withEnglishFallback({
databaseSelectionRequired: "请至少选择一个数据库。",
databaseLoadFailed: "无法加载数据库列表:{message}",
noDatabasesAvailable: "未找到可选择的数据库。",
namespacePickerTitle: "选择生产命名空间",
namespacePickerDescription: "选择「{connection}」中需要生产保护的命名空间。",
namespaceSearchPlaceholder: "搜索命名空间...",
namespaceSelectionRequired: "请至少选择一个命名空间。",
namespaceLoadFailed: "无法加载命名空间列表:{message}",
noNamespacesAvailable: "未找到可选择的命名空间。",
retry: "重试",
confirmTitle: "确认生产环境写入",
confirmTitle: "确认生产环境变更",
confirmMessage: "该操作将变更生产数据库,必须明确确认后才能继续。",
confirmDetails: "连接:{connection}\n数据库{database}\n入口{source}",
confirmAction: "在生产环境执行",
@ -7099,6 +7112,8 @@ export default withEnglishFallback({
nacosRnacosDisabledHint: "未启用时仅使用服务地址,基础配置和服务管理仍可用。",
nacosTlsHint: "仅在使用自签名 HTTPS 证书且确认目标可信时开启。",
nacosVisibleNamespacesTitle: "选择显示的命名空间",
nacosVisibleNamespacesDetailLabel: "可见命名空间",
nacosVisibleNamespacesDetailActionLabel: "配置「{connection}」的可见命名空间",
nacosVisibleNamespacesDescription: "选择「{name}」下要在侧边栏显示的命名空间。",
nacosSearchNamespaces: "搜索命名空间...",
nacosSelectedNamespaces: "已选择 {selected}/{total}",

View File

@ -5290,6 +5290,8 @@ export default withEnglishFallback({
nacosRnacosDisabledHint: "停用時只使用服務位址;基本設定與服務管理仍可使用。",
nacosTlsHint: "僅在使用可信任的自簽 HTTPS 憑證時啟用。",
nacosVisibleNamespacesTitle: "選擇顯示的命名空間",
nacosVisibleNamespacesDetailLabel: "可見命名空間",
nacosVisibleNamespacesDetailActionLabel: "設定「{connection}」的可見命名空間",
nacosVisibleNamespacesDescription: "選擇要在「{name}」側邊欄顯示的命名空間。",
nacosSearchNamespaces: "搜尋命名空間...",
nacosSelectedNamespaces: "已選 {selected}/{total}",
@ -6688,6 +6690,19 @@ export default withEnglishFallback({
databaseSelectionRequired: "請至少選擇一個資料庫。",
databaseLoadFailed: "無法載入資料庫清單:{message}",
noDatabasesAvailable: "未找到可選擇的資料庫。",
namespaceDisabledDescription: "開啟後可選擇保護全部命名空間或指定命名空間。",
allNamespaces: "全部命名空間",
selectedNamespaces: "選擇命名空間",
namespaces: "生產命名空間",
namespaceConnectionDescription: "此連線上的所有命名空間都會啟用生產保護。",
namespaceDescription: "從連線中選擇需要生產保護的命名空間。首次開啟預設全選。",
selectNamespaces: "選擇生產命名空間",
namespacePickerTitle: "選擇生產命名空間",
namespacePickerDescription: "選擇「{connection}」中需要生產保護的命名空間。",
namespaceSearchPlaceholder: "搜尋命名空間...",
namespaceSelectionRequired: "請至少選擇一個命名空間。",
namespaceLoadFailed: "無法載入命名空間清單:{message}",
noNamespacesAvailable: "未找到可選擇的命名空間。",
retry: "重試",
confirmTitle: "確認生產環境寫入",
confirmMessage: "該操作將變更生產資料庫,必須明確確認後才能繼續。",

View File

@ -63,4 +63,12 @@ describe("Nacos connection dialog layout", () => {
expect(source).toContain("api.nacosListNamespaces(draftId)");
expect(source).toContain("showVisibleNacosNamespacesDialog");
});
it("uses namespaces when scoping Nacos production safeguards", () => {
expect(source).toContain('form.value.db_type === "nacos"');
expect(source).toContain("production.allNamespaces");
expect(source).toContain("production.namespacePickerTitle");
expect(source).toContain("api.nacosListNamespaces(connectionId)");
expect(source).toContain("nacosNamespaceIdentity(name)");
});
});

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { filterNacosNamespacesForSidebar, normalizeNacosNamespaceSelection, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
import { nacosVisibleNamespaceSummary } from "@/lib/sidebar/sidebarVisibleFilterSummary";
const namespaces = [
{ namespace: "", namespaceShowName: "public" },
@ -30,4 +31,13 @@ describe("filterNacosNamespacesForSidebar", () => {
const duplicatePublic = [namespaces[0], { namespace: "public", namespaceShowName: "public" }, namespaces[1]];
expect(normalizeNacosNamespacesForDisplay(duplicatePublic)).toEqual([duplicatePublic[1], duplicatePublic[2]]);
});
it("counts a legacy public selection as visible for a Nacos 3 endpoint", () => {
expect(nacosVisibleNamespaceSummary({ visible_databases: ["", "prod"] }, ["public", "dev", "prod"])).toEqual({
mode: "namespace",
isActive: true,
selected: 2,
total: 3,
});
});
});

View File

@ -40,6 +40,12 @@ describe("production SQL safety", () => {
expect(productionContextForDatabase(connection(), "staging").active).toBe(false);
});
it("treats Nacos public and empty namespace IDs as the same protected namespace", () => {
const nacos = connection({ db_type: "nacos", production_databases: [""] });
expect(productionContextForDatabase(nacos, "public")).toMatchObject({ active: true, reason: "database", databases: ["public"] });
expect(productionContextForDatabase(nacos, "")).toMatchObject({ active: true, reason: "database", databases: ["public"] });
});
it("detects a write after a USE production switch despite comments", () => {
const assessment = assessProductionSql("-- install\nUSE `prod_app`; /* migration */ DELETE FROM users", connection(), "staging");
expect(assessment).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });

View File

@ -1,4 +1,5 @@
import type { ConnectionConfig, DatabaseType } from "@/types/database";
import { nacosNamespaceIdentity } from "@/lib/nacos/nacosNamespaceVisibility";
import { classifySqlRisk, isSqlRiskMutation } from "@/lib/sql/sqlRisk";
export type ProductionContextReason = "connection" | "database" | "sql_target";
@ -93,6 +94,9 @@ export function normalizeProductionDatabase(value: string | undefined | null): s
export function productionDatabases(connection: ConnectionConfig | undefined): string[] {
if (!connection?.production_databases?.length) return [];
if (connection.db_type === "nacos") {
return [...new Set(connection.production_databases.map(nacosNamespaceIdentity))];
}
return [...new Set(connection.production_databases.map(normalizeProductionDatabase).filter(Boolean))];
}
@ -100,10 +104,10 @@ export function productionContextForDatabase(connection: ConnectionConfig | unde
if (!connection) return { active: false, databases: [] };
if (connection.is_production) return { active: true, reason: "connection", databases: [] };
const normalizedDatabase = normalizeProductionDatabase(database);
const normalizedDatabase = connection.db_type === "nacos" ? nacosNamespaceIdentity(String(database ?? "")) : normalizeProductionDatabase(database);
const marked = productionDatabases(connection);
if (normalizedDatabase && marked.includes(normalizedDatabase)) {
return { active: true, reason: "database", databases: [String(database)] };
return { active: true, reason: "database", databases: [connection.db_type === "nacos" ? normalizedDatabase : String(database)] };
}
return { active: false, databases: [] };
}

View File

@ -1,10 +1,11 @@
import type { ConnectionConfig } from "@/types/database";
import { connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, filterSchemaNamesForVisiblePicker, normalizeVisibleDatabaseSelection } from "@/lib/database/visibleDatabases";
import { nacosNamespaceIdentity } from "@/lib/nacos/nacosNamespaceVisibility";
type SidebarVisibleFilterConnection = Pick<ConnectionConfig, "database" | "db_type" | "driver_profile" | "show_system_schemas" | "username" | "visible_databases" | "visible_schemas">;
export type SidebarVisibleFilterSummary = {
mode: "database" | "schema";
mode: "database" | "schema" | "namespace";
isActive: boolean;
selected: number | null;
total: number | null;
@ -40,3 +41,17 @@ export function sidebarVisibleFilterSummary(connection: SidebarVisibleFilterConn
total,
};
}
export function nacosVisibleNamespaceSummary(connection: Pick<ConnectionConfig, "visible_databases">, namespaceIds?: readonly string[]): SidebarVisibleFilterSummary {
if (!namespaceIds) return { mode: "namespace", isActive: false, selected: null, total: null };
const identities = [...new Set(namespaceIds.map(nacosNamespaceIdentity))];
const total = identities.length;
if (!Array.isArray(connection.visible_databases)) {
return { mode: "namespace", isActive: false, selected: total, total };
}
const selected = new Set(connection.visible_databases.map(nacosNamespaceIdentity));
const selectedCount = identities.filter((identity) => selected.has(identity)).length;
return { mode: "namespace", isActive: selectedCount < total, selected: selectedCount, total };
}

View File

@ -109,7 +109,7 @@ import { normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel }
import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, hasInstalledAgentVersion, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint";
import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints";
import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases";
import { filterNacosNamespacesForSidebar } from "@/lib/nacos/nacosNamespaceVisibility";
import { filterNacosNamespacesForSidebar, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
import { configuredDatabaseProductName, connectionConfigFingerprint, normalizeDatabaseConnectionInfo } from "@/lib/connection/connectionDatabaseInfo";
import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator, type MetadataLoadTraceLogger } from "@/lib/metadata/metadataLoadCoordinator";
import type { MetadataScopeInput } from "@/lib/metadata/metadataLoadScope";
@ -123,7 +123,7 @@ import i18n from "@/i18n";
import type { MqAdminConfig } from "@/types/mq";
import { RABBITMQ_MQ_TENANT, resolveMqSystemKindFromConnection } from "@/lib/mq/mqConsoleDefaults";
import { applySidebarDatabaseStorage, applySidebarTableStorage, sidebarDatabaseNames, supportsSidebarDatabaseStorage, supportsSidebarTableStorage, type SidebarTableStorageScope } from "@/lib/sidebar/sidebarDatabaseStorage";
import { connectionHasConfiguredSidebarVisibleFilter, sidebarVisibleFilterSummary } from "@/lib/sidebar/sidebarVisibleFilterSummary";
import { connectionHasConfiguredSidebarVisibleFilter, nacosVisibleNamespaceSummary, sidebarVisibleFilterSummary } from "@/lib/sidebar/sidebarVisibleFilterSummary";
import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu";
const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes";
@ -2590,6 +2590,7 @@ export const useConnectionStore = defineStore("connection", () => {
function getSidebarVisibleFilterSummary(connectionId: string) {
const config = getConfig(connectionId);
if (config?.db_type === "nacos") return nacosVisibleNamespaceSummary(config, primaryVisibleObjectNames.value[connectionId]);
return config ? sidebarVisibleFilterSummary(config, primaryVisibleObjectNames.value[connectionId]) : null;
}
@ -3508,7 +3509,7 @@ export const useConnectionStore = defineStore("connection", () => {
load = reclaimTreeNodeLoad(load, node);
if (useCachedChildren(node, options, load)) return;
const namespaces = await api.nacosListNamespaces(connectionId);
const namespaces = normalizeNacosNamespacesForDisplay(await api.nacosListNamespaces(connectionId));
const visibleNamespaces = filterNacosNamespacesForSidebar(namespaces, getConfig(connectionId)?.visible_databases);
const sorted = [...visibleNamespaces].sort((left, right) => {
const leftLabel = left.namespaceShowName || left.namespace || "public";
@ -3517,6 +3518,10 @@ export const useConnectionStore = defineStore("connection", () => {
});
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
recordPrimaryVisibleObjectNames(
connectionId,
namespaces.map((namespace) => namespace.namespace),
);
setChildren(
targetNode,
sorted.map((namespace) => {