feat(nacos): namespace production protection and sidebar visibility
This commit is contained in:
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>
|
||||
+12
@@ -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"));
|
||||
|
||||
Reference in new issue
Block a user