diff --git a/agents/drivers/zookeeper/agent_test.go b/agents/drivers/zookeeper/agent_test.go index 358cc2a746..0fcc647c5b 100644 --- a/agents/drivers/zookeeper/agent_test.go +++ b/agents/drivers/zookeeper/agent_test.go @@ -13,6 +13,20 @@ import ( "github.com/go-zookeeper/zk" ) +func TestParseServerVersionTrimsBuiltOnSuffix(t *testing.T) { + srvr := "Zookeeper version: 3.6.1--104dcb3dcae0bf1bca4b4a00b2d76f2b5f6d79c, built on 04/21/2020 15:01 GMT\n" + if got := parseServerVersion(srvr); got != "3.6.1--104dcb3dcae0bf1bca4b4a00b2d76f2b5f6d79c" { + t.Fatalf("srvr version = %q", got) + } + envi := "zookeeper.version=3.8.4-9a6b1d5e9c0f, built on 2024-01-01 00:00 GMT\n" + if got := parseServerVersion(envi); got != "3.8.4-9a6b1d5e9c0f" { + t.Fatalf("envi version = %q", got) + } + if got := parseServerVersion("Latency min/avg/max: 0/0/0\n"); got != "" { + t.Fatalf("unrelated line parsed as version %q", got) + } +} + func TestHandshakeAndRPCFailures(t *testing.T) { service := newServer() response, shutdown := service.handleRequest([]byte(`{"jsonrpc":"2.0","id":1,"method":"handshake","params":{}}`)) @@ -20,7 +34,7 @@ func TestHandshakeAndRPCFailures(t *testing.T) { t.Fatalf("handshake response = %#v, shutdown=%v", response, shutdown) } handshake, ok := response.Result.(handshakeResult) - if !ok || handshake.ProtocolVersion != 1 || handshake.AgentProtocolVersion != 1 || strings.Join(handshake.Capabilities, ",") != "connect,test_connection,kv" { + if !ok || handshake.ProtocolVersion != 1 || handshake.AgentProtocolVersion != 1 || strings.Join(handshake.Capabilities, ",") != "connect,test_connection,connection_info,kv" { t.Fatalf("unexpected handshake: %#v", response.Result) } diff --git a/agents/drivers/zookeeper/connection.go b/agents/drivers/zookeeper/connection.go index 1d033e977c..8b93241d40 100644 --- a/agents/drivers/zookeeper/connection.go +++ b/agents/drivers/zookeeper/connection.go @@ -111,13 +111,14 @@ func (service *server) connect(params json.RawMessage) (map[string]bool, error) } previousClient := service.activeClient service.activeClient = nextClient + service.activeConfig = config if previousClient != nil { previousClient.Close() } return map[string]bool{"ok": true}, nil } -func (service *server) testConnection(params json.RawMessage) (map[string]bool, error) { +func (service *server) testConnection(params json.RawMessage) (map[string]any, error) { config, err := decodeConnectionConfig(params) if err != nil { return nil, err @@ -127,7 +128,22 @@ func (service *server) testConnection(params json.RawMessage) (map[string]bool, return nil, err } probe.Close() - return map[string]bool{"ok": true}, nil + result := map[string]any{"ok": true} + if info := databaseInfo(config); info != nil { + result["databaseInfo"] = info + } + return result, nil +} + +func (service *server) connectionInfo() (map[string]any, error) { + if _, err := service.requireClient(); err != nil { + return nil, err + } + result := map[string]any{} + if info := databaseInfo(service.activeConfig); info != nil { + result["databaseInfo"] = info + } + return result, nil } func openClient(config connectionConfig) (*clientSession, error) { @@ -272,6 +288,74 @@ func connectionString(config connectionConfig) string { return net.JoinHostPort(strings.Trim(host, "[]"), strconv.Itoa(port)) } +func databaseInfo(config connectionConfig) map[string]any { + info := map[string]any{"productName": "ZooKeeper"} + target, err := parseConnectTarget(connectionString(config)) + if err != nil { + return info + } + if version := detectServerVersion(target.Servers, millisecondsOrDefault(config.ConnectionTimeoutMS, defaultConnectionTimeout)); version != "" { + info["productVersion"] = version + } + return info +} + +func detectServerVersion(servers []string, timeout time.Duration) string { + deadline := timeout + if deadline <= 0 || deadline > 2*time.Second { + deadline = 2 * time.Second + } + for _, server := range servers { + address, err := endpointAddress(server) + if err != nil { + continue + } + // ZooKeeper 3.5+ whitelists only "srvr" by default; "envi"/"stat" + // are opt-in, so probe srvr first for default-config clusters. + for _, command := range []string{"srvr", "envi", "stat"} { + connection, err := net.DialTimeout("tcp", address, deadline) + if err != nil { + continue + } + _ = connection.SetDeadline(time.Now().Add(deadline)) + if _, err := connection.Write([]byte(command)); err != nil { + connection.Close() + continue + } + buffer := make([]byte, 16*1024) + count, _ := connection.Read(buffer) + if version := parseServerVersion(string(buffer[:count])); version != "" { + connection.Close() + return version + } + connection.Close() + } + } + return "" +} + +func parseServerVersion(response string) string { + for _, line := range strings.Split(response, "\n") { + line = strings.TrimSpace(line) + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + parts = strings.SplitN(line, ":", 2) + } + key := strings.ToLower(strings.TrimSpace(parts[0])) + key = strings.NewReplacer(" ", ".", "_", ".").Replace(key) + if len(parts) == 2 && key == "zookeeper.version" { + version := strings.TrimSpace(parts[1]) + // Drop the ", built on ..." suffix envi/stat/srvr carry so only + // the version itself is shown. + if idx := strings.Index(version, ","); idx >= 0 { + version = strings.TrimSpace(version[:idx]) + } + return version + } + } + return "" +} + func parseConnectTarget(value string) (connectTarget, error) { connectString := strings.TrimSpace(strings.TrimPrefix(value, "zookeeper://")) slash := strings.Index(connectString, "/") @@ -460,6 +544,7 @@ func (service *server) closeClient() { service.activeClient.Close() service.activeClient = nil } + service.activeConfig = connectionConfig{} } func (session *clientSession) Close() { diff --git a/agents/drivers/zookeeper/main.go b/agents/drivers/zookeeper/main.go index 95ae868d89..add2f95c42 100644 --- a/agents/drivers/zookeeper/main.go +++ b/agents/drivers/zookeeper/main.go @@ -13,7 +13,7 @@ const ( maxRPCMessageBytes = 32 * 1024 * 1024 ) -var capabilities = []string{"connect", "test_connection", "kv"} +var capabilities = []string{"connect", "test_connection", "connection_info", "kv"} type rpcRequest struct { ID json.RawMessage `json:"id"` @@ -41,6 +41,7 @@ type handshakeResult struct { type server struct { activeClient znodeClient + activeConfig connectionConfig statLookupConcurrency int } @@ -104,6 +105,9 @@ func (service *server) dispatch(method string, params json.RawMessage) (any, boo case "test_connection": result, err := service.testConnection(params) return result, false, err + case "connection_info": + result, err := service.connectionInfo() + return result, false, err case "kv_list_prefix": result, err := service.listPrefix(params) return result, false, err diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index adff055160..472e659c7f 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -963,7 +963,7 @@ export default { configuredDescription: "Shows information known from the form first. A successful test adds server version and driver metadata.", testedDescription: "This information comes from the connection used by the latest successful test.", productName: "DBMS", - productVersion: "DBMS version", + productVersion: "Server version", currentDatabase: "Current database", serverComment: "Server comment", serverCharset: "Server charset", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index bedf02a0d0..a1e34ec653 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1008,7 +1008,7 @@ export default withEnglishFallback({ configuredDescription: "Muestra primero la información que se puede determinar desde el formulario; después de una prueba exitosa, se complementa con la versión devuelta por el servidor y los metadatos del controlador.", testedDescription: "La siguiente información proviene de la conexión utilizada para esta prueba exitosa.", productName: "Sistema de gestión de bases de datos", - productVersion: "Versión de la base de datos", + productVersion: "Versión del servidor", currentDatabase: "Base de datos actual", serverComment: "Comentario del servidor", serverCharset: "Conjunto de caracteres del servidor", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 86aae02bb7..2df7bab821 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1006,7 +1006,7 @@ export default withEnglishFallback({ configuredDescription: "Mostra prima le informazioni determinabili dal modulo; dopo un test riuscito, vengono aggiunte la versione restituita dal server e i metadati del driver.", testedDescription: "Le seguenti informazioni provengono dalla connessione utilizzata per questo test riuscito.", productName: "Sistema di gestione database", - productVersion: "Versione database", + productVersion: "Versione del server", currentDatabase: "Database corrente", serverComment: "Commento server", serverCharset: "Set di caratteri del server", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index cd8cb6bc75..ace333e12e 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1006,7 +1006,7 @@ export default withEnglishFallback({ configuredDescription: "まずフォームで確定可能な情報を表示します。テスト成功後、サーバーから返されたバージョンとドライバーのメタデータが補足されます。", testedDescription: "以下の情報は、今回の成功したテストで使用された接続からのものです。", productName: "データベース管理システム", - productVersion: "データベースバージョン", + productVersion: "サーバーバージョン", currentDatabase: "現在のデータベース", serverComment: "サーバーコメント", serverCharset: "サーバー文字セット", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 9eb7c62bee..2c5a62fe76 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -855,7 +855,7 @@ export default withEnglishFallback({ configuredDescription: "먼저 양식에서 알려진 정보를 표시합니다. 성공적인 테스트는 서버 버전과 드라이버 메타데이터를 추가합니다.", testedDescription: "이 정보는 가장 최근 성공한 테스트에 사용된 연결에서 가져왔습니다.", productName: "DBMS", - productVersion: "DBMS 버전", + productVersion: "서버 버전", currentDatabase: "현재 데이터베이스", serverComment: "서버 주석", serverCharset: "서버 문자셋", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 9b61079d59..ea0587bdd3 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1007,7 +1007,7 @@ export default withEnglishFallback({ configuredDescription: "Exibe as informações que podem ser determinadas no formulário; após um teste bem-sucedido, serão complementadas com a versão e os metadados do driver retornados pelo servidor.", testedDescription: "As informações abaixo são da conexão usada neste teste bem-sucedido.", productName: "Sistema de Gerenciamento de Banco de Dados", - productVersion: "Versão do banco de dados", + productVersion: "Versão do servidor", currentDatabase: "Banco de dados atual", serverComment: "Descrição do servidor", serverCharset: "Charset do servidor", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 06989553dc..95a22112e3 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -887,7 +887,7 @@ export default withEnglishFallback({ configuredDescription: "先显示表单中可确定的信息;测试成功后会补充服务器返回的版本和驱动元数据。", testedDescription: "以下信息来自本次成功测试使用的连接。", productName: "数据库管理系统", - productVersion: "数据库版本", + productVersion: "服务端版本", currentDatabase: "当前数据库", serverComment: "服务器说明", serverCharset: "服务器字符集", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 1d7aed0faa..0762f06c1a 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1006,7 +1006,7 @@ export default withEnglishFallback({ configuredDescription: "先顯示表單中可確定的資訊;測試成功後會補充伺服器回傳的版本和驅動元數據。", testedDescription: "以下資訊來自本次成功測試使用的連線。", productName: "資料庫管理系統", - productVersion: "資料庫版本", + productVersion: "伺服器版本", currentDatabase: "目前資料庫", serverComment: "伺服器說明", serverCharset: "伺服器字元集", diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 79c6ffd43f..bf387807e3 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -173,9 +173,21 @@ impl PoolKind { enum ConnectionDatabaseInfoSource { Agent(Arc), - ExternalDriver { config: Arc, session: Arc }, + MongoAgent(Arc, Option), + ExternalDriver { + config: Arc, + session: Arc, + }, NativeMysql(db::mysql::MySqlPool), NativeHBase(db::hbase_driver::HBaseClient), + NativeMongo(mongodb::Client, Option), + Meilisearch(db::meilisearch_driver::MeilisearchClient), + VictoriaMetrics(db::victoriametrics_driver::VictoriaMetricsClient), + Redis(String), + Nacos, + Consul(Box), + #[cfg(feature = "mq-admin")] + MessageQueue, } /// Held connection for a manual transaction session @@ -4275,6 +4287,9 @@ impl AppState { let source = { let connections = self.connections.read().await; match connections.get(&pool_key) { + Some(PoolKind::Agent(client)) if config.db_type == DatabaseType::MongoDb => { + Some(ConnectionDatabaseInfoSource::MongoAgent(client.clone(), database.map(str::to_string))) + } Some(PoolKind::Agent(client)) => Some(ConnectionDatabaseInfoSource::Agent(client.clone())), Some(PoolKind::ExternalDriver { config, session, .. }) => { Some(ConnectionDatabaseInfoSource::ExternalDriver { @@ -4284,6 +4299,18 @@ impl AppState { } Some(PoolKind::Mysql(pool, _)) => Some(ConnectionDatabaseInfoSource::NativeMysql(pool.clone())), Some(PoolKind::HBase(client)) => Some(ConnectionDatabaseInfoSource::NativeHBase(client.clone())), + Some(PoolKind::MongoDb(client)) => { + Some(ConnectionDatabaseInfoSource::NativeMongo(client.clone(), database.map(str::to_string))) + } + Some(PoolKind::Meilisearch(client)) => Some(ConnectionDatabaseInfoSource::Meilisearch(client.clone())), + Some(PoolKind::VictoriaMetrics(client)) => { + Some(ConnectionDatabaseInfoSource::VictoriaMetrics(client.clone())) + } + Some(PoolKind::Redis(_)) => Some(ConnectionDatabaseInfoSource::Redis(pool_key.clone())), + Some(PoolKind::Nacos) => Some(ConnectionDatabaseInfoSource::Nacos), + Some(PoolKind::Consul(client)) => Some(ConnectionDatabaseInfoSource::Consul(Box::new(client.clone()))), + #[cfg(feature = "mq-admin")] + Some(PoolKind::MessageQueue) => Some(ConnectionDatabaseInfoSource::MessageQueue), _ => None, } }; @@ -4293,6 +4320,17 @@ impl AppState { let mut agent = client.lock().await; Ok(agent.connection_info(Some(db::connection_timeout())).await?.database_info) } + Some(ConnectionDatabaseInfoSource::MongoAgent(client, database)) => { + let mut agent = client.lock().await; + let version = agent.mongo_server_version::(database.as_deref().unwrap_or("admin")).await?; + Ok(Some(DatabaseConnectionInfo { + product_name: Some("MongoDB".to_string()), + product_version: Some(version), + current_database: database, + driver_name: Some("MongoDB legacy Agent".to_string()), + ..Default::default() + })) + } Some(ConnectionDatabaseInfoSource::ExternalDriver { config, session }) => { let response = session .invoke_with_timeout::( @@ -4309,6 +4347,41 @@ impl AppState { Some(ConnectionDatabaseInfoSource::NativeHBase(client)) => { db::hbase_driver::database_connection_info(&client).await } + Some(ConnectionDatabaseInfoSource::NativeMongo(client, database)) => { + db::mongo_driver::database_connection_info(&client, database.as_deref()).await.map(Some) + } + Some(ConnectionDatabaseInfoSource::Meilisearch(client)) => { + db::meilisearch_driver::database_connection_info(&client).await.map(Some) + } + Some(ConnectionDatabaseInfoSource::VictoriaMetrics(client)) => { + db::victoriametrics_driver::database_connection_info(&client, db::connection_timeout()).await.map(Some) + } + Some(ConnectionDatabaseInfoSource::Redis(pool_key)) => { + let connections = self.connections.read().await; + match connections.get(&pool_key) { + Some(PoolKind::Redis(redis)) => db::redis_driver::database_connection_info(redis).await.map(Some), + _ => Ok(None), + } + } + Some(ConnectionDatabaseInfoSource::Nacos) => { + let admin_config = self.nacos_admin_config_for_connection(connection_id, &config).await?; + let admin = self.nacos_registry.get_or_build_config(connection_id, admin_config).await?; + Ok(crate::nacos::service::database_info_from_connection(&admin.test_connection().await?)) + } + Some(ConnectionDatabaseInfoSource::Consul(client)) => { + let identity = client.agent_self().await?; + Ok(Some(DatabaseConnectionInfo { + product_name: Some("Consul".to_string()), + product_version: identity.version, + server_comment: Some(format!("Agent {}", identity.node)), + driver_name: Some("Consul HTTP API".to_string()), + ..Default::default() + })) + } + #[cfg(feature = "mq-admin")] + Some(ConnectionDatabaseInfoSource::MessageQueue) => { + Ok(crate::mq::service::mq_database_connection_info(self, connection_id).await?) + } None => Ok(None), } } diff --git a/crates/dbx-core/src/db/meilisearch_driver.rs b/crates/dbx-core/src/db/meilisearch_driver.rs index dfff3d208e..b7d620d780 100644 --- a/crates/dbx-core/src/db/meilisearch_driver.rs +++ b/crates/dbx-core/src/db/meilisearch_driver.rs @@ -8,6 +8,7 @@ use std::time::{Duration, Instant}; use super::{http_client_builder, ColumnInfo}; use crate::db::document_result::DocumentQueryResult; +use crate::models::connection::DatabaseConnectionInfo; use crate::types::QueryResult; const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS @@ -178,6 +179,22 @@ pub async fn test_connection(client: &MeilisearchClient, _timeout: Duration) -> Ok(()) } +pub async fn database_connection_info(client: &MeilisearchClient) -> Result { + let response = + client.get("/version").send().await.map_err(|error| format!("Meilisearch request failed: {error}"))?; + let value = response_json(response, "version lookup").await?; + Ok(DatabaseConnectionInfo { + product_name: Some("Meilisearch".to_string()), + product_version: value + .get("pkgVersion") + .or_else(|| value.get("version")) + .and_then(Value::as_str) + .map(str::to_string), + driver_name: Some("Meilisearch HTTP API".to_string()), + ..Default::default() + }) +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct IndexInfoResponse { diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index 138e8dffd7..c997c70bb1 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use super::with_connection_timeout; use crate::document_ops::{MongoGridFsBucketInfo, MongoGridFsFileInfo}; +use crate::models::connection::DatabaseConnectionInfo; use crate::types::IndexInfo; use futures::{io::AsyncReadExt, io::AsyncWriteExt, TryStreamExt}; use percent_encoding::percent_decode_str; @@ -239,6 +240,20 @@ pub async fn server_version(client: &Client, database: &str) -> Result, +) -> Result { + let version = server_version(client, database.unwrap_or("admin")).await?; + Ok(DatabaseConnectionInfo { + product_name: Some("MongoDB".to_string()), + product_version: Some(version), + current_database: database.map(str::to_string), + driver_name: Some("MongoDB Rust driver".to_string()), + ..Default::default() + }) +} + pub async fn run_command(client: &Client, database: &str, command_json: &str) -> Result { let value: serde_json::Value = serde_json::from_str(command_json).map_err(|error| format!("Invalid MongoDB command JSON: {error}"))?; diff --git a/crates/dbx-core/src/db/redis_driver.rs b/crates/dbx-core/src/db/redis_driver.rs index 6913705727..49dbf5c18f 100644 --- a/crates/dbx-core/src/db/redis_driver.rs +++ b/crates/dbx-core/src/db/redis_driver.rs @@ -1,4 +1,4 @@ -use crate::models::connection::ConnectionConfig; +use crate::models::connection::{ConnectionConfig, DatabaseConnectionInfo}; use base64::Engine; use redis::{ aio::ConnectionLike, @@ -749,6 +749,41 @@ pub async fn test_connection(connection: &RedisConnection) -> Result<(), String> } } +/// Read the Redis server identity without making metadata unavailable when the +/// INFO command is disabled by ACL or server policy. +pub async fn database_connection_info(connection: &RedisConnection) -> Result { + match connection { + RedisConnection::Direct(con) => { + let mut con = con.lock().await; + redis_database_connection_info(&mut *con).await + } + RedisConnection::Cluster(pool) => { + let mut con = cluster_any_connection(pool).await?; + redis_database_connection_info(&mut con).await + } + } +} + +async fn redis_database_connection_info(con: &mut C) -> Result +where + C: ConnectionLike + Send + Sync + Unpin, +{ + let info = + tokio::time::timeout(super::connection_timeout(), redis::cmd("INFO").arg("server").query_async::(con)) + .await + .map_err(|_| format!("Redis INFO command timed out ({}s)", super::CONNECTION_TIMEOUT_SECS))? + .map_err(|error| format!("Redis INFO command failed: {error}"))?; + let product_version = info.lines().find_map(|line| { + let (key, value) = line.trim().split_once(':')?; + (key.eq_ignore_ascii_case("redis_version") && !value.trim().is_empty()).then(|| value.trim().to_string()) + }); + Ok(DatabaseConnectionInfo { + product_name: Some("Redis".to_string()), + product_version, + ..DatabaseConnectionInfo::default() + }) +} + async fn redis_ping(con: &mut C, label: &str) -> Result<(), String> where C: ConnectionLike + Send + Sync + Unpin, diff --git a/crates/dbx-core/src/db/victoriametrics_driver.rs b/crates/dbx-core/src/db/victoriametrics_driver.rs index 17c8446ff5..05639dfb84 100644 --- a/crates/dbx-core/src/db/victoriametrics_driver.rs +++ b/crates/dbx-core/src/db/victoriametrics_driver.rs @@ -8,6 +8,7 @@ use std::time::{Duration, Instant}; use super::with_connection_timeout; use crate::models::connection::ConnectionConfig; +use crate::models::connection::DatabaseConnectionInfo; use crate::types::{ColumnInfo, DatabaseInfo, ObjectStatistics, QueryResult, TableInfo}; const DEFAULT_API_PATH: &str = "/prometheus"; @@ -182,6 +183,24 @@ pub async fn test_connection(client: &VictoriaMetricsClient, timeout: Duration) parse_api_response::(response).await.map(|_| ()) } +pub async fn database_connection_info( + client: &VictoriaMetricsClient, + timeout: Duration, +) -> Result { + let request = client.request(client.http.get(client.endpoint("/api/v1/status/buildinfo"))); + let response = with_connection_timeout("VictoriaMetrics", timeout, async { + request.send().await.map_err(|error| format!("VictoriaMetrics connection failed: {error}")) + }) + .await?; + let value = parse_api_response::(response).await?; + Ok(DatabaseConnectionInfo { + product_name: Some("VictoriaMetrics".to_string()), + product_version: value.get("version").and_then(Value::as_str).map(str::to_string), + driver_name: Some("VictoriaMetrics HTTP API".to_string()), + ..Default::default() + }) +} + pub async fn list_databases(client: &VictoriaMetricsClient) -> Result, String> { Ok(vec![DatabaseInfo { name: client.database_label.clone(), ..Default::default() }]) } diff --git a/crates/dbx-core/src/mq/service.rs b/crates/dbx-core/src/mq/service.rs index 51602e3219..506284e93f 100644 --- a/crates/dbx-core/src/mq/service.rs +++ b/crates/dbx-core/src/mq/service.rs @@ -38,6 +38,27 @@ pub async fn mq_test_connection_core(state: &AppState, conn_id: &str) -> Result< } } +/// Convert the adapter's cluster probe into the common connection metadata +/// shown by the desktop and web connection views. +pub async fn mq_database_connection_info( + state: &AppState, + conn_id: &str, +) -> Result, String> { + let info = mq_test_connection_core(state, conn_id).await?; + let product_name = match info.system_kind { + MqSystemKind::Pulsar => "Pulsar", + MqSystemKind::Kafka => "Kafka", + MqSystemKind::RocketMq => "RocketMQ", + MqSystemKind::RabbitMq => "RabbitMQ", + }; + Ok(Some(crate::models::connection::DatabaseConnectionInfo { + product_name: Some(product_name.to_string()), + product_version: info.server_version, + driver_name: Some("Message Queue Admin API".to_string()), + ..Default::default() + })) +} + // ---- Tenants ---- pub async fn mq_list_tenants_core(state: &AppState, conn_id: &str) -> Result, String> { diff --git a/crates/dbx-core/src/nacos/service.rs b/crates/dbx-core/src/nacos/service.rs index eb2a082eae..ca85f663ba 100644 --- a/crates/dbx-core/src/nacos/service.rs +++ b/crates/dbx-core/src/nacos/service.rs @@ -1,9 +1,18 @@ use std::future::Future; use crate::connection::AppState; -use crate::models::connection::DatabaseType; +use crate::models::connection::{DatabaseConnectionInfo, DatabaseType}; use crate::nacos::types::*; +pub fn database_info_from_connection(info: &NacosConnectionInfo) -> Option { + let product_version = info.server_version.clone().filter(|value| !value.trim().is_empty()); + Some(DatabaseConnectionInfo { + product_name: Some("Nacos".to_string()), + product_version, + ..DatabaseConnectionInfo::default() + }) +} + async fn refresh_access_control_after_mutation(admin: &std::sync::Arc) { admin.invalidate_access_control_capabilities(); let _ = admin.refresh_access_control_capabilities().await; diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index 824da696de..7822d099a2 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -260,11 +260,15 @@ async fn run_temporary_connection_test( ) -> Result { let temp_id = format!("{TEST_PROBE_ID_PREFIX}{}", uuid::Uuid::new_v4()); app.configs.write().await.insert(temp_id.clone(), config.clone()); + let mut nacos_database_info = None; let pool_result = if config.db_type == DatabaseType::Nacos { match app.nacos_admin_config_for_connection(&temp_id, &config).await { Ok(admin_config) => match app.nacos_registry.build_transient_config(admin_config).await { - Ok(adapter) => adapter.test_connection_with_scope_validation().await.map(|_| temp_id.clone()), + Ok(adapter) => adapter.test_connection_with_scope_validation().await.map(|info| { + nacos_database_info = dbx_core::nacos::service::database_info_from_connection(&info); + temp_id.clone() + }), Err(error) => Err(error), }, Err(error) => Err(error), @@ -272,7 +276,9 @@ async fn run_temporary_connection_test( } else { app.get_or_create_pool(&temp_id, config.database.as_deref()).await }; - let database_info = if include_database_info && config.db_type != DatabaseType::Nacos { + let database_info = if include_database_info && config.db_type == DatabaseType::Nacos { + nacos_database_info + } else if include_database_info { match &pool_result { Ok(_) => match app.connection_database_info(&temp_id, config.database.as_deref()).await { Ok(info) => info, diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 4612519786..b623b5beee 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -1024,17 +1024,30 @@ async fn test_redis_connection( host: &str, port: u16, connect_timeout: std::time::Duration, -) -> Result { +) -> Result, String> { // Connection tests must exercise the same Redis lifecycle as a saved connection, // including compatibility auth, TLS, and database selection. if config.uses_redis_cluster() { - drop(state.connect_redis_cluster(tunnel_id, config).await?); - } else if config.uses_redis_sentinel() { - drop(state.connect_redis_sentinel(tunnel_id, config).await?); - } else { - drop(db::redis_driver::connect_standalone(config, host, port, connect_timeout).await?); + let connection = + db::redis_driver::RedisConnection::Cluster(state.connect_redis_cluster(tunnel_id, config).await?); + let info = db::redis_driver::database_connection_info(&connection).await.ok(); + drop(connection); + return Ok(info); } - Ok("Connection successful".to_string()) + if config.uses_redis_sentinel() { + let connection = db::redis_driver::RedisConnection::Direct(tokio::sync::Mutex::new( + state.connect_redis_sentinel(tunnel_id, config).await?, + )); + let info = db::redis_driver::database_connection_info(&connection).await.ok(); + drop(connection); + return Ok(info); + } + let connection = db::redis_driver::RedisConnection::Direct(tokio::sync::Mutex::new( + db::redis_driver::connect_standalone(config, host, port, connect_timeout).await?, + )); + let info = db::redis_driver::database_connection_info(&connection).await.ok(); + drop(connection); + Ok(info) } #[tauri::command] @@ -1173,7 +1186,13 @@ async fn test_connection_with_info_inner( DatabaseType::Redis => { // Keep the result inside the outer lifecycle so temporary transports // are reset after both successful and failed Redis tests. - test_redis_connection(state, &tunnel_id, &config, &host, port, connect_timeout).await + match test_redis_connection(state, &tunnel_id, &config, &host, port, connect_timeout).await { + Ok(info) => { + database_info = info; + Ok("Connection successful".to_string()) + } + Err(error) => Err(error), + } } #[cfg(feature = "duckdb-sidecar")] DatabaseType::DuckDb => { @@ -1191,8 +1210,19 @@ async fn test_connection_with_info_inner( .connect(mongo_legacy_connect_params(&config, &host, port)?) .await .map_err(|err| mongo_legacy_error_with_auth_hint(&err))?; + let version = client + .mongo_server_version::(config.effective_database().unwrap_or("admin")) + .await + .ok(); client.disconnect().await.ok(); - return Ok(ConnectionTestResult::success("Connection successful (via legacy driver)")); + return Ok(ConnectionTestResult::success("Connection successful (via legacy driver)") + .with_database_info(version.map(|product_version| DatabaseConnectionInfo { + product_name: Some("MongoDB".to_string()), + product_version: Some(product_version), + current_database: config.effective_database().map(str::to_string), + driver_name: Some("MongoDB legacy Agent".to_string()), + ..Default::default() + }))); } let native_err = match db::mongo_driver::connect_with_oidc( @@ -1212,7 +1242,15 @@ async fn test_connection_with_info_inner( ) .await { - Ok(()) => return Ok(ConnectionTestResult::success("Connection successful")), + Ok(()) => { + let info = + db::mongo_driver::database_connection_info(&client, config.effective_database()) + .await + .ok(); + return Ok( + ConnectionTestResult::success("Connection successful").with_database_info(info) + ); + } Err(e) => e, } } @@ -1228,7 +1266,18 @@ async fn test_connection_with_info_inner( &mongo_legacy_error_with_auth_hint(&err), ) })?; + let version = client + .mongo_server_version::(config.effective_database().unwrap_or("admin")) + .await + .ok(); client.disconnect().await.ok(); + database_info = version.map(|product_version| DatabaseConnectionInfo { + product_name: Some("MongoDB".to_string()), + product_version: Some(product_version), + current_database: config.effective_database().map(str::to_string), + driver_name: Some("MongoDB legacy Agent".to_string()), + ..Default::default() + }); Ok("Connection successful (via legacy driver)".to_string()) } else { Err(native_err) @@ -1301,9 +1350,9 @@ async fn test_connection_with_info_inner( config.external_config.as_ref(), connect_timeout, )?; - db::meilisearch_driver::test_connection(&client, connect_timeout) - .await - .map(|_| "Connection successful".to_string()) + db::meilisearch_driver::test_connection(&client, connect_timeout).await?; + database_info = db::meilisearch_driver::database_connection_info(&client).await.ok(); + Ok("Connection successful".to_string()) } DatabaseType::Hbase => { let client = db::hbase_driver::HBaseClient::new( @@ -1313,9 +1362,9 @@ async fn test_connection_with_info_inner( false, connect_timeout, )?; - db::hbase_driver::test_connection(&client, connect_timeout) - .await - .map(|_| "Connection successful".to_string()) + db::hbase_driver::test_connection(&client, connect_timeout).await?; + database_info = db::hbase_driver::database_connection_info(&client).await.ok().flatten(); + Ok("Connection successful".to_string()) } DatabaseType::Qdrant | DatabaseType::Milvus | DatabaseType::Weaviate | DatabaseType::ChromaDb => { let kind = match config.db_type { @@ -1388,14 +1437,16 @@ async fn test_connection_with_info_inner( DatabaseType::VictoriaMetrics => { let client = db::victoriametrics_driver::VictoriaMetricsClient::new_for_config(&url, &config, connect_timeout)?; - db::victoriametrics_driver::test_connection(&client, connect_timeout) - .await - .map(|_| "Connection successful".to_string()) + db::victoriametrics_driver::test_connection(&client, connect_timeout).await?; + database_info = + db::victoriametrics_driver::database_connection_info(&client, connect_timeout).await.ok(); + Ok("Connection successful".to_string()) } DatabaseType::Nacos => { let admin_config = state.nacos_admin_config_for_connection(connection_id, &config).await?; let adapter = state.nacos_registry.build_transient_config(admin_config).await?; - adapter.test_connection_with_scope_validation().await?; + let info = adapter.test_connection_with_scope_validation().await?; + database_info = dbx_core::nacos::service::database_info_from_connection(&info); Ok("Connection successful".to_string()) } DatabaseType::Consul => { @@ -1413,6 +1464,13 @@ async fn test_connection_with_info_inner( } else { client.agent_self().await.ok() }; + database_info = identity.as_ref().map(|identity| DatabaseConnectionInfo { + product_name: Some("Consul".to_string()), + product_version: identity.version.clone(), + server_comment: Some(format!("Agent {}", identity.node)), + driver_name: Some("Consul HTTP API".to_string()), + ..Default::default() + }); Ok(identity .map(|identity| format!("Connection successful (Agent: {} at {})", identity.node, identity.address)) .unwrap_or_else(|| { @@ -1426,7 +1484,21 @@ async fn test_connection_with_info_inner( let mqc = state.mq_admin_config_for_connection(connection_id, &config).await?; let agent_launch = dbx_core::mq::service::resolve_mq_agent_launch_spec(&mqc, state); let adapter = state.mq_registry.build_transient_config(mqc, agent_launch).await?; - adapter.test_connection().await?; + let info = adapter.test_connection().await?; + database_info = Some(DatabaseConnectionInfo { + product_name: Some( + match info.system_kind { + dbx_core::mq::types::MqSystemKind::Pulsar => "Pulsar", + dbx_core::mq::types::MqSystemKind::Kafka => "Kafka", + dbx_core::mq::types::MqSystemKind::RocketMq => "RocketMQ", + dbx_core::mq::types::MqSystemKind::RabbitMq => "RabbitMQ", + } + .to_string(), + ), + product_version: info.server_version, + driver_name: Some("Message Queue Admin API".to_string()), + ..Default::default() + }); Ok("Connection successful".to_string()) } #[cfg(not(feature = "mq-admin"))]