From c237ecbfca893126d86e1b1963941810e8c833a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Fri, 28 Aug 2026 15:10:25 +0800 Subject: [PATCH] fix(nacos): allow manual scope when namespace directory is missing --- .../connection/ConnectionDialog.vue | 17 ++- .../connectionDialogScrolling.spec.ts | 8 +- .../connection/nacosConnectionDialog.spec.ts | 5 +- crates/dbx-core/src/nacos/http.rs | 119 +++++++++++++----- crates/dbx-core/src/nacos/namespace_access.rs | 30 ++++- 5 files changed, 138 insertions(+), 41 deletions(-) diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 3614b6d83a..accfd494db 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -3109,7 +3109,7 @@ const agentDriverFocus = computed(() => ({ target: "driver", d const canChooseVisibleNacosNamespaces = computed(() => form.value.db_type === "nacos"); const isNacosV3AdminPlane = computed(() => nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "admin"); const isNacosV3ConsolePlane = computed(() => nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "console"); -const canDetectNacosNamespaceAccess = computed(() => form.value.db_type === "nacos" && nacosImplementation.value === "nacos" && nacosAuthKind.value === "usernamePassword"); +const canDetectNacosNamespaceAccess = computed(() => form.value.db_type === "nacos" && nacosAuthKind.value === "usernamePassword"); const nacosManualNamespaceLabelKey = computed(() => (isNacosV3AdminPlane.value ? "nacos.nacosManagedNamespaces" : "nacos.nacosManagedNamespacesNameOrId")); const nacosManualNamespacePlaceholderKey = computed(() => (isNacosV3AdminPlane.value ? "nacos.nacosManagedNamespacesIdPlaceholder" : "nacos.nacosManagedNamespacesPlaceholder")); const nacosManualNamespaceHintKey = computed(() => { @@ -3323,7 +3323,8 @@ const shouldUseWideConnectionDialog = computed(() => dialogStep.value === "confi const connectionDialogContentClass = computed(() => { if (dialogStep.value === "select") return "connection-dialog-content--picker sm:h-[720px] sm:max-w-[880px]"; const widthClass = shouldUseWideConnectionDialog.value ? "connection-dialog-content--wide sm:max-w-[660px]" : "connection-dialog-content--standard sm:max-w-[560px]"; - return `${widthClass} connection-dialog-content--config`; + const scrollableAdvancedNacos = form.value.db_type === "nacos" && configTab.value === "advanced"; + return `${widthClass} connection-dialog-content--config${scrollableAdvancedNacos ? " connection-dialog-content--scrollable" : ""}`; }); const connectionLabelClass = "justify-self-start text-left"; const connectionLabelSmallClass = `${connectionLabelClass} text-xs`; @@ -4471,6 +4472,7 @@ async function resolveManualNacosNamespaceNames(namespaces: string[]): Promise -
+
@@ -8979,6 +8984,10 @@ function openExternalUrl(url: string) { min-height: 0; } +.connection-dialog-content--scrollable { + height: min(720px, calc(var(--dbx-viewport-height) - 2rem)); +} + .connection-dialog-content--config .connection-form-body { /* Preserve every form section's natural height; the form viewport owns * scrolling and must never shrink cards into collapsed grid rows. */ diff --git a/apps/desktop/src/lib/__tests__/connection/connectionDialogScrolling.spec.ts b/apps/desktop/src/lib/__tests__/connection/connectionDialogScrolling.spec.ts index 8409413025..ab9791081c 100644 --- a/apps/desktop/src/lib/__tests__/connection/connectionDialogScrolling.spec.ts +++ b/apps/desktop/src/lib/__tests__/connection/connectionDialogScrolling.spec.ts @@ -5,10 +5,10 @@ const dialogSource = readFileSync(new URL("../../../components/connection/Connec describe("connection dialog scrolling", () => { it("keeps every configuration tab inside a shrinkable form viewport", () => { - expect(dialogSource).toContain("return `${widthClass} connection-dialog-content--config`;"); + expect(dialogSource).toContain('connection-dialog-content--config${scrollableAdvancedNacos ? " connection-dialog-content--scrollable" : ""}'); expect(dialogSource.match(/]*class="m-0 flex min-h-0 flex-1 flex-col overflow-hidden">/g)).toHaveLength(4); expect(dialogSource.match(/class="connection-form-body grid min-h-0 flex-1 scroll-pb-6 gap-4 overflow-y-auto/g)).toHaveLength(4); - expect(dialogSource).not.toMatch(/\.connection-dialog-content--config\s*\{[\s\S]*?height:\s*min\(720px/); + expect(dialogSource).toMatch(/\.connection-dialog-content--scrollable\s*\{[\s\S]*?height:\s*min\(720px, calc\(var\(--dbx-viewport-height\) - 2rem\)\);/); expect(dialogSource).toMatch(/@media \(max-height: 720px\)[\s\S]*?height:\s*calc\(var\(--dbx-viewport-height\) - 2rem\);/); expect(dialogSource).toMatch(/\.connection-dialog-content--config \.connection-form-body\s*\{[\s\S]*?align-content:\s*start;/); }); @@ -18,8 +18,8 @@ describe("connection dialog scrolling", () => { expect(dialogSource).toContain('class="connection-form-body grid min-h-0 flex-1 scroll-pb-6 gap-4 overflow-y-auto overflow-x-hidden pt-4 pr-2 pb-6"'); }); - it("keeps conditional Nacos authentication fields from shrinking earlier cards", () => { - expect(dialogSource).toContain(":class=\"{ 'connection-form-body--nacos': form.db_type === 'nacos' }\""); + it("keeps Nacos cards at their content height on both form tabs", () => { + expect(dialogSource.match(/:class="\{ 'connection-form-body--nacos': form\.db_type === 'nacos' \}"/g)).toHaveLength(2); expect(dialogSource).toMatch(/\.connection-form-body--nacos\s*\{[\s\S]*?grid-auto-rows:\s*max-content;/); }); }); diff --git a/apps/desktop/src/lib/__tests__/connection/nacosConnectionDialog.spec.ts b/apps/desktop/src/lib/__tests__/connection/nacosConnectionDialog.spec.ts index a0914f0ebd..5ad069de6e 100644 --- a/apps/desktop/src/lib/__tests__/connection/nacosConnectionDialog.spec.ts +++ b/apps/desktop/src/lib/__tests__/connection/nacosConnectionDialog.spec.ts @@ -87,6 +87,7 @@ describe("Nacos connection dialog layout", () => { it("guides namespace listing permission failures to manual scope entry", () => { expect(source).toContain("function isNacosNamespaceListingPermissionError(error: unknown): boolean"); + expect(source).toContain("NACOS_ERROR\\[rnacosNamespaceDirectoryUnavailable\\]"); expect(source).toContain("NACOS_ERROR\\[(?:v3ManagedNamespacesRequired|managedNamespacesRequired)\\]"); expect(source).toContain("/\\/v3\\/(?:admin|console)\\/core\\/namespace\\/list/"); expect(source).toContain("visibleNacosNamespaceListingPermissionDenied.value = isNacosNamespaceListingPermissionError(e)"); @@ -110,7 +111,9 @@ describe("Nacos connection dialog layout", () => { expect(source).toContain('if (isNacosV3AdminPlane.value) return "nacos.nacosV3AdminManagedNamespacesHint"'); expect(source).toContain('if (isNacosV3ConsolePlane.value) return "nacos.nacosV3ConsoleManagedNamespacesHint"'); expect(source).toContain('v-if="isNacosV3AdminPlane" class="flex gap-2 rounded-md border border-amber-500/30'); - expect(source).toMatch(/const resolvedNamespaces = isNacosV3AdminPlane\.value\s*\?\s*manualNamespaces\s*:\s*await resolveManualNacosNamespaceNames\(manualNamespaces\);/); + expect(source).toContain('isNacosV3AdminPlane.value || nacosImplementation.value === "rnacos"'); + expect(source).toContain("? manualNamespaces"); + expect(source).toContain(": await resolveManualNacosNamespaceNames(manualNamespaces)"); }); it("opens namespace access setup instead of showing a save validation error", () => { diff --git a/crates/dbx-core/src/nacos/http.rs b/crates/dbx-core/src/nacos/http.rs index db29ff89af..c3f174cdc5 100644 --- a/crates/dbx-core/src/nacos/http.rs +++ b/crates/dbx-core/src/nacos/http.rs @@ -647,11 +647,6 @@ impl NacosOpenApiAdmin { } } - async fn list_rnacos_console_namespaces(&self) -> Result, String> { - let value = self.get_rnacos_console_json("/rnacos/api/console/v2/namespaces/list", Vec::new()).await?; - Ok(parse_namespaces(value)) - } - /// The Nacos-compatible discovery endpoint intentionally hides disabled /// instances. r-nacos's own management console exposes the complete /// administrative view instead, including `enabled = false` instances. @@ -1786,7 +1781,15 @@ impl NacosAdmin for NacosOpenApiAdmin { let is_rnacos = self.is_explicit_rnacos() || state.as_ref().is_some_and(|state| state.is_rnacos_compatible); self.detected_rnacos.store(is_rnacos, Ordering::Relaxed); let _ = self.access_token().await?; - self.list_namespaces().await?; + if let Err(error) = self.list_namespaces().await { + // r-nacos can authenticate and serve configuration reads without + // exposing a namespace directory. The connection dialog will + // collect an explicit display scope instead of rejecting this + // otherwise valid OpenAPI connection. + if !(is_rnacos && error.contains("NACOS_ERROR[rnacosNamespaceDirectoryUnavailable]")) { + return Err(error); + } + } let mut capabilities = NacosCapabilities { service_management: self.service_capabilities(), ..Default::default() }; if is_rnacos { @@ -2270,20 +2273,27 @@ impl NacosAdmin for NacosOpenApiAdmin { async fn list_namespaces(&self) -> Result, String> { if self.is_rnacos_compatible() { - // r-nacos v0.6.12 exposes the Nacos-compatible namespace API on - // the main OpenAPI service. This keeps the connection tree usable - // without a separately configured console, including consoles - // that require an interactive CAPTCHA. + // Namespace discovery is not part of r-nacos' client OpenAPI + // contract. Do not turn a failed optional console login into a + // prerequisite for normal OpenAPI configuration access: console + // deployments can require CAPTCHA, OAuth, or different credentials. + // The caller can still use an explicitly configured namespace + // scope, whose individual config requests remain server-authorized. match self.get_json("/v1/console/namespaces", Vec::new()).await { Ok(value) => return Ok(parse_namespaces(value)), - Err(openapi_error) if self.cfg.rnacos_console_addr.is_empty() => return Err(openapi_error), - Err(openapi_error) => { - return self.list_rnacos_console_namespaces().await.map_err(|console_error| { - format!( - "Failed to list r-nacos namespaces through the OpenAPI endpoint ({openapi_error}) or console fallback ({console_error})" - ) - }); + Err(openapi_error) if rnacos_namespace_directory_endpoint_unavailable(&openapi_error) => { + return Err(classified_error( + "rnacosNamespaceDirectoryUnavailable", + &format!( + "r-nacos could not list namespaces through its OpenAPI endpoint: {openapi_error}. Configure the namespace IDs to display instead of relying on console authentication" + ), + )); } + // A reachable endpoint that rejects the current API token, or + // a transport/server failure, is not evidence that the + // directory is optional. Propagate it so connection testing + // cannot report success for an unusable OpenAPI session. + Err(openapi_error) => return Err(openapi_error), } } match self @@ -3808,6 +3818,12 @@ async fn response_json_or_text(resp: reqwest::Response) -> Result Ok(serde_json::from_slice(&bytes).unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).to_string()))) } +fn rnacos_namespace_directory_endpoint_unavailable(error: &str) -> bool { + ["404 Not Found", "405 Method Not Allowed", "410 Gone"] + .iter() + .any(|status| error.contains(&format!("returned {status}"))) +} + async fn error_for_status(resp: reqwest::Response, path: &str) -> Result { let status = resp.status(); if status.is_success() { @@ -6442,23 +6458,13 @@ mod tests { } #[tokio::test] - async fn explicit_rnacos_falls_back_to_console_namespace_api() { + async fn explicit_rnacos_reports_unavailable_openapi_namespace_directory() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let server = tokio::spawn(async move { let (mut socket, _) = listener.accept().await.unwrap(); assert_eq!(read_request_target(&mut socket).await, "/v1/console/namespaces"); write_not_found_response(&mut socket).await; - - let (mut socket, _) = listener.accept().await.unwrap(); - let request = read_http_request(&mut socket).await; - assert_eq!(request.split_whitespace().nth(1), Some("/rnacos/api/console/v2/namespaces/list")); - assert!(request.to_ascii_lowercase().contains("token: console-token")); - write_json_response( - &mut socket, - r#"{"success":true,"data":[{"namespaceId":"prod","namespaceName":"production"}]}"#, - ) - .await; }); let mut config = test_admin_config(format!("http://{address}")); config.implementation = Some(NacosImplementation::RNacos); @@ -6473,8 +6479,61 @@ mod tests { expires_at: Instant::now() + Duration::from_secs(300), }); - let namespaces = admin.list_namespaces().await.unwrap(); - assert_eq!(namespaces[1].namespace, "prod"); + let error = admin.list_namespaces().await.unwrap_err(); + assert!(error.contains("NACOS_ERROR[rnacosNamespaceDirectoryUnavailable]")); + server.await.unwrap(); + } + + #[tokio::test] + async fn rnacos_connection_accepts_a_missing_namespace_directory() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + assert_eq!(read_request_target(&mut socket).await, "/nacos/health"); + write_json_response(&mut socket, "success").await; + + let (mut socket, _) = listener.accept().await.unwrap(); + assert_eq!(read_request_target(&mut socket).await, "/nacos/v1/console/namespaces"); + write_not_found_response(&mut socket).await; + }); + let mut config = test_admin_config(format!("http://{address}")); + config.implementation = Some(NacosImplementation::RNacos); + config.context_path = "/nacos".to_string(); + + NacosOpenApiAdmin::new(config).unwrap().test_connection().await.unwrap(); + server.await.unwrap(); + } + + #[tokio::test] + async fn rnacos_connection_rejects_namespace_directory_authorization_failures() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + assert_eq!(read_request_target(&mut socket).await, "/nacos/health"); + write_json_response(&mut socket, "success").await; + + let (mut socket, _) = listener.accept().await.unwrap(); + assert_eq!(read_request_target(&mut socket).await, "/nacos/v1/auth/users/login"); + write_json_response(&mut socket, r#"{"accessToken":"rnacos-token","tokenTtl":18000}"#).await; + + let (mut socket, _) = listener.accept().await.unwrap(); + let target = read_request_target(&mut socket).await; + assert!(target.starts_with("/nacos/v1/console/namespaces?")); + assert!(target.contains("accessToken=rnacos-token")); + write_forbidden_response(&mut socket).await; + }); + let mut config = test_admin_config(format!("http://{address}")); + config.implementation = Some(NacosImplementation::RNacos); + config.context_path = "/nacos".to_string(); + config.auth = + NacosAuthConfig::UsernamePassword { username: "ordinary".to_string(), password: "secret".to_string() }; + + let error = NacosOpenApiAdmin::new(config).unwrap().test_connection().await.unwrap_err(); + + assert!(error.contains("/v1/console/namespaces returned 403 Forbidden")); + assert!(!error.contains("NACOS_ERROR[rnacosNamespaceDirectoryUnavailable]")); server.await.unwrap(); } diff --git a/crates/dbx-core/src/nacos/namespace_access.rs b/crates/dbx-core/src/nacos/namespace_access.rs index d6a83afe6b..aad426a059 100644 --- a/crates/dbx-core/src/nacos/namespace_access.rs +++ b/crates/dbx-core/src/nacos/namespace_access.rs @@ -76,6 +76,22 @@ pub async fn list_displayable_namespaces( } } +fn is_rnacos_namespace_directory_unavailable(error: &str) -> bool { + error.contains("NACOS_ERROR[rnacosNamespaceDirectoryUnavailable]") +} + +fn scoped_namespace(namespace: &str) -> NacosNamespaceInfo { + let namespace = namespace.trim().to_string(); + NacosNamespaceInfo { + namespace_show_name: if namespace.is_empty() { "public".to_string() } else { namespace.clone() }, + namespace, + namespace_desc: None, + config_count: None, + quota: None, + namespace_type: None, + } +} + async fn probe_displayable_namespaces( namespaces: Vec, admin: Arc, @@ -175,7 +191,8 @@ pub async fn sidebar_snapshot( } /// Applies a user-saved display scope without relying on Nacos role metadata. -/// The scope only narrows the namespaces already returned by the server. +/// For r-nacos deployments without a namespace directory, the saved IDs also +/// provide the sidebar's explicit namespace list. pub async fn sidebar_snapshot_with_visible_scope( connection_id: &str, connection_fingerprint: String, @@ -186,7 +203,16 @@ pub async fn sidebar_snapshot_with_visible_scope( return sidebar_snapshot(connection_id, connection_fingerprint, admin).await; }; - let namespaces = admin.list_namespaces().await?; + let namespaces = match admin.list_namespaces().await { + Ok(namespaces) => namespaces, + // r-nacos's client OpenAPI does not guarantee a namespace directory. + // A user-selected scope is enough to render the tree, while every + // subsequent config request remains authorized by the server. + Err(error) if is_rnacos_namespace_directory_unavailable(&error) => { + visible_scope.iter().map(|namespace| scoped_namespace(namespace)).collect() + } + Err(error) => return Err(error), + }; let visible = visible_scope.iter().map(|namespace| namespace_identity(namespace)).collect(); Ok(NacosNamespaceSidebarSnapshot { namespaces: filter_namespaces(namespaces, &visible),