diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index c208fc6b01..040b19cb7f 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -34,6 +34,7 @@ import { copyToClipboard } from "@/lib/clipboard"; import { showAgentDriverInstallHint, type AgentDriverInstallState } from "@/lib/agentDriverInstallHint"; import { prestoSqlBuiltinDriverPaths } from "@/lib/prestoSqlBuiltinDriver"; import { SQLITE_DATABASE_FILE_EXTENSIONS } from "@/lib/databaseFileDetection"; +import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage, connectionAttemptTimeoutMs } from "@/lib/connectionAttemptTimeout"; import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pipette, Plus, Search, ShieldCheck, Square, Trash2 } from "@lucide/vue"; import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleDatabaseSelectionIsStale } from "@/lib/connectionVisibleDatabases"; import { canSaveVisibleDatabaseSelection, filterDatabaseNamesForConnection, isSystemDatabaseName, normalizeVisibleDatabaseSelection, buildDraftVisibleSchemasConnectionId, normalizeVisibleSchemaSelection } from "@/lib/visibleDatabases"; @@ -56,6 +57,7 @@ type JdbcDriverSelectItem = { const NACOS_DEFAULT_CONSOLE_URL = "http://127.0.0.1:8085"; const NACOS_LEGACY_SERVER_PORT = "8848"; const NACOS_DOCKER_CONSOLE_PORT = "8085"; +const DEFAULT_SSH_USER = "root"; type LegacyTransportFields = { ssh_enabled?: boolean; @@ -164,7 +166,7 @@ function defaultSshTunnel(): SshTunnelConfig { enabled: true, host: "", port: 22, - user: "", + user: DEFAULT_SSH_USER, password: "", key_path: "", key_passphrase: "", @@ -182,7 +184,7 @@ function normalizeSshTunnel(hop: Partial): SshTunnelConfig { enabled: hop.enabled !== false, host: hop.host || "", port: Number(hop.port) || 22, - user: hop.user || "", + user: hop.user?.trim() || DEFAULT_SSH_USER, password: hop.password || "", key_path: hop.key_path || "", key_passphrase: hop.key_passphrase || "", @@ -725,7 +727,7 @@ async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalE nacosServerAddr.value = fallbackUrl; try { const fallbackConfig = connectionConfigForSubmit(config.id); - const message = await api.testConnection(fallbackConfig); + const message = await testConnectionWithTimeout(fallbackConfig); return `${message} ${t("connection.nacosConsoleUrlAutoAdjusted", { from: previousUrl.trim(), to: fallbackUrl })}`; } catch { nacosServerAddr.value = previousUrl; @@ -733,6 +735,39 @@ async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalE } } +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +async function testConnectionWithTimeout(config: ConnectionConfig): Promise { + const timeoutMs = connectionAttemptTimeoutMs(config); + const timeoutMessage = connectionAttemptTimeoutMessage(timeoutMs); + const promise = api.testConnection(config); + let timedOut = false; + let timer: ReturnType | undefined; + void promise.catch((error) => { + if (!timedOut) return; + testResult.value = { + ok: false, + message: connectionAttemptOriginalErrorMessage(timeoutMessage, errorMessage(error)), + }; + }); + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => { + timedOut = true; + reject(new Error(timeoutMessage)); + }, timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + function applyMqAdminUrl(config: LegacyConnectionConfig, adminUrl: string) { let parsed: URL; try { @@ -1398,7 +1433,7 @@ async function testConnection() { testResult.value = null; const config = connectionConfigForSubmit(editingId.value || uuid()); try { - const msg = await api.testConnection(config); + const msg = await testConnectionWithTimeout(config); if (runId !== testRunId) return; if (config.db_type === "mongodb" && /legacy driver/i.test(msg)) { mongoDriverMode.value = "legacy"; @@ -2286,7 +2321,7 @@ function validateTransportLayers(config: LegacyConnectionConfig) { throw new Error(t("connection.sshHopInvalidPort", { hop: label })); } if (layer.type === "ssh") { - if (!layer.user?.trim()) throw new Error(t("connection.sshHopInvalidUser", { hop: label })); + layer.user = layer.user?.trim() || DEFAULT_SSH_USER; // Auth credentials are optional: the backend probes "none" authentication // first, so hops that require no credential (e.g. passwordless SSH proxies) // are valid with password, key, and agent all left empty. diff --git a/apps/desktop/src/components/nacos/NacosAdminConsole.vue b/apps/desktop/src/components/nacos/NacosAdminConsole.vue index 8e2d5056d4..07a2fa36be 100644 --- a/apps/desktop/src/components/nacos/NacosAdminConsole.vue +++ b/apps/desktop/src/components/nacos/NacosAdminConsole.vue @@ -857,7 +857,7 @@ onBeforeUnmount(() => {
- {{ connectionInfo?.serverAddr || "Nacos" }} + {{ connectionInfo?.displayServerAddr || connectionInfo?.serverAddr || "Nacos" }} {{ connectionInfo.serverVersion }} {{ namespaceLabel }} {{ namespaceIdLabel }} diff --git a/apps/desktop/src/types/nacos.ts b/apps/desktop/src/types/nacos.ts index 11d4d3cd2f..6b61423b13 100644 --- a/apps/desktop/src/types/nacos.ts +++ b/apps/desktop/src/types/nacos.ts @@ -8,6 +8,7 @@ export interface NacosCapabilities { export interface NacosConnectionInfo { serverAddr: string; + displayServerAddr: string; namespace: string; serverVersion?: string; auth: string; diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 2d0ebfacc7..d1a8cdd7f2 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -1117,7 +1117,7 @@ impl AppState { } let (host, port) = self.connection_host_port(connection_id, config).await?; - Ok(nacos_config.with_connect_override(&host, port)) + nacos_config.with_server_endpoint(&host, port) } async fn remove_stale_connection_pool(&self, pool_key: &str) -> bool { @@ -3479,6 +3479,40 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn nacos_admin_config_rewrites_server_addr_to_forwarded_endpoint() { + let (state, dir) = test_app_state().await; + let mut config = mysql_config(None); + config.id = "proxied-nacos".to_string(); + config.db_type = DatabaseType::Nacos; + config.host = "192.168.2.51".to_string(); + config.port = 10840; + config.external_config = Some(serde_json::json!({ + "serverAddr": "http://192.168.2.51:10840", + "namespace": "public", + "contextPath": "", + "auth": { "kind": "none" } + })); + config.transport_layers = vec![TransportLayerConfig::Proxy(ProxyTunnelConfig { + id: "proxy".to_string(), + name: String::new(), + enabled: true, + proxy_type: ProxyType::Socks5, + host: "127.0.0.1".to_string(), + port: 65000, + username: String::new(), + password: String::new(), + })]; + + let nacos_config = state.nacos_admin_config_for_connection("proxied-nacos", &config).await.unwrap(); + + assert!(nacos_config.server_addr.starts_with("http://127.0.0.1:")); + assert_ne!(nacos_config.server_addr, "http://192.168.2.51:10840"); + assert!(nacos_config.connect_override.is_none()); + state.proxy_tunnels.stop_tunnel("proxied-nacos:transport:0").await; + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] #[ignore = "requires a reachable GaussDB instance via environment variables"] async fn live_gaussdb_native_connection_succeeds() { diff --git a/crates/dbx-core/src/nacos/config.rs b/crates/dbx-core/src/nacos/config.rs index 3960b48077..ec1886956b 100644 --- a/crates/dbx-core/src/nacos/config.rs +++ b/crates/dbx-core/src/nacos/config.rs @@ -19,6 +19,8 @@ impl Default for NacosAuthConfig { #[serde(rename_all = "camelCase")] pub struct NacosAdminConfig { pub server_addr: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub display_server_addr: String, #[serde(default)] pub namespace: String, #[serde(default)] @@ -53,6 +55,7 @@ impl NacosAdminConfig { let scheme = if cfg.ssl { "https" } else { "http" }; NacosAdminConfig { server_addr: format!("{scheme}://{}:{}", cfg.host.trim(), cfg.port), + display_server_addr: String::new(), namespace: cfg.database.clone().unwrap_or_default(), context_path: String::new(), auth: if cfg.username.trim().is_empty() { @@ -73,6 +76,11 @@ impl NacosAdminConfig { if self.server_addr.is_empty() { return Err("Nacos server address is empty".to_string()); } + if self.display_server_addr.trim().is_empty() { + self.display_server_addr = self.server_addr.clone(); + } else { + self.display_server_addr = self.display_server_addr.trim().trim_end_matches('/').to_string(); + } self.context_path = normalize_context_path(&self.context_path); if self.page_size == 0 { self.page_size = default_page_size(); @@ -85,6 +93,16 @@ impl NacosAdminConfig { self.connect_override = Some(NacosConnectOverride { host: host.to_string(), port }); self } + + pub fn with_server_endpoint(mut self, host: &str, port: u16) -> Result { + let mut url = + reqwest::Url::parse(&self.server_addr).map_err(|e| format!("Nacos server address is invalid: {e}"))?; + url.set_host(Some(host)).map_err(|_| format!("Nacos server address host is invalid: {host}"))?; + url.set_port(Some(port)).map_err(|_| format!("Nacos server address port is invalid: {port}"))?; + self.server_addr = url.to_string().trim_end_matches('/').to_string(); + self.connect_override = None; + Ok(self) + } } pub fn normalize_context_path(path: &str) -> String { @@ -191,4 +209,20 @@ mod tests { assert_eq!(parsed.context_path, ""); assert!(matches!(parsed.auth, NacosAuthConfig::UsernamePassword { .. })); } + + #[test] + fn with_server_endpoint_rewrites_only_host_and_port() { + let cfg = connection_with_external(serde_json::json!({ + "serverAddr": "https://192.168.2.51:10840/nacos", + "namespace": "public", + "contextPath": "/console", + "auth": { "kind": "none" } + })); + + let parsed = NacosAdminConfig::from_connection(&cfg).unwrap().with_server_endpoint("127.0.0.1", 49152).unwrap(); + + assert_eq!(parsed.server_addr, "https://127.0.0.1:49152/nacos"); + assert_eq!(parsed.context_path, "/console"); + assert!(parsed.connect_override.is_none()); + } } diff --git a/crates/dbx-core/src/nacos/http.rs b/crates/dbx-core/src/nacos/http.rs index de59553fe5..53f24b9ab1 100644 --- a/crates/dbx-core/src/nacos/http.rs +++ b/crates/dbx-core/src/nacos/http.rs @@ -32,22 +32,6 @@ impl NacosOpenApiAdmin { if cfg.tls_skip_verify { builder = builder.danger_accept_invalid_certs(true); } - if let Some(connect_override) = cfg.connect_override.as_ref() { - let url = - reqwest::Url::parse(&cfg.server_addr).map_err(|e| format!("Nacos server address is invalid: {e}"))?; - let host = url.host_str().ok_or("Nacos server address host is empty")?; - let _port = url.port_or_known_default().ok_or("Nacos server address port is empty")?; - builder = builder.resolve( - host, - std::net::SocketAddr::new( - connect_override - .host - .parse() - .map_err(|e| format!("Nacos transport override host must be an IP address: {e}"))?, - connect_override.port, - ), - ); - } let http = builder.build().map_err(|e| format!("Failed to build Nacos HTTP client: {e}"))?; Ok(Self { cfg, http, token: Mutex::new(None) }) } @@ -359,6 +343,7 @@ impl NacosAdmin for NacosOpenApiAdmin { let _ = self.access_token().await?; Ok(NacosConnectionInfo { server_addr: self.cfg.server_addr.clone(), + display_server_addr: self.cfg.display_server_addr.clone(), namespace: self.cfg.namespace.clone(), server_version: extract_server_version(&raw), auth: match self.cfg.auth { diff --git a/crates/dbx-core/src/nacos/mod.rs b/crates/dbx-core/src/nacos/mod.rs index 6646800438..9d60c393fa 100644 --- a/crates/dbx-core/src/nacos/mod.rs +++ b/crates/dbx-core/src/nacos/mod.rs @@ -27,7 +27,7 @@ pub use crate::nacos::types::*; #[derive(Default)] pub struct NacosAdminRegistry { - instances: RwLock>>, + instances: RwLock)>>, build_locks: RwLock>>>, } @@ -46,8 +46,10 @@ impl NacosAdminRegistry { connection_id: &str, cfg: NacosAdminConfig, ) -> Result, String> { - if let Some(admin) = self.instances.read().await.get(connection_id) { - return Ok(admin.clone()); + if let Some((existing_cfg, admin)) = self.instances.read().await.get(connection_id) { + if existing_cfg == &cfg { + return Ok(admin.clone()); + } } let lock = { @@ -56,12 +58,14 @@ impl NacosAdminRegistry { }; let _guard = lock.lock().await; - if let Some(admin) = self.instances.read().await.get(connection_id) { - return Ok(admin.clone()); + if let Some((existing_cfg, admin)) = self.instances.read().await.get(connection_id) { + if existing_cfg == &cfg { + return Ok(admin.clone()); + } } - let admin = build_admin(cfg)?; - self.instances.write().await.insert(connection_id.to_string(), admin.clone()); + let admin = build_admin(cfg.clone())?; + self.instances.write().await.insert(connection_id.to_string(), (cfg, admin.clone())); Ok(admin) } diff --git a/crates/dbx-core/src/nacos/service.rs b/crates/dbx-core/src/nacos/service.rs index 759ff3b149..938a22ec21 100644 --- a/crates/dbx-core/src/nacos/service.rs +++ b/crates/dbx-core/src/nacos/service.rs @@ -4,7 +4,11 @@ use crate::nacos::types::*; pub async fn nacos_test_connection_core(state: &AppState, conn_id: &str) -> Result { let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?; - let admin = state.nacos_registry.build_transient(&cfg).await?; + if cfg.db_type != DatabaseType::Nacos { + return Err("Connection is not a Nacos admin connection".to_string()); + } + let admin_config = state.nacos_admin_config_for_connection(conn_id, &cfg).await?; + let admin = state.nacos_registry.build_transient_config(admin_config).await?; admin.test_connection().await } @@ -140,7 +144,8 @@ async fn get_admin( if cfg.db_type != DatabaseType::Nacos { return Err("Connection is not a Nacos admin connection".to_string()); } - state.nacos_registry.get_or_build(&cfg).await + let admin_config = state.nacos_admin_config_for_connection(conn_id, &cfg).await?; + state.nacos_registry.get_or_build_config(conn_id, admin_config).await } async fn ensure_connection_writable(state: &AppState, conn_id: &str, action: &str) -> Result<(), String> { diff --git a/crates/dbx-core/src/nacos/types.rs b/crates/dbx-core/src/nacos/types.rs index bcb8972466..9f002855b0 100644 --- a/crates/dbx-core/src/nacos/types.rs +++ b/crates/dbx-core/src/nacos/types.rs @@ -26,6 +26,7 @@ impl Default for NacosCapabilities { #[serde(rename_all = "camelCase")] pub struct NacosConnectionInfo { pub server_addr: String, + pub display_server_addr: String, pub namespace: String, pub server_version: Option, pub auth: String,