diff --git a/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java b/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java index 6a0a0eed6..d48c0fc98 100644 --- a/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java +++ b/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java @@ -9,7 +9,9 @@ import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.Types; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; public abstract class AbstractJdbcAgent extends BaseDatabaseAgent { private Connection connection; @@ -42,14 +44,33 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent { @Override public final boolean testConnection(ConnectParams params) { + return Boolean.TRUE.equals(testConnectionWithInfo(params).get("ok")); + } + + @Override + public final Map testConnectionWithInfo(ConnectParams params) { return unchecked(() -> { loadDriver(params); try (Connection conn = openConnection(params)) { - return conn.isValid(5); + boolean valid = conn.isValid(5); + Map result = new LinkedHashMap<>(); + result.put("ok", valid); + if (valid) { + Map databaseInfo = JdbcDatabaseInfo.from(conn); + if (!databaseInfo.isEmpty()) { + result.put("databaseInfo", databaseInfo); + } + } + return result; } }); } + @Override + public final Map getDatabaseInfo() { + return JdbcDatabaseInfo.from(getConnection()); + } + private void loadDriver(ConnectParams params) throws Exception { List driverPaths = params.getJdbc_driver_paths(); String driverClass = params.getJdbc_driver_class(); diff --git a/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java b/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java index be2233dbb..d33011cba 100644 --- a/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java +++ b/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java @@ -4,7 +4,9 @@ import java.sql.Connection; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -13,6 +15,16 @@ public interface DatabaseAgent { boolean testConnection(ConnectParams params); + default Map testConnectionWithInfo(ConnectParams params) { + Map result = new LinkedHashMap<>(); + result.put("ok", testConnection(params)); + return result; + } + + default Map getDatabaseInfo() { + return Collections.emptyMap(); + } + default String getIdentifierQuote() { return ""; } diff --git a/agents/common/src/main/java/com/dbx/agent/JdbcDatabaseInfo.java b/agents/common/src/main/java/com/dbx/agent/JdbcDatabaseInfo.java new file mode 100644 index 000000000..bdb794af6 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/JdbcDatabaseInfo.java @@ -0,0 +1,95 @@ +package com.dbx.agent; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +final class JdbcDatabaseInfo { + private JdbcDatabaseInfo() { + } + + static Map from(Connection connection) { + if (connection == null) { + return Collections.emptyMap(); + } + + final DatabaseMetaData metadata; + try { + metadata = connection.getMetaData(); + } catch (SQLException | AbstractMethodError | UnsupportedOperationException ignored) { + return Collections.emptyMap(); + } + if (metadata == null) { + return Collections.emptyMap(); + } + + Map info = new LinkedHashMap<>(); + putText(info, "productName", () -> metadata.getDatabaseProductName()); + putText(info, "productVersion", () -> metadata.getDatabaseProductVersion()); + putIdentifierCase( + info, + "unquotedIdentifierCase", + () -> metadata.storesLowerCaseIdentifiers(), + () -> metadata.storesUpperCaseIdentifiers(), + () -> metadata.storesMixedCaseIdentifiers() + ); + putIdentifierCase( + info, + "quotedIdentifierCase", + () -> metadata.storesLowerCaseQuotedIdentifiers(), + () -> metadata.storesUpperCaseQuotedIdentifiers(), + () -> metadata.storesMixedCaseQuotedIdentifiers() + ); + putText(info, "driverName", () -> metadata.getDriverName()); + putText(info, "driverVersion", () -> metadata.getDriverVersion()); + + Integer jdbcMajor = readInteger(() -> metadata.getJDBCMajorVersion()); + Integer jdbcMinor = readInteger(() -> metadata.getJDBCMinorVersion()); + if (jdbcMajor != null && jdbcMinor != null && jdbcMajor >= 0 && jdbcMinor >= 0) { + info.put("jdbcVersion", jdbcMajor + "." + jdbcMinor); + } + return info; + } + + private static void putText(Map target, String key, SqlSupplier supplier) { + String value = read(supplier); + if (value != null && !value.trim().isEmpty()) { + target.put(key, value.trim()); + } + } + + private static void putIdentifierCase( + Map target, + String key, + SqlSupplier lower, + SqlSupplier upper, + SqlSupplier mixed + ) { + if (Boolean.TRUE.equals(read(lower))) { + target.put(key, "lower"); + } else if (Boolean.TRUE.equals(read(upper))) { + target.put(key, "upper"); + } else if (Boolean.TRUE.equals(read(mixed))) { + target.put(key, "mixed"); + } + } + + private static Integer readInteger(SqlSupplier supplier) { + return read(supplier); + } + + private static T read(SqlSupplier supplier) { + try { + return supplier.get(); + } catch (SQLException | AbstractMethodError | UnsupportedOperationException ignored) { + return null; + } + } + + private interface SqlSupplier { + T get() throws SQLException; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java b/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java index d93de4547..c7e2e1c96 100644 --- a/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java +++ b/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java @@ -17,7 +17,9 @@ import java.math.BigDecimal; import java.math.BigInteger; import java.sql.Connection; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; public final class JsonRpcServer { private static final long CONNECTION_VALIDATION_INTERVAL_MILLIS = 5_000L; @@ -105,10 +107,11 @@ public final class JsonRpcServer { return Collections.singletonMap("ok", true); } if (AgentProtocol.METHOD_TEST_CONNECTION.equals(method)) { - if (!agent.testConnection(gson.fromJson(params, ConnectParams.class))) { + Map result = agent.testConnectionWithInfo(gson.fromJson(params, ConnectParams.class)); + if (!Boolean.TRUE.equals(result.get("ok"))) { throw new RuntimeException("Connection failed"); } - return Collections.singletonMap("ok", true); + return result; } if (AgentProtocol.METHOD_VALIDATE_CONNECTION.equals(method)) { Connection conn = agent.getConnection(); @@ -126,7 +129,13 @@ public final class JsonRpcServer { } ensureLiveConnection(method); if (AgentProtocol.METHOD_CONNECTION_INFO.equals(method)) { - return Collections.singletonMap("identifierQuote", agent.getIdentifierQuote()); + Map result = new LinkedHashMap<>(); + result.put("identifierQuote", agent.getIdentifierQuote()); + Map databaseInfo = agent.getDatabaseInfo(); + if (databaseInfo != null && !databaseInfo.isEmpty()) { + result.put("databaseInfo", databaseInfo); + } + return result; } if (AgentProtocol.METHOD_LIST_DATABASES.equals(method)) { return agent.listDatabases(); diff --git a/agents/common/src/test/java/com/dbx/agent/AbstractJdbcAgentTest.java b/agents/common/src/test/java/com/dbx/agent/AbstractJdbcAgentTest.java index 2043c6388..fbd677ef4 100644 --- a/agents/common/src/test/java/com/dbx/agent/AbstractJdbcAgentTest.java +++ b/agents/common/src/test/java/com/dbx/agent/AbstractJdbcAgentTest.java @@ -14,6 +14,7 @@ import java.sql.Types; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.Map; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -56,6 +57,47 @@ class AbstractJdbcAgentTest { assertEquals(1, tracking.closeCount); } + @Test + void databaseInfoKeepsSupportedFieldsWhenOneMetadataGetterFails() { + DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> { + switch (method.getName()) { + case "getDatabaseProductName": + return "ExampleDB"; + case "getDatabaseProductVersion": + throw new UnsupportedOperationException("version unavailable"); + case "storesLowerCaseIdentifiers": + throw new UnsupportedOperationException("case unavailable"); + case "storesUpperCaseIdentifiers": + return true; + case "storesMixedCaseQuotedIdentifiers": + return true; + case "getDriverName": + return "Example JDBC"; + case "getDriverVersion": + return "1.2.3"; + case "getJDBCMajorVersion": + return 4; + case "getJDBCMinorVersion": + return 2; + default: + return defaultValue(method.getReturnType()); + } + }); + Connection connection = proxy(Connection.class, (method, args) -> + "getMetaData".equals(method.getName()) ? metadata : defaultValue(method.getReturnType()) + ); + + Map info = JdbcDatabaseInfo.from(connection); + + assertEquals("ExampleDB", info.get("productName")); + assertFalse(info.containsKey("productVersion")); + assertEquals("upper", info.get("unquotedIdentifierCase")); + assertEquals("mixed", info.get("quotedIdentifierCase")); + assertEquals("Example JDBC", info.get("driverName")); + assertEquals("1.2.3", info.get("driverVersion")); + assertEquals("4.2", info.get("jdbcVersion")); + } + @Test void delegatesQueryExecutionWithSchemaAndValueReader() { TrackingConnection tracking = new TrackingConnection(); diff --git a/agents/common/src/test/java/com/dbx/agent/CommonJavaCompatibilityTest.java b/agents/common/src/test/java/com/dbx/agent/CommonJavaCompatibilityTest.java index b3ce2acd3..19c0a7168 100644 --- a/agents/common/src/test/java/com/dbx/agent/CommonJavaCompatibilityTest.java +++ b/agents/common/src/test/java/com/dbx/agent/CommonJavaCompatibilityTest.java @@ -17,7 +17,9 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -83,6 +85,49 @@ class CommonJavaCompatibilityTest { assertTrue(containsCapability(result.getAsJsonArray("capabilities"), "metadata")); } + @Test + void jsonRpcConnectionTestAddsOptionalDatabaseInfoWithoutChangingLegacySuccess() { + JsonRpcServer legacyServer = new JsonRpcServer(new MinimalAgent()); + JsonObject legacyResult = JsonParser.parseString(legacyServer.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"test_connection\",\"params\":{}}" + )).getAsJsonObject().getAsJsonObject("result"); + assertTrue(legacyResult.get("ok").getAsBoolean()); + assertFalse(legacyResult.has("databaseInfo")); + + JsonRpcServer detailedServer = new JsonRpcServer(new MinimalAgent() { + @Override + public Map testConnectionWithInfo(ConnectParams params) { + Map result = new LinkedHashMap<>(); + result.put("ok", true); + result.put("databaseInfo", Collections.singletonMap("productName", "ExampleDB")); + return result; + } + }); + JsonObject detailedResult = JsonParser.parseString(detailedServer.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"test_connection\",\"params\":{}}" + )).getAsJsonObject().getAsJsonObject("result"); + assertEquals("ExampleDB", detailedResult.getAsJsonObject("databaseInfo").get("productName").getAsString()); + } + + @Test + void multiSessionConnectionTestDelegatesOptionalDatabaseInfo() { + MultiSessionJsonRpcServer server = new MultiSessionJsonRpcServer(() -> new MinimalAgent() { + @Override + public Map testConnectionWithInfo(ConnectParams params) { + Map result = new LinkedHashMap<>(); + result.put("ok", true); + result.put("databaseInfo", Collections.singletonMap("driverName", "Example JDBC")); + return result; + } + }); + + JsonObject result = JsonParser.parseString(server.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"test_connection\",\"params\":{}}" + )).getAsJsonObject().getAsJsonObject("result"); + + assertEquals("Example JDBC", result.getAsJsonObject("databaseInfo").get("driverName").getAsString()); + } + @Test void multiSessionServerCreatesAndClosesIndependentAgents() { java.util.List created = new java.util.ArrayList<>(); diff --git a/agents/drivers/h2/src/test/java/com/dbx/agent/h2/H2AgentTest.java b/agents/drivers/h2/src/test/java/com/dbx/agent/h2/H2AgentTest.java index 350b1688b..986fe0689 100644 --- a/agents/drivers/h2/src/test/java/com/dbx/agent/h2/H2AgentTest.java +++ b/agents/drivers/h2/src/test/java/com/dbx/agent/h2/H2AgentTest.java @@ -10,6 +10,7 @@ import com.dbx.agent.TableInfo; import com.dbx.agent.test.JdbcExecutionBehaviorTest; import com.dbx.agent.test.JdbcMetadataBehaviorTest; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -41,6 +42,35 @@ class H2AgentMigrationTest { Assertions.assertEquals("jdbc:h2:tcp://127.0.0.1:9092/test", H2Agent.buildUrl(params)); } + + @Test + @SuppressWarnings("unchecked") + void exposesDatabaseMetadataForTestAndConnectedConnection() { + ConnectParams params = new ConnectParams("", 0, "mem:dbx-agent-info;DB_CLOSE_DELAY=-1", "sa", "", "", "", false); + H2Agent agent = new H2Agent(); + + Map result = agent.testConnectionWithInfo(params); + Assertions.assertEquals(true, result.get("ok")); + Map testedInfo = (Map) result.get("databaseInfo"); + assertH2DatabaseInfo(testedInfo); + + agent.connect(params); + try { + assertH2DatabaseInfo(agent.getDatabaseInfo()); + } finally { + agent.disconnect(); + } + } + + private static void assertH2DatabaseInfo(Map info) { + Assertions.assertEquals("H2", info.get("productName")); + Assertions.assertFalse(info.get("productVersion").isEmpty()); + Assertions.assertFalse(info.get("driverName").isEmpty()); + Assertions.assertFalse(info.get("driverVersion").isEmpty()); + Assertions.assertFalse(info.get("jdbcVersion").isEmpty()); + Assertions.assertEquals("upper", info.get("unquotedIdentifierCase")); + Assertions.assertFalse(info.containsKey("quotedIdentifierCase")); + } } class H2ExecutionBehaviorTest extends JdbcExecutionBehaviorTest { diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index c0492a64f..26b5c2aa5 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -13,7 +13,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Switch } from "@/components/ui/switch"; -import type { ConnectionConfig, DatabaseType, HttpTunnelConfig, JdbcDriverInfo, JdbcLocalBundleInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshConfigHostEntry, SshTunnelConfig, TransportLayerConfig } from "@/types/database"; +import type { ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, DatabaseType, HttpTunnelConfig, IdentifierCase, JdbcDriverInfo, JdbcLocalBundleInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshConfigHostEntry, SshTunnelConfig, TransportLayerConfig } from "@/types/database"; import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb"; import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq"; import type { NacosAdminConfig, NacosAuthConfig } from "@/types/nacos"; @@ -37,6 +37,7 @@ import { MQ_PINNED_VERSION_OPTIONS, pinnedVersionToSelection, selectionToPinnedV import { mongodbAuthFailureHint, mongoUrlParam, mongoUrlParamIsTrue, normalizeMongoTlsFormState, setMongoUrlParam, setMongoUrlParamBoolean } from "@/lib/mongo/mongoConnectionOptions"; import { mysqlCleartextPasswordAuthEnabled, setMysqlCleartextPasswordAuthEnabled } from "@/lib/database/mysqlConnectionOptions"; import { copyToClipboard } from "@/lib/common/clipboard"; +import { configuredDatabaseProductName, connectionConfigFingerprint, databaseInfoCopyText, databaseInfoRows, normalizeDatabaseConnectionInfo, type DatabaseInfoField } from "@/lib/connection/connectionDatabaseInfo"; import { agentDriverInstallKey, appendAgentDriverUpdateHint, hasAgentDriverUpdate, showAgentDriverInstallHint, type AgentDriverInstallState, type DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint"; import { prestoSqlBuiltinDriverPaths } from "@/lib/database/prestoSqlBuiltinDriver"; import { SQLITE_DATABASE_FILE_EXTENSIONS } from "@/lib/database/databaseFileDetection"; @@ -46,7 +47,35 @@ import { normalizeKafkaBootstrapServers } from "@/lib/connection/kafkaBootstrapS import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth"; import { driverInstallProgressPercent, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi"; import { isSqlServerLegacyCompatibilityMode, requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityMode, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility"; -import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pencil, Pipette, Plus, RefreshCw, Search, ShieldAlert, ShieldCheck, Square, Trash2 } from "@lucide/vue"; +import { + ArrowLeft, + ArrowDown, + ArrowUp, + CheckSquare, + ChevronRight, + CircleHelp, + Copy, + Database as DatabaseLucide, + ExternalLink, + FilePlus2, + FolderOpen, + GripVertical, + Grid3X3, + KeyRound, + Link2, + List, + ListFilter, + Loader2, + Pencil, + Pipette, + Plus, + RefreshCw, + Search, + ShieldAlert, + ShieldCheck, + Square, + Trash2, +} from "@lucide/vue"; import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleDatabaseSelectionIsStale } from "@/lib/connection/connectionVisibleDatabases"; import { canSaveVisibleDatabaseSelection, connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, isSystemDatabaseName, normalizeVisibleDatabaseSelection, buildDraftVisibleSchemasConnectionId, normalizeVisibleSchemaSelection } from "@/lib/database/visibleDatabases"; import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport"; @@ -101,6 +130,8 @@ type LegacyTransportFields = { }; type LegacyConnectionConfig = ConnectionConfig & LegacyTransportFields; type ConnectionForm = Omit; +type ConnectionTestState = ConnectionTestResult & { ok: boolean }; +type SuccessfulConnectionTest = { result: ConnectionTestResult; config: ConnectionConfig }; const { t } = useI18n(); const { toast } = useToast(); @@ -126,7 +157,13 @@ const store = useConnectionStore(); const tunnelProfileStore = useTunnelProfileStore(); const isTesting = ref(false); const isSaving = ref(false); -const testResult = ref<{ ok: boolean; message: string } | null>(null); +const testResult = ref(null); +const testedConfigFingerprint = ref(""); +const testedConfigId = ref(""); +const testedGeneratedName = ref(""); +const savedDatabaseInfo = ref(null); +const savedDatabaseInfoFingerprint = ref(""); +const savedConnectionConfigFingerprint = ref(""); const showAgentInstallDialog = ref(false); const agentInstallRunning = ref(false); const agentInstallDriverKey = ref(""); @@ -1038,7 +1075,7 @@ function isNacosAdminEndpointNotFound(message: string): boolean { return /Nacos admin endpoint was not found/i.test(message); } -async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalError: string, runId: number): Promise { +async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalError: string, runId: number): Promise { if (config.db_type !== "nacos" || !isNacosAdminEndpointNotFound(originalError)) return null; const fallbackUrl = dockerNacosConsoleFallbackUrl(nacosServerAddr.value); if (!fallbackUrl || fallbackUrl === nacosServerAddr.value.trim()) return null; @@ -1046,9 +1083,15 @@ async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalE const previousUrl = nacosServerAddr.value; nacosServerAddr.value = fallbackUrl; try { - const fallbackConfig = connectionConfigForSubmit(config.id); - const message = await testConnectionWithTimeout(fallbackConfig, runId); - return `${message} ${t("connection.nacosConsoleUrlAutoAdjusted", { from: previousUrl.trim(), to: fallbackUrl })}`; + const fallbackConfig = connectionConfigForSubmit(config.id, config.name); + const result = await testConnectionWithTimeout(fallbackConfig, runId); + return { + config: fallbackConfig, + result: { + ...result, + message: `${result.message} ${t("connection.nacosConsoleUrlAutoAdjusted", { from: previousUrl.trim(), to: fallbackUrl })}`, + }, + }; } catch { nacosServerAddr.value = previousUrl; return null; @@ -1199,15 +1242,63 @@ function isSqlServerTlsHandshakeFailure(message: string): boolean { return text.includes("sql server") && text.includes("tls") && (text.includes("handshake") || text.includes("eof") || text.includes("performing i/o")); } -async function testConnectionWithTimeout(config: ConnectionConfig, runId: number): Promise { +function clearTestedConnectionInfo() { + testedConfigFingerprint.value = ""; + testedConfigId.value = ""; + testedGeneratedName.value = ""; +} + +function clearSavedDatabaseInfo() { + savedDatabaseInfo.value = null; + savedDatabaseInfoFingerprint.value = ""; + savedConnectionConfigFingerprint.value = ""; +} + +function applySavedDatabaseInfo(config: ConnectionConfig) { + clearSavedDatabaseInfo(); + try { + const current = connectionConfigForSubmit(config.id, config.name); + savedConnectionConfigFingerprint.value = connectionConfigFingerprint(current, form.value.name); + const info = normalizeDatabaseConnectionInfo(config.database_info); + if (info) { + savedDatabaseInfo.value = info; + savedDatabaseInfoFingerprint.value = savedConnectionConfigFingerprint.value; + } + } catch { + clearSavedDatabaseInfo(); + } +} + +function applySuccessfulConnectionTest(result: ConnectionTestResult, config: ConnectionConfig, sourceName: string) { + testResult.value = { ok: true, ...result }; + testedConfigFingerprint.value = connectionConfigFingerprint(config, sourceName); + testedConfigId.value = config.id; + testedGeneratedName.value = config.name; +} + +async function persistSuccessfulConnectionTest(result: ConnectionTestResult, config: ConnectionConfig, sourceName: string) { + if (!editingId.value || !result.databaseInfo || !savedConnectionConfigFingerprint.value) return; + const fingerprint = connectionConfigFingerprint(config, sourceName); + if (fingerprint !== savedConnectionConfigFingerprint.value) return; + try { + await store.updateConnectionDatabaseInfo(editingId.value, result.databaseInfo); + savedDatabaseInfo.value = { ...result.databaseInfo }; + savedDatabaseInfoFingerprint.value = fingerprint; + } catch { + // The successful test remains valid even when optional metadata persistence fails. + } +} + +async function testConnectionWithTimeout(config: ConnectionConfig, runId: number): Promise { const timeoutMs = connectionAttemptTimeoutMs(config); const timeoutMessage = connectionAttemptTimeoutMessage(timeoutMs); - const promise = api.testConnection(config); + const promise = api.testConnectionWithInfo(config); let timedOut = false; let timer: ReturnType | undefined; void promise.catch((error) => { if (!timedOut) return; if (runId !== testRunId) return; + clearTestedConnectionInfo(); testResult.value = { ok: false, message: connectionErrorWithDriverUpdateHint(config, connectionAttemptOriginalErrorMessage(timeoutMessage, errorMessage(error))), @@ -1216,7 +1307,7 @@ async function testConnectionWithTimeout(config: ConnectionConfig, runId: number try { return await Promise.race([ promise, - new Promise((_, reject) => { + new Promise((_, reject) => { timer = setTimeout(() => { timedOut = true; reject(new Error(timeoutMessage)); @@ -1436,9 +1527,11 @@ function switchGbaseProfile(profile: "gbase8a" | "gbase8s") { } watch( - () => props.editConfig, - (config) => { + [() => props.editConfig, open], + ([config, isOpen]) => { + if (!isOpen) return; if (config) { + clearSavedDatabaseInfo(); const legacyConfig = config as LegacyConnectionConfig; const profile = profileForConfig(config); const oceanbaseMode = profile === "oceanbase" ? oceanbaseSubModeFromConfig(config) : "mysql"; @@ -1528,7 +1621,14 @@ watch( customDriverName.value = isCustomCompatibleProfile() ? config.driver_label || "" : ""; dialogStep.value = "config"; configTab.value = initialConfigTab(); + // Form/profile watchers normalize derived fields in this flush. Capture + // the saved baseline afterwards so those initial changes are not treated + // as user edits that invalidate persisted database metadata. + void nextTick(() => { + if (open.value && props.editConfig?.id === config.id) applySavedDatabaseInfo(config); + }); } else { + clearSavedDatabaseInfo(); editingId.value = null; form.value = defaultForm(); productionProtectionEnabled.value = false; @@ -2050,6 +2150,71 @@ const visibleObjectSelectedCountKey = computed(() => (visibleFilterUsesSchemas.v const visibleObjectEmptySelectionKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.emptySelection" : "visibleDatabases.emptySelection")); const visibleObjectLoadFailedKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.loadFailed" : "visibleDatabases.loadFailed")); const visibleObjectSaveKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.save" : "visibleDatabases.save")); +const databaseInfoLabelKeys: Record = { + productName: "connection.databaseInfo.productName", + productVersion: "connection.databaseInfo.productVersion", + currentDatabase: "connection.databaseInfo.currentDatabase", + serverComment: "connection.databaseInfo.serverComment", + serverCharset: "connection.databaseInfo.serverCharset", + serverCollation: "connection.databaseInfo.serverCollation", + unquotedIdentifierCase: "connection.databaseInfo.unquotedIdentifierCase", + quotedIdentifierCase: "connection.databaseInfo.quotedIdentifierCase", + driverName: "connection.databaseInfo.driverName", + driverVersion: "connection.databaseInfo.driverVersion", + jdbcVersion: "connection.databaseInfo.jdbcVersion", +}; +function databaseInfoFieldLabel(field: DatabaseInfoField): string { + return t(databaseInfoLabelKeys[field]); +} +function databaseIdentifierCaseLabel(value: IdentifierCase): string { + return t(`connection.databaseInfo.identifierCase.${value}`); +} +const visibleTestDatabaseInfo = computed(() => { + const result = testResult.value; + if (!result?.ok || !result.databaseInfo || !testedConfigFingerprint.value || !testedConfigId.value) return null; + try { + const current = connectionConfigForSubmit(testedConfigId.value, testedGeneratedName.value); + return connectionConfigFingerprint(current, form.value.name) === testedConfigFingerprint.value ? result.databaseInfo : null; + } catch { + return null; + } +}); +const visibleSavedDatabaseInfo = computed(() => { + if (!savedDatabaseInfo.value || !savedDatabaseInfoFingerprint.value || !editingId.value) return null; + try { + const current = connectionConfigForSubmit(editingId.value, form.value.name); + return connectionConfigFingerprint(current, form.value.name) === savedDatabaseInfoFingerprint.value ? savedDatabaseInfo.value : null; + } catch { + return null; + } +}); +const configuredDatabaseInfo = computed(() => { + const productName = configuredDatabaseProductName({ + db_type: form.value.db_type, + driver_label: form.value.driver_label, + }); + return normalizeDatabaseConnectionInfo(undefined, productName, form.value.database) ?? null; +}); +const visibleDatabaseInfo = computed(() => visibleTestDatabaseInfo.value ?? visibleSavedDatabaseInfo.value ?? configuredDatabaseInfo.value); +const databaseInfoVerified = computed(() => !!visibleTestDatabaseInfo.value || !!visibleSavedDatabaseInfo.value); +const databaseInfoStatusLabel = computed(() => (databaseInfoVerified.value ? t("connection.databaseInfo.sourceTested") : t("connection.databaseInfo.sourceConfigured"))); +const databaseInfoDescription = computed(() => (databaseInfoVerified.value ? t("connection.databaseInfo.testedDescription") : t("connection.databaseInfo.configuredDescription"))); +const databaseInfoDisplayRows = computed(() => + visibleDatabaseInfo.value + ? databaseInfoRows(visibleDatabaseInfo.value).map((row) => ({ + ...row, + label: databaseInfoFieldLabel(row.key), + displayValue: row.key === "unquotedIdentifierCase" || row.key === "quotedIdentifierCase" ? databaseIdentifierCaseLabel(row.value as IdentifierCase) : row.value, + })) + : [], +); +const databaseInfoCompactLabel = computed(() => + databaseInfoDisplayRows.value + .filter((row) => row.key === "productName" || row.key === "productVersion" || row.key === "currentDatabase") + .slice(0, 3) + .map((row) => row.displayValue) + .join(" · "), +); const testResultMessage = computed(() => { if (!testResult.value) return ""; return testResult.value.ok ? t("connection.testSuccess") : translateBackendError(t, testResult.value.message); @@ -2147,30 +2312,37 @@ async function testConnection() { isTesting.value = true; testResult.value = null; let config: ConnectionConfig | null = null; + const submittedSourceName = form.value.name; try { config = connectionConfigForSubmit(editingId.value || draftTestConnectionId.value); await ensureRequiredAgentDriverInstalled(config); - const msg = await testConnectionWithTimeout(config, runId); + const result = await testConnectionWithTimeout(config, runId); if (runId !== testRunId) return; - if (config.db_type === "mongodb" && /legacy driver/i.test(msg)) { + let successfulConfig = config; + if (config.db_type === "mongodb" && /legacy driver/i.test(result.message)) { mongoDriverMode.value = "legacy"; + successfulConfig = connectionConfigForSubmit(config.id, config.name); } - testResult.value = { ok: true, message: msg }; + applySuccessfulConnectionTest(result, successfulConfig, submittedSourceName); + void persistSuccessfulConnectionTest(result, successfulConfig, submittedSourceName); clearEditedConnectionErrorAfterSuccessfulTest(); } catch (e: any) { if (runId !== testRunId) return; const rawMessage = mongodbAuthFailureHint(errorMessage(e)); const message = config ? connectionErrorWithDriverUpdateHint(config, rawMessage) : rawMessage; - const fallbackMessage = config ? await tryNacosDockerConsoleFallback(config, message, runId) : null; + const fallback = config ? await tryNacosDockerConsoleFallback(config, message, runId) : null; if (runId !== testRunId) return; - const shouldShowSqlServerLegacyMode = !fallbackMessage && config?.db_type === "sqlserver" && !isSqlServerLegacyCompatibilityMode(config.url_params) && isSqlServerTlsHandshakeFailure(message); + const shouldShowSqlServerLegacyMode = !fallback && config?.db_type === "sqlserver" && !isSqlServerLegacyCompatibilityMode(config.url_params) && isSqlServerTlsHandshakeFailure(message); if (shouldShowSqlServerLegacyMode) { configTab.value = "advanced"; } - testResult.value = fallbackMessage ? { ok: true, message: fallbackMessage } : { ok: false, message }; - if (fallbackMessage) { + if (fallback) { + applySuccessfulConnectionTest(fallback.result, fallback.config, submittedSourceName); + void persistSuccessfulConnectionTest(fallback.result, fallback.config, submittedSourceName); clearEditedConnectionErrorAfterSuccessfulTest(); } else { + clearTestedConnectionInfo(); + testResult.value = { ok: false, message }; showConnectionError(message); } } finally { @@ -2319,13 +2491,14 @@ function generateConnectionName(): string { return `${label}_${rand}`; } -function connectionConfigForSubmit(id: string): ConnectionConfig { +function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionConfig { const config = { ...formValueForSubmit(), id } as LegacyConnectionConfig; + config.database_info = undefined; if (selectedType.value === "oceanbase" && (config.driver_profile === "oceanbase" || config.driver_profile === "oceanbase-oracle")) { Object.assign(config, oceanbaseModeConnectionPatch(oceanbaseSubMode.value)); } if (!config.name?.trim()) { - config.name = generateConnectionName(); + config.name = generatedName.trim() || generateConnectionName(); } if (config.db_type === "kingbase") { config.database = config.database?.trim() || undefined; @@ -2602,6 +2775,13 @@ function connectionConfigForSubmit(id: string): ConnectionConfig { return config as ConnectionConfig; } +function withSavedDatabaseInfo(config: ConnectionConfig, databaseInfo: DatabaseConnectionInfo | null): ConnectionConfig { + return { + ...config, + database_info: databaseInfo ? { ...databaseInfo } : undefined, + }; +} + function connectionConfigSnapshotForVisibleDatabases(): ConnectionConfig { return { ...(form.value as ConnectionConfig), @@ -2813,6 +2993,7 @@ function resetTestState() { testRunId += 1; isTesting.value = false; testResult.value = null; + clearTestedConnectionInfo(); showConnectionErrorDialog.value = false; connectionErrorDetail.value = ""; } @@ -3098,6 +3279,17 @@ async function copyTestResult() { } } +async function copyDatabaseInfo() { + const info = visibleDatabaseInfo.value; + if (!info) return; + try { + await copyToClipboard(databaseInfoCopyText(info, databaseInfoFieldLabel, databaseIdentifierCaseLabel)); + toast(t("grid.copied")); + } catch (e: any) { + toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000); + } +} + async function copyAgentInstallError() { if (!agentInstallError.value) return; try { @@ -3431,16 +3623,16 @@ function validateTransportLayers(config: LegacyConnectionConfig) { async function save() { if (!ensureConnectionHostResolvedFromUrl()) return; if (isSaving.value) return; + const databaseInfoForSave = visibleTestDatabaseInfo.value ?? visibleSavedDatabaseInfo.value; isSaving.value = true; - resetTestState(); try { if (editingId.value) { - const updated = connectionConfigForSubmit(editingId.value); + const updated = withSavedDatabaseInfo(connectionConfigForSubmit(editingId.value), databaseInfoForSave); await ensureRequiredAgentDriverInstalled(updated); await store.updateConnection(updated); store.stopEditing(); } else { - const config = connectionConfigForSubmit(draftTestConnectionId.value); + const config = withSavedDatabaseInfo(connectionConfigForSubmit(draftTestConnectionId.value), databaseInfoForSave); await ensureRequiredAgentDriverInstalled(config); await store.addConnection(config); draftTestConnectionId.value = uuid(); @@ -3817,7 +4009,7 @@ function openExternalUrl(url: string) { + +
+ + + + + + +
+
+
+ +
{{ t("connection.databaseInfo.title") }}
+
+

{{ databaseInfoDescription }}

+
+ +
+
+ +
+
+
+
- -
+ +
- -
+ +
@@ -5419,8 +5649,8 @@ function openExternalUrl(url: string) {
- -
+ +
@@ -5663,7 +5893,7 @@ function openExternalUrl(url: string) {
- +
@@ -5921,6 +6151,12 @@ function openExternalUrl(url: string) {