diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index e14ea80..9221a60 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -148,6 +148,7 @@ type ChoiceCardOption = { description?: string; }; const MAX_TIMEOUT_SECONDS = 3600; +const DEFAULT_KEEPALIVE_INTERVAL_MINUTES = 240; const PRIMARY_USERNAME_OPTIONAL_TYPES = new Set([ "mongodb", "elasticsearch", @@ -1427,6 +1428,11 @@ const ConnectionModal: React.FC<{ driver: config.driver, dsn: config.dsn, timeout: resolvedJvmTimeout, + keepAliveEnabled: !!config.keepAliveEnabled, + keepAliveIntervalMinutes: + Number(config.keepAliveIntervalMinutes) > 0 + ? Number(config.keepAliveIntervalMinutes) + : DEFAULT_KEEPALIVE_INTERVAL_MINUTES, mysqlTopology: mysqlIsReplica ? "replica" : "single", mysqlReplicaHosts: mysqlReplicaHosts, rocketmqTopology: rocketmqIsCluster ? "cluster" : "single", @@ -2033,6 +2039,8 @@ const ConnectionModal: React.FC<{ httpTunnelUser: "", httpTunnelPassword: "", timeout: 30, + keepAliveEnabled: false, + keepAliveIntervalMinutes: DEFAULT_KEEPALIVE_INTERVAL_MINUTES, uri: "", connectionParams: "", includeDatabases: undefined, @@ -2105,6 +2113,8 @@ const ConnectionModal: React.FC<{ httpTunnelPort: 8080, httpTunnelUser: "", httpTunnelPassword: "", + keepAliveEnabled: false, + keepAliveIntervalMinutes: DEFAULT_KEEPALIVE_INTERVAL_MINUTES, mysqlTopology: "single", rocketmqTopology: "single", mqttTopology: "single", @@ -2153,6 +2163,8 @@ const ConnectionModal: React.FC<{ httpTunnelPort: 8080, httpTunnelUser: "", httpTunnelPassword: "", + keepAliveEnabled: false, + keepAliveIntervalMinutes: DEFAULT_KEEPALIVE_INTERVAL_MINUTES, mysqlTopology: "single", rocketmqTopology: "single", mqttTopology: "single", diff --git a/frontend/src/components/connectionModal/ConnectionModalNetworkSecuritySection.tsx b/frontend/src/components/connectionModal/ConnectionModalNetworkSecuritySection.tsx index 5121809..ce292da 100644 --- a/frontend/src/components/connectionModal/ConnectionModalNetworkSecuritySection.tsx +++ b/frontend/src/components/connectionModal/ConnectionModalNetworkSecuritySection.tsx @@ -6,6 +6,9 @@ import { getStoredSecretPlaceholder } from "../../utils/connectionModalPresentat import { noAutoCapInputProps } from "../../utils/inputAutoCap"; const { Text } = Typography; +const DEFAULT_KEEPALIVE_INTERVAL_MINUTES = 240; +const MIN_KEEPALIVE_INTERVAL_MINUTES = 1; +const MAX_KEEPALIVE_INTERVAL_MINUTES = 1440; type ConnectionModalNetworkSecuritySectionProps = Record; @@ -53,6 +56,7 @@ const ConnectionModalNetworkSecuritySection: React.FC + + + {t("connection.modal.network.keepAliveEnabled.checkbox")} + + + + + ); diff --git a/frontend/src/components/connectionModal/ConnectionModalStep2.tsx b/frontend/src/components/connectionModal/ConnectionModalStep2.tsx index aaa34db..2e4f27f 100644 --- a/frontend/src/components/connectionModal/ConnectionModalStep2.tsx +++ b/frontend/src/components/connectionModal/ConnectionModalStep2.tsx @@ -2301,6 +2301,8 @@ const renderStep2 = () => { useHttpTunnel: false, httpTunnelPort: 8080, timeout: 30, + keepAliveEnabled: false, + keepAliveIntervalMinutes: 240, uri: "", connectionParams: "", oceanBaseProtocol: "mysql", diff --git a/frontend/src/components/connectionModal/connectionModalConfig.keepalive.test.ts b/frontend/src/components/connectionModal/connectionModalConfig.keepalive.test.ts new file mode 100644 index 0000000..f928ac9 --- /dev/null +++ b/frontend/src/components/connectionModal/connectionModalConfig.keepalive.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import { buildConnectionConfig } from "./connectionModalConfig"; + +const translate = (key: string) => key; + +const buildBaseValues = () => ({ + type: "mysql", + host: "db.local", + port: 3306, + user: "root", + password: "", + database: "", + useSSL: false, + useSSH: false, + useProxy: false, + useHttpTunnel: false, + timeout: 30, + keepAliveEnabled: false, + keepAliveIntervalMinutes: 240, + savePassword: true, + uri: "", + connectionParams: "", + sslMode: "preferred", + sslCAPath: "", + sslCertPath: "", + sslKeyPath: "", + sshHost: "", + sshPort: 22, + sshUser: "", + sshPassword: "", + sshKeyPath: "", + proxyType: "socks5", + proxyHost: "", + proxyPort: 1080, + proxyUser: "", + proxyPassword: "", + httpTunnelHost: "", + httpTunnelPort: 8080, + httpTunnelUser: "", + httpTunnelPassword: "", + mysqlTopology: "single", + rocketmqTopology: "single", + mqttTopology: "single", + kafkaTopology: "single", + redisTopology: "single", + mongoTopology: "single", + mongoSrv: false, + mongoReadPreference: "primary", + mongoAuthMechanism: "", + mysqlReplicaHosts: [], + rocketmqHosts: [], + mqttHosts: [], + kafkaHosts: [], + redisHosts: [], + redisSentinelMaster: "", + redisSentinelUser: "", + redisSentinelPassword: "", + mongoHosts: [], + mysqlReplicaUser: "", + mysqlReplicaPassword: "", + mongoReplicaUser: "", + mongoReplicaPassword: "", + redisDB: 0, +}); + +describe("connectionModalConfig keepalive", () => { + it("keeps keepalive settings for network connections", async () => { + const config = await buildConnectionConfig({ + values: { + ...buildBaseValues(), + keepAliveEnabled: true, + keepAliveIntervalMinutes: 15, + }, + forPersist: true, + translate, + }); + + expect(config.keepAliveEnabled).toBe(true); + expect(config.keepAliveIntervalMinutes).toBe(15); + }); + + it("forces file database keepalive off", async () => { + const config = await buildConnectionConfig({ + values: { + ...buildBaseValues(), + type: "sqlite", + host: "D:/tmp/demo.db", + port: 0, + keepAliveEnabled: true, + keepAliveIntervalMinutes: 15, + }, + forPersist: true, + translate, + }); + + expect(config.keepAliveEnabled).toBe(false); + expect(config.keepAliveIntervalMinutes).toBe(15); + }); +}); diff --git a/frontend/src/components/connectionModal/connectionModalConfig.ts b/frontend/src/components/connectionModal/connectionModalConfig.ts index d515adb..20652f7 100644 --- a/frontend/src/components/connectionModal/connectionModalConfig.ts +++ b/frontend/src/components/connectionModal/connectionModalConfig.ts @@ -1,807 +1,850 @@ -import type { ConnectionConfig, SavedConnection } from "../../types"; -import { resolveConnectionSecretDraft } from "../../utils/connectionSecretDraft"; -import { - getConnectionTypeDefaultPort as getDefaultPortByType, -} from "../../utils/connectionTypeCatalog"; -import { - isFileDatabaseType, - isMySQLCompatibleType, - supportsConnectionParamsForType, - supportsSSLClientCertificateForType, - supportsSSLForType, -} from "../../utils/connectionTypeCapabilities"; -import { - buildDefaultJVMConnectionValues, - buildJVMConnectionConfig, - hasUnsupportedJVMDiagnosticTransport, - hasUnsupportedJVMEditableModes, - normalizeEditableJVMModes, -} from "../../utils/jvmConnectionConfig"; -import { resolveRedisConfigDraft } from "../../utils/redisConnectionUri"; -import { - normalizeAddressList, - normalizeClickHouseProtocolValue, - normalizeConnectionParamsText, - normalizeFileDbPath, - normalizeMongoSrvHostList, - normalizeOceanBaseConnectionParamsText, - normalizeOceanBaseProtocolValue, - parseClickHouseHTTPUriToValues, - parseHostPort, - parseUriToValues, - toAddress, -} from "./connectionModalUri"; - +import type { ConnectionConfig, SavedConnection } from "../../types"; +import { resolveConnectionSecretDraft } from "../../utils/connectionSecretDraft"; +import { + getConnectionTypeDefaultPort as getDefaultPortByType, +} from "../../utils/connectionTypeCatalog"; +import { + isFileDatabaseType, + isMySQLCompatibleType, + supportsConnectionParamsForType, + supportsSSLClientCertificateForType, + supportsSSLForType, +} from "../../utils/connectionTypeCapabilities"; +import { + buildDefaultJVMConnectionValues, + buildJVMConnectionConfig, + hasUnsupportedJVMDiagnosticTransport, + hasUnsupportedJVMEditableModes, + normalizeEditableJVMModes, +} from "../../utils/jvmConnectionConfig"; +import { resolveRedisConfigDraft } from "../../utils/redisConnectionUri"; +import { + normalizeAddressList, + normalizeClickHouseProtocolValue, + normalizeConnectionParamsText, + normalizeFileDbPath, + normalizeMongoSrvHostList, + normalizeOceanBaseConnectionParamsText, + normalizeOceanBaseProtocolValue, + parseClickHouseHTTPUriToValues, + parseHostPort, + parseUriToValues, + toAddress, +} from "./connectionModalUri"; + type Translate = (key: string, params?: any) => string; - -export type ConnectionSecretKey = - | "primaryPassword" - | "sshPassword" - | "proxyPassword" - | "httpTunnelPassword" - | "mysqlReplicaPassword" - | "mongoReplicaPassword" - | "redisSentinelPassword" - | "opaqueURI" - | "opaqueDSN"; - -export type ConnectionSecretClearState = Record; - -export const createEmptyConnectionSecretClearState = - (): ConnectionSecretClearState => ({ - primaryPassword: false, - sshPassword: false, - proxyPassword: false, - httpTunnelPassword: false, - mysqlReplicaPassword: false, - mongoReplicaPassword: false, - redisSentinelPassword: false, - opaqueURI: false, - opaqueDSN: false, - }); -type BuildSavedConnectionInputParams = { - config: ConnectionConfig; - values: any; - initialValues?: SavedConnection | null; - clearSecrets: ConnectionSecretClearState; - customIconType?: string; - customIconColor?: string; -}; - -type GetBlockingSecretClearMessageParams = { - values: any; - clearSecrets: ConnectionSecretClearState; - initialValues?: SavedConnection | null; - translate: Translate; -}; - -type BuildConnectionConfigParams = { - values: any; - forPersist: boolean; - initialValues?: SavedConnection | null; - translate: Translate; -}; - -export const buildSavedConnectionInput = ({ - config, - values, - initialValues, - clearSecrets, - customIconType, - customIconColor, -}: BuildSavedConnectionInputParams) => { - const connectionId = - initialValues?.id || config.id || Date.now().toString(); - const primaryDraft = resolveConnectionSecretDraft({ - hasSecret: initialValues?.hasPrimaryPassword, - valueInput: config.password, - clearSecret: - clearSecrets.primaryPassword || - (initialValues?.hasPrimaryPassword === true && - String(config.password || "") === ""), - forceClear: values.type === "mongodb" && values.savePassword === false, - }); - const sshDraft = resolveConnectionSecretDraft({ - hasSecret: initialValues?.hasSSHPassword, - valueInput: config.ssh?.password, - clearSecret: clearSecrets.sshPassword, - forceClear: !config.useSSH, - }); - const proxyDraft = resolveConnectionSecretDraft({ - hasSecret: initialValues?.hasProxyPassword, - valueInput: config.proxy?.password, - clearSecret: clearSecrets.proxyPassword, - forceClear: !config.useProxy, - }); - const httpTunnelDraft = resolveConnectionSecretDraft({ - hasSecret: initialValues?.hasHttpTunnelPassword, - valueInput: config.httpTunnel?.password, - clearSecret: clearSecrets.httpTunnelPassword, - forceClear: !config.useHttpTunnel, - }); - const mysqlReplicaEnabled = - isMySQLCompatibleType(config.type) && config.topology === "replica"; - const mysqlReplicaDraft = resolveConnectionSecretDraft({ - hasSecret: initialValues?.hasMySQLReplicaPassword, - valueInput: config.mysqlReplicaPassword, - clearSecret: clearSecrets.mysqlReplicaPassword, - forceClear: !mysqlReplicaEnabled, - }); - const mongoReplicaEnabled = - config.type === "mongodb" && - config.topology === "replica" && - values.savePassword !== false; - const mongoReplicaDraft = resolveConnectionSecretDraft({ - hasSecret: initialValues?.hasMongoReplicaPassword, - valueInput: config.mongoReplicaPassword, - clearSecret: clearSecrets.mongoReplicaPassword, - forceClear: !mongoReplicaEnabled, - }); - const redisSentinelEnabled = - config.type === "redis" && - config.topology === "sentinel" && - values.savePassword !== false; - const redisSentinelDraft = resolveConnectionSecretDraft({ - hasSecret: initialValues?.hasRedisSentinelPassword, - valueInput: config.redisSentinelPassword, - clearSecret: clearSecrets.redisSentinelPassword, - forceClear: !redisSentinelEnabled, - }); - const opaqueUriDraft = resolveConnectionSecretDraft({ - hasSecret: initialValues?.hasOpaqueURI, - valueInput: config.uri, - clearSecret: clearSecrets.opaqueURI, - forceClear: values.type === "custom", - trimInput: true, - }); - const opaqueDsnDraft = resolveConnectionSecretDraft({ - hasSecret: initialValues?.hasOpaqueDSN, - valueInput: config.dsn, - clearSecret: clearSecrets.opaqueDSN, - forceClear: values.type !== "custom", - trimInput: true, - }); - const isRedisType = values.type === "redis"; - const displayHost = String( - (config as any).host || values.host || "", - ).trim(); - const nextName = - values.name || - (isFileDatabaseType(values.type) - ? values.type === "duckdb" - ? "DuckDB DB" - : "SQLite DB" - : values.type === "redis" - ? `Redis ${displayHost}` - : displayHost); - - return { - id: connectionId, - name: nextName, - config: { - ...config, - id: connectionId, - password: primaryDraft.value, - ssh: { - ...(config.ssh || { - host: "", - port: 22, - user: "", - password: "", - keyPath: "", - }), - password: sshDraft.value, - }, - proxy: { - ...(config.proxy || { - type: "socks5", - host: "", - port: 1080, - user: "", - password: "", - }), - password: proxyDraft.value, - }, - httpTunnel: { - ...(config.httpTunnel || { - host: "", - port: 8080, - user: "", - password: "", - }), - password: httpTunnelDraft.value, - }, - uri: opaqueUriDraft.value, - dsn: opaqueDsnDraft.value, - mysqlReplicaPassword: mysqlReplicaDraft.value, - mongoReplicaPassword: mongoReplicaDraft.value, - redisSentinelPassword: redisSentinelDraft.value, - }, - includeDatabases: values.includeDatabases, - includeRedisDatabases: isRedisType - ? values.includeRedisDatabases - : undefined, - iconType: customIconType || "", - iconColor: customIconColor || "", - clearPrimaryPassword: primaryDraft.clearStoredSecret, - clearSSHPassword: sshDraft.clearStoredSecret, - clearProxyPassword: proxyDraft.clearStoredSecret, - clearHttpTunnelPassword: httpTunnelDraft.clearStoredSecret, - clearMySQLReplicaPassword: mysqlReplicaDraft.clearStoredSecret, - clearMongoReplicaPassword: mongoReplicaDraft.clearStoredSecret, - clearRedisSentinelPassword: redisSentinelDraft.clearStoredSecret, - clearOpaqueURI: opaqueUriDraft.clearStoredSecret, - clearOpaqueDSN: opaqueDsnDraft.clearStoredSecret, - }; -}; - -export const getBlockingSecretClearMessage = ({ - values, - clearSecrets, - initialValues, - translate: t, -}: GetBlockingSecretClearMessageParams): string | null => { - if ( - clearSecrets.primaryPassword && - values.type !== "custom" && - !isFileDatabaseType(values.type) && - String(values.password ?? "") === "" - ) { - return t("connection.modal.secret.blocking.primary"); - } - if ( - clearSecrets.sshPassword && - values.useSSH && - String(values.sshPassword ?? "") === "" - ) { - return t("connection.modal.secret.blocking.ssh"); - } - if ( - clearSecrets.proxyPassword && - values.useProxy && - !values.useHttpTunnel && - String(values.proxyPassword ?? "") === "" - ) { - return t("connection.modal.secret.blocking.proxy"); - } - if ( - clearSecrets.httpTunnelPassword && - values.useHttpTunnel && - String(values.httpTunnelPassword ?? "") === "" - ) { - return t("connection.modal.secret.blocking.httpTunnel"); - } - if ( - clearSecrets.mysqlReplicaPassword && - isMySQLCompatibleType(values.type) && - values.mysqlTopology === "replica" && - String(values.mysqlReplicaPassword ?? "") === "" - ) { - return t("connection.modal.secret.blocking.mysqlReplica"); - } - if ( - clearSecrets.mongoReplicaPassword && - values.type === "mongodb" && - values.mongoTopology === "replica" && - String(values.mongoReplicaPassword ?? "") === "" - ) { - return t("connection.modal.secret.blocking.mongoReplica"); - } - if ( - clearSecrets.redisSentinelPassword && - values.type === "redis" && - values.redisTopology === "sentinel" && - String(values.redisSentinelPassword ?? "") === "" - ) { - return t("connection.modal.secret.blocking.redis_sentinel"); - } - if ( - values.type === "mongodb" && - values.savePassword === false && - initialValues?.hasPrimaryPassword && - String(values.password ?? "") === "" - ) { - return t("connection.modal.secret.blocking.mongoPrimary"); - } - return null; -}; - -export const buildConnectionConfig = async ({ - values, - forPersist, - initialValues, - translate: t, -}: BuildConnectionConfigParams): Promise => { - const mergedValues = { ...values }; - if ( - String(mergedValues.type || "") - .trim() - .toLowerCase() === "jvm" - ) { - if ( - hasUnsupportedJVMEditableModes({ - allowedModes: mergedValues.jvmAllowedModes, - preferredMode: mergedValues.jvmPreferredMode, - }) - ) { - throw new Error(t("connection.modal.jvm.unsupportedMode.saveTest")); - } - if ( - hasUnsupportedJVMDiagnosticTransport( - mergedValues.jvmDiagnosticTransport, - ) - ) { - throw new Error( - t("connection.modal.jvm.unsupportedTransport.saveTest"), - ); - } - const existingDiagnostic = initialValues?.config?.jvm?.diagnostic; - if ( - mergedValues.jvmDiagnosticEnabled === undefined && - existingDiagnostic?.enabled !== undefined - ) { - mergedValues.jvmDiagnosticEnabled = existingDiagnostic.enabled; - } - if ( - String(mergedValues.jvmDiagnosticTransport || "").trim() === "" && - existingDiagnostic?.transport - ) { - mergedValues.jvmDiagnosticTransport = existingDiagnostic.transport; - } - if ( - String(mergedValues.jvmDiagnosticBaseUrl || "").trim() === "" && - existingDiagnostic?.baseUrl - ) { - mergedValues.jvmDiagnosticBaseUrl = existingDiagnostic.baseUrl; - } - if ( - String(mergedValues.jvmDiagnosticTargetId || "").trim() === "" && - existingDiagnostic?.targetId - ) { - mergedValues.jvmDiagnosticTargetId = existingDiagnostic.targetId; - } - if ( - String(mergedValues.jvmDiagnosticApiKey || "").trim() === "" && - existingDiagnostic?.apiKey - ) { - mergedValues.jvmDiagnosticApiKey = existingDiagnostic.apiKey; - } - if ( - mergedValues.jvmDiagnosticAllowObserveCommands === undefined && - existingDiagnostic?.allowObserveCommands !== undefined - ) { - mergedValues.jvmDiagnosticAllowObserveCommands = - existingDiagnostic.allowObserveCommands; - } - if ( - mergedValues.jvmDiagnosticAllowTraceCommands === undefined && - existingDiagnostic?.allowTraceCommands !== undefined - ) { - mergedValues.jvmDiagnosticAllowTraceCommands = - existingDiagnostic.allowTraceCommands; - } - if ( - mergedValues.jvmDiagnosticAllowMutatingCommands === undefined && - existingDiagnostic?.allowMutatingCommands !== undefined - ) { - mergedValues.jvmDiagnosticAllowMutatingCommands = - existingDiagnostic.allowMutatingCommands; - } - if ( - (mergedValues.jvmDiagnosticTimeoutSeconds === undefined || - mergedValues.jvmDiagnosticTimeoutSeconds === null || - mergedValues.jvmDiagnosticTimeoutSeconds === "") && - Number(existingDiagnostic?.timeoutSeconds) > 0 - ) { - mergedValues.jvmDiagnosticTimeoutSeconds = Number( - existingDiagnostic?.timeoutSeconds, - ); - } - const resolvedJvmAllowedModes = normalizeEditableJVMModes( - mergedValues.jvmAllowedModes, - ); - const resolvedJvmTimeout = Number(mergedValues.timeout || 30); - const preferredJvmMode = String(mergedValues.jvmPreferredMode || "") - .trim() - .toLowerCase(); - const resolvedJvmPreferredMode = - resolvedJvmAllowedModes.find((mode) => mode === preferredJvmMode) || - resolvedJvmAllowedModes[0]; - return buildJVMConnectionConfig({ - ...buildDefaultJVMConnectionValues(), - ...mergedValues, - jvmAllowedModes: resolvedJvmAllowedModes, - jvmPreferredMode: resolvedJvmPreferredMode, - jvmEndpointEnabled: resolvedJvmAllowedModes.includes("endpoint"), - jvmAgentEnabled: resolvedJvmAllowedModes.includes("agent"), - timeout: resolvedJvmTimeout, - jvmEndpointTimeoutSeconds: resolvedJvmTimeout, - }); - } - const parsedUriValues = parseUriToValues( - mergedValues.uri, - mergedValues.type, - ); - const isEmptyField = (value: unknown) => - value === undefined || - value === null || - value === "" || - value === 0 || - (Array.isArray(value) && value.length === 0); - if (parsedUriValues) { - Object.entries(parsedUriValues).forEach(([key, value]) => { - if ( - key === "clickHouseProtocol" && - normalizeClickHouseProtocolValue((mergedValues as any)[key]) === - "auto" && - normalizeClickHouseProtocolValue(value) !== "auto" - ) { - (mergedValues as any)[key] = value; - return; - } - if (isEmptyField((mergedValues as any)[key])) { - (mergedValues as any)[key] = value; - } - }); - } - - const type = String(mergedValues.type || "").toLowerCase(); - const defaultPort = getDefaultPortByType(type); - const selectedOceanBaseProtocol = - type === "oceanbase" - ? normalizeOceanBaseProtocolValue(mergedValues.oceanBaseProtocol) - : "mysql"; - if (type === "clickhouse") { - const requestedProtocol = normalizeClickHouseProtocolValue( - mergedValues.clickHouseProtocol, - ); - const hostSchemeValues = parseClickHouseHTTPUriToValues( - mergedValues.host, - Number(mergedValues.port || defaultPort), - ); - if (hostSchemeValues) { - mergedValues.host = hostSchemeValues.host; - mergedValues.port = hostSchemeValues.port; - if (requestedProtocol !== "native") { - mergedValues.clickHouseProtocol = "http"; - mergedValues.useSSL = hostSchemeValues.useSSL; - mergedValues.sslMode = hostSchemeValues.sslMode; - } else { - mergedValues.clickHouseProtocol = "native"; - } - if (isEmptyField(mergedValues.user)) { - mergedValues.user = hostSchemeValues.user; - } - if (isEmptyField(mergedValues.password)) { - mergedValues.password = hostSchemeValues.password; - } - if (isEmptyField(mergedValues.database)) { - mergedValues.database = hostSchemeValues.database; - } - } - } - const isFileDbType = isFileDatabaseType(type); - const sslCapableType = supportsSSLForType(type); - - // Redis 默认不展示用户名字段;若 URI 可解析则以 URI 为准覆盖 user, - // 同时清理历史默认值 root,避免 go-redis 发送 ACL AUTH(user, pass) 导致 WRONGPASS。 - if (type === "redis") { - if ( - parsedUriValues && - Object.prototype.hasOwnProperty.call(parsedUriValues, "user") - ) { - mergedValues.user = String((parsedUriValues as any).user || ""); - } else if (String(mergedValues.user || "").trim() === "root") { - mergedValues.user = ""; - } - } - const sslModeRaw = String(mergedValues.sslMode || "preferred") - .trim() - .toLowerCase(); - const sslMode: "preferred" | "required" | "skip-verify" | "disable" = - sslModeRaw === "required" - ? "required" - : sslModeRaw === "skip-verify" - ? "skip-verify" - : sslModeRaw === "disable" - ? "disable" - : "preferred"; - const effectiveUseSSL = sslCapableType && !!mergedValues.useSSL; - const sslCAPath = sslCapableType - ? String(mergedValues.sslCAPath || "").trim() - : ""; - const sslCertPath = sslCapableType - ? String(mergedValues.sslCertPath || "").trim() - : ""; - const sslKeyPath = sslCapableType - ? String(mergedValues.sslKeyPath || "").trim() - : ""; - if (type === "dameng" && effectiveUseSSL && (!sslCertPath || !sslKeyPath)) { - throw new Error(t("connection.modal.validation.ssl.damengRequired")); - } - if (effectiveUseSSL && supportsSSLClientCertificateForType(type) && (!!sslCertPath !== !!sslKeyPath)) { - throw new Error(t("connection.modal.validation.ssl.clientPairRequired")); - } - - let primaryHost = "localhost"; - let primaryPort = defaultPort; - if (isFileDbType) { - // 文件型数据库(sqlite/duckdb)这里的 host 即数据库文件路径,不应参与 host:port 拼接与解析。 - primaryHost = normalizeFileDbPath(String(mergedValues.host || "").trim()); - primaryPort = 0; - } else { - const parsedPrimary = parseHostPort( - toAddress( - mergedValues.host || "localhost", - Number(mergedValues.port || defaultPort), - defaultPort, - ), - defaultPort, - ); - primaryHost = parsedPrimary?.host || "localhost"; - primaryPort = parsedPrimary?.port || defaultPort; - } - - let hosts: string[] = []; - let topology: "single" | "replica" | "cluster" | "sentinel" | undefined; - let replicaSet = ""; - let authSource = ""; - let readPreference = ""; - let mysqlReplicaUser = ""; - let mysqlReplicaPassword = ""; - let mongoSrvEnabled = false; - let mongoAuthMechanism = ""; - let mongoReplicaUser = ""; - let mongoReplicaPassword = ""; - let redisSentinelMaster = ""; - let redisSentinelUser = ""; - let redisSentinelPassword = ""; - const savePassword = - type === "mongodb" ? mergedValues.savePassword !== false : true; - - if (isMySQLCompatibleType(type) && selectedOceanBaseProtocol !== "oracle") { - const replicas = - mergedValues.mysqlTopology === "replica" - ? normalizeAddressList(mergedValues.mysqlReplicaHosts, defaultPort) - : []; - const allHosts = normalizeAddressList( - [`${primaryHost}:${primaryPort}`, ...replicas], - defaultPort, - ); - if (mergedValues.mysqlTopology === "replica" || allHosts.length > 1) { - hosts = allHosts; - topology = "replica"; - mysqlReplicaUser = String(mergedValues.mysqlReplicaUser || "").trim(); - mysqlReplicaPassword = String(mergedValues.mysqlReplicaPassword || ""); - } else { - topology = "single"; - } - } - - if (type === "kafka") { - const brokers = - mergedValues.kafkaTopology === "cluster" - ? normalizeAddressList(mergedValues.kafkaHosts, defaultPort) - : []; - const allHosts = normalizeAddressList( - [`${primaryHost}:${primaryPort}`, ...brokers], - defaultPort, - ); - if (mergedValues.kafkaTopology === "cluster" || allHosts.length > 1) { - hosts = allHosts; - topology = "cluster"; - } else { - topology = "single"; - } - } - - if (type === "mqtt") { - const brokers = - mergedValues.mqttTopology === "cluster" - ? normalizeAddressList(mergedValues.mqttHosts, defaultPort) - : []; - const allHosts = normalizeAddressList( - [`${primaryHost}:${primaryPort}`, ...brokers], - defaultPort, - ); - if (mergedValues.mqttTopology === "cluster" || allHosts.length > 1) { - hosts = allHosts; - topology = "cluster"; - } else { - topology = "single"; - } - } - - if (type === "rocketmq") { - const nameservers = - mergedValues.rocketmqTopology === "cluster" - ? normalizeAddressList(mergedValues.rocketmqHosts, defaultPort) - : []; - const allHosts = normalizeAddressList( - [`${primaryHost}:${primaryPort}`, ...nameservers], - defaultPort, - ); - if (mergedValues.rocketmqTopology === "cluster" || allHosts.length > 1) { - hosts = allHosts; - topology = "cluster"; - } else { - topology = "single"; - } - } - - if (type === "mongodb") { - mongoSrvEnabled = !!mergedValues.mongoSrv; - const extraHosts = - mergedValues.mongoTopology === "replica" - ? mongoSrvEnabled - ? normalizeMongoSrvHostList(mergedValues.mongoHosts, defaultPort) - : normalizeAddressList(mergedValues.mongoHosts, defaultPort) - : []; - const primarySeed = mongoSrvEnabled - ? primaryHost - : `${primaryHost}:${primaryPort}`; - const allHosts = mongoSrvEnabled - ? normalizeMongoSrvHostList([primarySeed, ...extraHosts], defaultPort) - : normalizeAddressList([primarySeed, ...extraHosts], defaultPort); - if ( - mergedValues.mongoTopology === "replica" || - allHosts.length > 1 || - mergedValues.mongoReplicaSet - ) { - hosts = allHosts; - topology = "replica"; - mongoReplicaUser = String(mergedValues.mongoReplicaUser || "").trim(); - mongoReplicaPassword = String(mergedValues.mongoReplicaPassword || ""); - } else { - topology = "single"; - } - replicaSet = String(mergedValues.mongoReplicaSet || "").trim(); - authSource = String( - mergedValues.mongoAuthSource || mergedValues.database || "admin", - ).trim(); - readPreference = String( - mergedValues.mongoReadPreference || "primary", - ).trim(); - mongoAuthMechanism = String(mergedValues.mongoAuthMechanism || "") - .trim() - .toUpperCase(); - } - - if (type === "redis") { - const redisDraft = resolveRedisConfigDraft( - mergedValues, - primaryHost, - primaryPort, - defaultPort, - ); - primaryPort = redisDraft.primaryPort; - hosts = redisDraft.hosts; - topology = redisDraft.topology; - redisSentinelMaster = redisDraft.redisSentinelMaster; - redisSentinelUser = redisDraft.redisSentinelUser; - redisSentinelPassword = redisDraft.redisSentinelPassword; - mergedValues.redisDB = redisDraft.redisDB; - } - - const sshConfig = mergedValues.useSSH - ? { - host: mergedValues.sshHost, - port: Number(mergedValues.sshPort), - user: mergedValues.sshUser, - password: mergedValues.sshPassword || "", - keyPath: mergedValues.sshKeyPath || "", - } - : { host: "", port: 22, user: "", password: "", keyPath: "" }; - const effectiveUseHttpTunnel = - !isFileDbType && !!mergedValues.useHttpTunnel; - const effectiveUseProxy = - !isFileDbType && !!mergedValues.useProxy && !effectiveUseHttpTunnel; - const proxyTypeRaw = String( - mergedValues.proxyType || "socks5", - ).toLowerCase(); - const proxyType: "socks5" | "http" = - proxyTypeRaw === "http" ? "http" : "socks5"; - const proxyConfig: NonNullable = - effectiveUseProxy - ? { - type: proxyType, - host: String(mergedValues.proxyHost || "").trim(), - port: Number( - mergedValues.proxyPort || (proxyTypeRaw === "http" ? 8080 : 1080), - ), - user: String(mergedValues.proxyUser || "").trim(), - password: mergedValues.proxyPassword || "", - } - : { - type: "socks5", - host: "", - port: 1080, - user: "", - password: "", - }; - const httpTunnelConfig: NonNullable = - effectiveUseHttpTunnel - ? { - host: String(mergedValues.httpTunnelHost || "").trim(), - port: Number(mergedValues.httpTunnelPort || 8080), - user: String(mergedValues.httpTunnelUser || "").trim(), - password: mergedValues.httpTunnelPassword || "", - } - : { - host: "", - port: 8080, - user: "", - password: "", - }; - if (effectiveUseHttpTunnel) { - if (!httpTunnelConfig.host) { - throw new Error(t("connection.modal.validation.httpTunnel.hostRequired")); - } - if ( - !Number.isFinite(httpTunnelConfig.port) || - httpTunnelConfig.port <= 0 || - httpTunnelConfig.port > 65535 - ) { - throw new Error(t("connection.modal.validation.httpTunnel.portRange")); - } - } - - const keepPassword = !forPersist || savePassword; - const normalizedConnectionParams = supportsConnectionParamsForType(type) - ? type === "oceanbase" - ? normalizeOceanBaseConnectionParamsText( - mergedValues.connectionParams, - selectedOceanBaseProtocol, - ) - : normalizeConnectionParamsText(mergedValues.connectionParams) - : ""; - - return { - type: mergedValues.type, - host: primaryHost, - port: Number(primaryPort || 0), - user: mergedValues.user || "", - password: keepPassword ? mergedValues.password || "" : "", - savePassword: savePassword, - database: mergedValues.database || "", - useSSL: effectiveUseSSL, - sslMode: effectiveUseSSL ? sslMode : "disable", - sslCAPath: sslCAPath, - sslCertPath: sslCertPath, - sslKeyPath: sslKeyPath, - useSSH: !!mergedValues.useSSH, - ssh: sshConfig, - useProxy: effectiveUseProxy, - proxy: proxyConfig, - useHttpTunnel: effectiveUseHttpTunnel, - httpTunnel: httpTunnelConfig, - driver: mergedValues.driver, - dsn: mergedValues.dsn, - connectionParams: normalizedConnectionParams, - timeout: Number(mergedValues.timeout || 30), - redisDB: Number.isFinite(Number(mergedValues.redisDB)) - ? Math.max(0, Math.trunc(Number(mergedValues.redisDB))) - : 0, - redisSentinelMaster: redisSentinelMaster, - redisSentinelUser: redisSentinelUser, - redisSentinelPassword: keepPassword ? redisSentinelPassword : "", - uri: String(mergedValues.uri || "").trim(), - clickHouseProtocol: - type === "clickhouse" - ? normalizeClickHouseProtocolValue(mergedValues.clickHouseProtocol) - : undefined, - oceanBaseProtocol: - type === "oceanbase" ? selectedOceanBaseProtocol : undefined, - hosts: hosts, - topology: topology, - mysqlReplicaUser: mysqlReplicaUser, - mysqlReplicaPassword: keepPassword ? mysqlReplicaPassword : "", - replicaSet: replicaSet, - authSource: authSource, - readPreference: readPreference, - mongoSrv: mongoSrvEnabled, - mongoAuthMechanism: mongoAuthMechanism, - mongoReplicaUser: mongoReplicaUser, - mongoReplicaPassword: keepPassword ? mongoReplicaPassword : "", - }; -}; + +const DEFAULT_KEEPALIVE_INTERVAL_MINUTES = 240; +const MIN_KEEPALIVE_INTERVAL_MINUTES = 1; +const MAX_KEEPALIVE_INTERVAL_MINUTES = 1440; + +export type ConnectionSecretKey = + + | "primaryPassword" + + | "sshPassword" + + | "proxyPassword" + + | "httpTunnelPassword" + + | "mysqlReplicaPassword" + + | "mongoReplicaPassword" + + | "redisSentinelPassword" + + | "opaqueURI" + + | "opaqueDSN"; + + + +export type ConnectionSecretClearState = Record; + +export const createEmptyConnectionSecretClearState = + + (): ConnectionSecretClearState => ({ + + primaryPassword: false, + + sshPassword: false, + + proxyPassword: false, + + httpTunnelPassword: false, + + mysqlReplicaPassword: false, + + mongoReplicaPassword: false, + + redisSentinelPassword: false, + + opaqueURI: false, + + opaqueDSN: false, + + }); +type BuildSavedConnectionInputParams = { + config: ConnectionConfig; + values: any; + initialValues?: SavedConnection | null; + clearSecrets: ConnectionSecretClearState; + customIconType?: string; + customIconColor?: string; +}; + +type GetBlockingSecretClearMessageParams = { + values: any; + clearSecrets: ConnectionSecretClearState; + initialValues?: SavedConnection | null; + translate: Translate; +}; + +type BuildConnectionConfigParams = { + values: any; + forPersist: boolean; + initialValues?: SavedConnection | null; + translate: Translate; +}; + +export const buildSavedConnectionInput = ({ + config, + values, + initialValues, + clearSecrets, + customIconType, + customIconColor, +}: BuildSavedConnectionInputParams) => { + const connectionId = + initialValues?.id || config.id || Date.now().toString(); + const primaryDraft = resolveConnectionSecretDraft({ + hasSecret: initialValues?.hasPrimaryPassword, + valueInput: config.password, + clearSecret: + clearSecrets.primaryPassword || + (initialValues?.hasPrimaryPassword === true && + String(config.password || "") === ""), + forceClear: values.type === "mongodb" && values.savePassword === false, + }); + const sshDraft = resolveConnectionSecretDraft({ + hasSecret: initialValues?.hasSSHPassword, + valueInput: config.ssh?.password, + clearSecret: clearSecrets.sshPassword, + forceClear: !config.useSSH, + }); + const proxyDraft = resolveConnectionSecretDraft({ + hasSecret: initialValues?.hasProxyPassword, + valueInput: config.proxy?.password, + clearSecret: clearSecrets.proxyPassword, + forceClear: !config.useProxy, + }); + const httpTunnelDraft = resolveConnectionSecretDraft({ + hasSecret: initialValues?.hasHttpTunnelPassword, + valueInput: config.httpTunnel?.password, + clearSecret: clearSecrets.httpTunnelPassword, + forceClear: !config.useHttpTunnel, + }); + const mysqlReplicaEnabled = + isMySQLCompatibleType(config.type) && config.topology === "replica"; + const mysqlReplicaDraft = resolveConnectionSecretDraft({ + hasSecret: initialValues?.hasMySQLReplicaPassword, + valueInput: config.mysqlReplicaPassword, + clearSecret: clearSecrets.mysqlReplicaPassword, + forceClear: !mysqlReplicaEnabled, + }); + const mongoReplicaEnabled = + config.type === "mongodb" && + config.topology === "replica" && + values.savePassword !== false; + const mongoReplicaDraft = resolveConnectionSecretDraft({ + hasSecret: initialValues?.hasMongoReplicaPassword, + valueInput: config.mongoReplicaPassword, + clearSecret: clearSecrets.mongoReplicaPassword, + forceClear: !mongoReplicaEnabled, + }); + const redisSentinelEnabled = + config.type === "redis" && + config.topology === "sentinel" && + values.savePassword !== false; + const redisSentinelDraft = resolveConnectionSecretDraft({ + hasSecret: initialValues?.hasRedisSentinelPassword, + valueInput: config.redisSentinelPassword, + clearSecret: clearSecrets.redisSentinelPassword, + forceClear: !redisSentinelEnabled, + }); + const opaqueUriDraft = resolveConnectionSecretDraft({ + hasSecret: initialValues?.hasOpaqueURI, + valueInput: config.uri, + clearSecret: clearSecrets.opaqueURI, + forceClear: values.type === "custom", + trimInput: true, + }); + const opaqueDsnDraft = resolveConnectionSecretDraft({ + hasSecret: initialValues?.hasOpaqueDSN, + valueInput: config.dsn, + clearSecret: clearSecrets.opaqueDSN, + forceClear: values.type !== "custom", + trimInput: true, + }); + const isRedisType = values.type === "redis"; + const displayHost = String( + (config as any).host || values.host || "", + ).trim(); + const nextName = + values.name || + (isFileDatabaseType(values.type) + ? values.type === "duckdb" + ? "DuckDB DB" + : "SQLite DB" + : values.type === "redis" + ? `Redis ${displayHost}` + : displayHost); + + return { + id: connectionId, + name: nextName, + config: { + ...config, + id: connectionId, + password: primaryDraft.value, + ssh: { + ...(config.ssh || { + host: "", + port: 22, + user: "", + password: "", + keyPath: "", + }), + password: sshDraft.value, + }, + proxy: { + ...(config.proxy || { + type: "socks5", + host: "", + port: 1080, + user: "", + password: "", + }), + password: proxyDraft.value, + }, + httpTunnel: { + ...(config.httpTunnel || { + host: "", + port: 8080, + user: "", + password: "", + }), + password: httpTunnelDraft.value, + }, + uri: opaqueUriDraft.value, + dsn: opaqueDsnDraft.value, + mysqlReplicaPassword: mysqlReplicaDraft.value, + mongoReplicaPassword: mongoReplicaDraft.value, + redisSentinelPassword: redisSentinelDraft.value, + }, + includeDatabases: values.includeDatabases, + includeRedisDatabases: isRedisType + ? values.includeRedisDatabases + : undefined, + iconType: customIconType || "", + iconColor: customIconColor || "", + clearPrimaryPassword: primaryDraft.clearStoredSecret, + clearSSHPassword: sshDraft.clearStoredSecret, + clearProxyPassword: proxyDraft.clearStoredSecret, + clearHttpTunnelPassword: httpTunnelDraft.clearStoredSecret, + clearMySQLReplicaPassword: mysqlReplicaDraft.clearStoredSecret, + clearMongoReplicaPassword: mongoReplicaDraft.clearStoredSecret, + clearRedisSentinelPassword: redisSentinelDraft.clearStoredSecret, + clearOpaqueURI: opaqueUriDraft.clearStoredSecret, + clearOpaqueDSN: opaqueDsnDraft.clearStoredSecret, + }; +}; + +export const getBlockingSecretClearMessage = ({ + values, + clearSecrets, + initialValues, + translate: t, +}: GetBlockingSecretClearMessageParams): string | null => { + if ( + clearSecrets.primaryPassword && + values.type !== "custom" && + !isFileDatabaseType(values.type) && + String(values.password ?? "") === "" + ) { + return t("connection.modal.secret.blocking.primary"); + } + if ( + clearSecrets.sshPassword && + values.useSSH && + String(values.sshPassword ?? "") === "" + ) { + return t("connection.modal.secret.blocking.ssh"); + } + if ( + clearSecrets.proxyPassword && + values.useProxy && + !values.useHttpTunnel && + String(values.proxyPassword ?? "") === "" + ) { + return t("connection.modal.secret.blocking.proxy"); + } + if ( + clearSecrets.httpTunnelPassword && + values.useHttpTunnel && + String(values.httpTunnelPassword ?? "") === "" + ) { + return t("connection.modal.secret.blocking.httpTunnel"); + } + if ( + clearSecrets.mysqlReplicaPassword && + isMySQLCompatibleType(values.type) && + values.mysqlTopology === "replica" && + String(values.mysqlReplicaPassword ?? "") === "" + ) { + return t("connection.modal.secret.blocking.mysqlReplica"); + } + if ( + clearSecrets.mongoReplicaPassword && + values.type === "mongodb" && + values.mongoTopology === "replica" && + String(values.mongoReplicaPassword ?? "") === "" + ) { + return t("connection.modal.secret.blocking.mongoReplica"); + } + if ( + clearSecrets.redisSentinelPassword && + values.type === "redis" && + values.redisTopology === "sentinel" && + String(values.redisSentinelPassword ?? "") === "" + ) { + return t("connection.modal.secret.blocking.redis_sentinel"); + } + if ( + values.type === "mongodb" && + values.savePassword === false && + initialValues?.hasPrimaryPassword && + String(values.password ?? "") === "" + ) { + return t("connection.modal.secret.blocking.mongoPrimary"); + } + return null; +}; + +export const buildConnectionConfig = async ({ + values, + forPersist, + initialValues, + translate: t, +}: BuildConnectionConfigParams): Promise => { + const mergedValues = { ...values }; + if ( + String(mergedValues.type || "") + .trim() + .toLowerCase() === "jvm" + ) { + if ( + hasUnsupportedJVMEditableModes({ + allowedModes: mergedValues.jvmAllowedModes, + preferredMode: mergedValues.jvmPreferredMode, + }) + ) { + throw new Error(t("connection.modal.jvm.unsupportedMode.saveTest")); + } + if ( + hasUnsupportedJVMDiagnosticTransport( + mergedValues.jvmDiagnosticTransport, + ) + ) { + throw new Error( + t("connection.modal.jvm.unsupportedTransport.saveTest"), + ); + } + const existingDiagnostic = initialValues?.config?.jvm?.diagnostic; + if ( + mergedValues.jvmDiagnosticEnabled === undefined && + existingDiagnostic?.enabled !== undefined + ) { + mergedValues.jvmDiagnosticEnabled = existingDiagnostic.enabled; + } + if ( + String(mergedValues.jvmDiagnosticTransport || "").trim() === "" && + existingDiagnostic?.transport + ) { + mergedValues.jvmDiagnosticTransport = existingDiagnostic.transport; + } + if ( + String(mergedValues.jvmDiagnosticBaseUrl || "").trim() === "" && + existingDiagnostic?.baseUrl + ) { + mergedValues.jvmDiagnosticBaseUrl = existingDiagnostic.baseUrl; + } + if ( + String(mergedValues.jvmDiagnosticTargetId || "").trim() === "" && + existingDiagnostic?.targetId + ) { + mergedValues.jvmDiagnosticTargetId = existingDiagnostic.targetId; + } + if ( + String(mergedValues.jvmDiagnosticApiKey || "").trim() === "" && + existingDiagnostic?.apiKey + ) { + mergedValues.jvmDiagnosticApiKey = existingDiagnostic.apiKey; + } + if ( + mergedValues.jvmDiagnosticAllowObserveCommands === undefined && + existingDiagnostic?.allowObserveCommands !== undefined + ) { + mergedValues.jvmDiagnosticAllowObserveCommands = + existingDiagnostic.allowObserveCommands; + } + if ( + mergedValues.jvmDiagnosticAllowTraceCommands === undefined && + existingDiagnostic?.allowTraceCommands !== undefined + ) { + mergedValues.jvmDiagnosticAllowTraceCommands = + existingDiagnostic.allowTraceCommands; + } + if ( + mergedValues.jvmDiagnosticAllowMutatingCommands === undefined && + existingDiagnostic?.allowMutatingCommands !== undefined + ) { + mergedValues.jvmDiagnosticAllowMutatingCommands = + existingDiagnostic.allowMutatingCommands; + } + if ( + (mergedValues.jvmDiagnosticTimeoutSeconds === undefined || + mergedValues.jvmDiagnosticTimeoutSeconds === null || + mergedValues.jvmDiagnosticTimeoutSeconds === "") && + Number(existingDiagnostic?.timeoutSeconds) > 0 + ) { + mergedValues.jvmDiagnosticTimeoutSeconds = Number( + existingDiagnostic?.timeoutSeconds, + ); + } + const resolvedJvmAllowedModes = normalizeEditableJVMModes( + mergedValues.jvmAllowedModes, + ); + const resolvedJvmTimeout = Number(mergedValues.timeout || 30); + const preferredJvmMode = String(mergedValues.jvmPreferredMode || "") + .trim() + .toLowerCase(); + const resolvedJvmPreferredMode = + resolvedJvmAllowedModes.find((mode) => mode === preferredJvmMode) || + resolvedJvmAllowedModes[0]; + return buildJVMConnectionConfig({ + ...buildDefaultJVMConnectionValues(), + ...mergedValues, + jvmAllowedModes: resolvedJvmAllowedModes, + jvmPreferredMode: resolvedJvmPreferredMode, + jvmEndpointEnabled: resolvedJvmAllowedModes.includes("endpoint"), + jvmAgentEnabled: resolvedJvmAllowedModes.includes("agent"), + timeout: resolvedJvmTimeout, + jvmEndpointTimeoutSeconds: resolvedJvmTimeout, + }); + } + const parsedUriValues = parseUriToValues( + mergedValues.uri, + mergedValues.type, + ); + const isEmptyField = (value: unknown) => + value === undefined || + value === null || + value === "" || + value === 0 || + (Array.isArray(value) && value.length === 0); + if (parsedUriValues) { + Object.entries(parsedUriValues).forEach(([key, value]) => { + if ( + key === "clickHouseProtocol" && + normalizeClickHouseProtocolValue((mergedValues as any)[key]) === + "auto" && + normalizeClickHouseProtocolValue(value) !== "auto" + ) { + (mergedValues as any)[key] = value; + return; + } + if (isEmptyField((mergedValues as any)[key])) { + (mergedValues as any)[key] = value; + } + }); + } + + const type = String(mergedValues.type || "").toLowerCase(); + const defaultPort = getDefaultPortByType(type); + const selectedOceanBaseProtocol = + type === "oceanbase" + ? normalizeOceanBaseProtocolValue(mergedValues.oceanBaseProtocol) + : "mysql"; + if (type === "clickhouse") { + const requestedProtocol = normalizeClickHouseProtocolValue( + mergedValues.clickHouseProtocol, + ); + const hostSchemeValues = parseClickHouseHTTPUriToValues( + mergedValues.host, + Number(mergedValues.port || defaultPort), + ); + if (hostSchemeValues) { + mergedValues.host = hostSchemeValues.host; + mergedValues.port = hostSchemeValues.port; + if (requestedProtocol !== "native") { + mergedValues.clickHouseProtocol = "http"; + mergedValues.useSSL = hostSchemeValues.useSSL; + mergedValues.sslMode = hostSchemeValues.sslMode; + } else { + mergedValues.clickHouseProtocol = "native"; + } + if (isEmptyField(mergedValues.user)) { + mergedValues.user = hostSchemeValues.user; + } + if (isEmptyField(mergedValues.password)) { + mergedValues.password = hostSchemeValues.password; + } + if (isEmptyField(mergedValues.database)) { + mergedValues.database = hostSchemeValues.database; + } + } + } + const isFileDbType = isFileDatabaseType(type); + const sslCapableType = supportsSSLForType(type); + + // Redis 默认不展示用户名字段;若 URI 可解析则以 URI 为准覆盖 user, + // 同时清理历史默认值 root,避免 go-redis 发送 ACL AUTH(user, pass) 导致 WRONGPASS。 + if (type === "redis") { + if ( + parsedUriValues && + Object.prototype.hasOwnProperty.call(parsedUriValues, "user") + ) { + mergedValues.user = String((parsedUriValues as any).user || ""); + } else if (String(mergedValues.user || "").trim() === "root") { + mergedValues.user = ""; + } + } + const sslModeRaw = String(mergedValues.sslMode || "preferred") + .trim() + .toLowerCase(); + const sslMode: "preferred" | "required" | "skip-verify" | "disable" = + sslModeRaw === "required" + ? "required" + : sslModeRaw === "skip-verify" + ? "skip-verify" + : sslModeRaw === "disable" + ? "disable" + : "preferred"; + const effectiveUseSSL = sslCapableType && !!mergedValues.useSSL; + const sslCAPath = sslCapableType + ? String(mergedValues.sslCAPath || "").trim() + : ""; + const sslCertPath = sslCapableType + ? String(mergedValues.sslCertPath || "").trim() + : ""; + const sslKeyPath = sslCapableType + ? String(mergedValues.sslKeyPath || "").trim() + : ""; + if (type === "dameng" && effectiveUseSSL && (!sslCertPath || !sslKeyPath)) { + throw new Error(t("connection.modal.validation.ssl.damengRequired")); + } + if (effectiveUseSSL && supportsSSLClientCertificateForType(type) && (!!sslCertPath !== !!sslKeyPath)) { + throw new Error(t("connection.modal.validation.ssl.clientPairRequired")); + } + + let primaryHost = "localhost"; + let primaryPort = defaultPort; + if (isFileDbType) { + // 文件型数据库(sqlite/duckdb)这里的 host 即数据库文件路径,不应参与 host:port 拼接与解析。 + primaryHost = normalizeFileDbPath(String(mergedValues.host || "").trim()); + primaryPort = 0; + } else { + const parsedPrimary = parseHostPort( + toAddress( + mergedValues.host || "localhost", + Number(mergedValues.port || defaultPort), + defaultPort, + ), + defaultPort, + ); + primaryHost = parsedPrimary?.host || "localhost"; + primaryPort = parsedPrimary?.port || defaultPort; + } + + let hosts: string[] = []; + let topology: "single" | "replica" | "cluster" | "sentinel" | undefined; + let replicaSet = ""; + let authSource = ""; + let readPreference = ""; + let mysqlReplicaUser = ""; + let mysqlReplicaPassword = ""; + let mongoSrvEnabled = false; + let mongoAuthMechanism = ""; + let mongoReplicaUser = ""; + let mongoReplicaPassword = ""; + let redisSentinelMaster = ""; + let redisSentinelUser = ""; + let redisSentinelPassword = ""; + const savePassword = + type === "mongodb" ? mergedValues.savePassword !== false : true; + + if (isMySQLCompatibleType(type) && selectedOceanBaseProtocol !== "oracle") { + const replicas = + mergedValues.mysqlTopology === "replica" + ? normalizeAddressList(mergedValues.mysqlReplicaHosts, defaultPort) + : []; + const allHosts = normalizeAddressList( + [`${primaryHost}:${primaryPort}`, ...replicas], + defaultPort, + ); + if (mergedValues.mysqlTopology === "replica" || allHosts.length > 1) { + hosts = allHosts; + topology = "replica"; + mysqlReplicaUser = String(mergedValues.mysqlReplicaUser || "").trim(); + mysqlReplicaPassword = String(mergedValues.mysqlReplicaPassword || ""); + } else { + topology = "single"; + } + } + + if (type === "kafka") { + const brokers = + mergedValues.kafkaTopology === "cluster" + ? normalizeAddressList(mergedValues.kafkaHosts, defaultPort) + : []; + const allHosts = normalizeAddressList( + [`${primaryHost}:${primaryPort}`, ...brokers], + defaultPort, + ); + if (mergedValues.kafkaTopology === "cluster" || allHosts.length > 1) { + hosts = allHosts; + topology = "cluster"; + } else { + topology = "single"; + } + } + + if (type === "mqtt") { + const brokers = + mergedValues.mqttTopology === "cluster" + ? normalizeAddressList(mergedValues.mqttHosts, defaultPort) + : []; + const allHosts = normalizeAddressList( + [`${primaryHost}:${primaryPort}`, ...brokers], + defaultPort, + ); + if (mergedValues.mqttTopology === "cluster" || allHosts.length > 1) { + hosts = allHosts; + topology = "cluster"; + } else { + topology = "single"; + } + } + + if (type === "rocketmq") { + const nameservers = + mergedValues.rocketmqTopology === "cluster" + ? normalizeAddressList(mergedValues.rocketmqHosts, defaultPort) + : []; + const allHosts = normalizeAddressList( + [`${primaryHost}:${primaryPort}`, ...nameservers], + defaultPort, + ); + if (mergedValues.rocketmqTopology === "cluster" || allHosts.length > 1) { + hosts = allHosts; + topology = "cluster"; + } else { + topology = "single"; + } + } + + if (type === "mongodb") { + mongoSrvEnabled = !!mergedValues.mongoSrv; + const extraHosts = + mergedValues.mongoTopology === "replica" + ? mongoSrvEnabled + ? normalizeMongoSrvHostList(mergedValues.mongoHosts, defaultPort) + : normalizeAddressList(mergedValues.mongoHosts, defaultPort) + : []; + const primarySeed = mongoSrvEnabled + ? primaryHost + : `${primaryHost}:${primaryPort}`; + const allHosts = mongoSrvEnabled + ? normalizeMongoSrvHostList([primarySeed, ...extraHosts], defaultPort) + : normalizeAddressList([primarySeed, ...extraHosts], defaultPort); + if ( + mergedValues.mongoTopology === "replica" || + allHosts.length > 1 || + mergedValues.mongoReplicaSet + ) { + hosts = allHosts; + topology = "replica"; + mongoReplicaUser = String(mergedValues.mongoReplicaUser || "").trim(); + mongoReplicaPassword = String(mergedValues.mongoReplicaPassword || ""); + } else { + topology = "single"; + } + replicaSet = String(mergedValues.mongoReplicaSet || "").trim(); + authSource = String( + mergedValues.mongoAuthSource || mergedValues.database || "admin", + ).trim(); + readPreference = String( + mergedValues.mongoReadPreference || "primary", + ).trim(); + mongoAuthMechanism = String(mergedValues.mongoAuthMechanism || "") + .trim() + .toUpperCase(); + } + + if (type === "redis") { + const redisDraft = resolveRedisConfigDraft( + mergedValues, + primaryHost, + primaryPort, + defaultPort, + ); + primaryPort = redisDraft.primaryPort; + hosts = redisDraft.hosts; + topology = redisDraft.topology; + redisSentinelMaster = redisDraft.redisSentinelMaster; + redisSentinelUser = redisDraft.redisSentinelUser; + redisSentinelPassword = redisDraft.redisSentinelPassword; + mergedValues.redisDB = redisDraft.redisDB; + } + + const sshConfig = mergedValues.useSSH + ? { + host: mergedValues.sshHost, + port: Number(mergedValues.sshPort), + user: mergedValues.sshUser, + password: mergedValues.sshPassword || "", + keyPath: mergedValues.sshKeyPath || "", + } + : { host: "", port: 22, user: "", password: "", keyPath: "" }; + const effectiveUseHttpTunnel = + !isFileDbType && !!mergedValues.useHttpTunnel; + const effectiveUseProxy = + !isFileDbType && !!mergedValues.useProxy && !effectiveUseHttpTunnel; + const proxyTypeRaw = String( + mergedValues.proxyType || "socks5", + ).toLowerCase(); + const proxyType: "socks5" | "http" = + proxyTypeRaw === "http" ? "http" : "socks5"; + const proxyConfig: NonNullable = + effectiveUseProxy + ? { + type: proxyType, + host: String(mergedValues.proxyHost || "").trim(), + port: Number( + mergedValues.proxyPort || (proxyTypeRaw === "http" ? 8080 : 1080), + ), + user: String(mergedValues.proxyUser || "").trim(), + password: mergedValues.proxyPassword || "", + } + : { + type: "socks5", + host: "", + port: 1080, + user: "", + password: "", + }; + const httpTunnelConfig: NonNullable = + effectiveUseHttpTunnel + ? { + host: String(mergedValues.httpTunnelHost || "").trim(), + port: Number(mergedValues.httpTunnelPort || 8080), + user: String(mergedValues.httpTunnelUser || "").trim(), + password: mergedValues.httpTunnelPassword || "", + } + : { + host: "", + port: 8080, + user: "", + password: "", + }; + if (effectiveUseHttpTunnel) { + if (!httpTunnelConfig.host) { + throw new Error(t("connection.modal.validation.httpTunnel.hostRequired")); + } + if ( + !Number.isFinite(httpTunnelConfig.port) || + httpTunnelConfig.port <= 0 || + httpTunnelConfig.port > 65535 + ) { + throw new Error(t("connection.modal.validation.httpTunnel.portRange")); + } + } + + const keepPassword = !forPersist || savePassword; + const keepAliveEnabled = + !isFileDatabaseType(type) && + type !== "jvm" && + !!mergedValues.keepAliveEnabled; + const keepAliveIntervalMinutesRaw = Number( + mergedValues.keepAliveIntervalMinutes, + ); + const keepAliveIntervalMinutes = + Number.isFinite(keepAliveIntervalMinutesRaw) && + keepAliveIntervalMinutesRaw >= MIN_KEEPALIVE_INTERVAL_MINUTES + ? Math.min( + Math.trunc(keepAliveIntervalMinutesRaw), + MAX_KEEPALIVE_INTERVAL_MINUTES, + ) + : DEFAULT_KEEPALIVE_INTERVAL_MINUTES; + const normalizedConnectionParams = supportsConnectionParamsForType(type) + ? type === "oceanbase" + ? normalizeOceanBaseConnectionParamsText( + mergedValues.connectionParams, + selectedOceanBaseProtocol, + ) + : normalizeConnectionParamsText(mergedValues.connectionParams) + : ""; + + return { + type: mergedValues.type, + host: primaryHost, + port: Number(primaryPort || 0), + user: mergedValues.user || "", + password: keepPassword ? mergedValues.password || "" : "", + savePassword: savePassword, + database: mergedValues.database || "", + useSSL: effectiveUseSSL, + sslMode: effectiveUseSSL ? sslMode : "disable", + sslCAPath: sslCAPath, + sslCertPath: sslCertPath, + sslKeyPath: sslKeyPath, + useSSH: !!mergedValues.useSSH, + ssh: sshConfig, + useProxy: effectiveUseProxy, + proxy: proxyConfig, + useHttpTunnel: effectiveUseHttpTunnel, + httpTunnel: httpTunnelConfig, + driver: mergedValues.driver, + dsn: mergedValues.dsn, + connectionParams: normalizedConnectionParams, + timeout: Number(mergedValues.timeout || 30), + keepAliveEnabled: keepAliveEnabled, + keepAliveIntervalMinutes: keepAliveIntervalMinutes, + redisDB: Number.isFinite(Number(mergedValues.redisDB)) + ? Math.max(0, Math.trunc(Number(mergedValues.redisDB))) + : 0, + redisSentinelMaster: redisSentinelMaster, + redisSentinelUser: redisSentinelUser, + redisSentinelPassword: keepPassword ? redisSentinelPassword : "", + uri: String(mergedValues.uri || "").trim(), + clickHouseProtocol: + type === "clickhouse" + ? normalizeClickHouseProtocolValue(mergedValues.clickHouseProtocol) + : undefined, + oceanBaseProtocol: + type === "oceanbase" ? selectedOceanBaseProtocol : undefined, + hosts: hosts, + topology: topology, + mysqlReplicaUser: mysqlReplicaUser, + mysqlReplicaPassword: keepPassword ? mysqlReplicaPassword : "", + replicaSet: replicaSet, + authSource: authSource, + readPreference: readPreference, + mongoSrv: mongoSrvEnabled, + mongoAuthMechanism: mongoAuthMechanism, + mongoReplicaUser: mongoReplicaUser, + mongoReplicaPassword: keepPassword ? mongoReplicaPassword : "", + }; +}; diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts index d8a9de5..6d0d8bc 100644 --- a/frontend/src/store.test.ts +++ b/frontend/src/store.test.ts @@ -417,6 +417,30 @@ describe('store appearance persistence', () => { ); }); + it('normalizes keepalive settings when replacing saved connections', async () => { + const { useStore } = await importStore(); + + useStore.getState().replaceConnections([ + { + id: 'postgres-keepalive', + name: 'Postgres KeepAlive', + config: { + id: 'postgres-keepalive', + type: 'postgres', + host: 'db.local', + port: 5432, + user: 'postgres', + keepAliveEnabled: true, + keepAliveIntervalMinutes: 0, + }, + }, + ]); + + const config = useStore.getState().connections[0]?.config; + expect(config?.keepAliveEnabled).toBe(true); + expect(config?.keepAliveIntervalMinutes).toBe(240); + }); + it('keeps StarRocks saved connections as independent datasource type', async () => { const { useStore } = await importStore(); diff --git a/frontend/src/store.ts b/frontend/src/store.ts index b7f7b74..6c9d0c3 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -124,6 +124,9 @@ const MAX_HOST_ENTRY_LENGTH = 512; const MAX_HOST_ENTRIES = 64; const DEFAULT_TIMEOUT_SECONDS = 30; const MAX_TIMEOUT_SECONDS = 3600; +const DEFAULT_KEEPALIVE_INTERVAL_MINUTES = 240; +const MIN_KEEPALIVE_INTERVAL_MINUTES = 1; +const MAX_KEEPALIVE_INTERVAL_MINUTES = 1440; const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15; const MAX_DIAGNOSTIC_TIMEOUT_SECONDS = 300; const PERSIST_VERSION = 12; @@ -827,6 +830,13 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { 1, MAX_TIMEOUT_SECONDS, ), + keepAliveEnabled: Boolean(raw.keepAliveEnabled), + keepAliveIntervalMinutes: normalizeIntegerInRange( + raw.keepAliveIntervalMinutes, + DEFAULT_KEEPALIVE_INTERVAL_MINUTES, + MIN_KEEPALIVE_INTERVAL_MINUTES, + MAX_KEEPALIVE_INTERVAL_MINUTES, + ), }; if (type === "redis") { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2d9da71..40fd154 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -297,6 +297,8 @@ export interface ConnectionConfig { dsn?: string; connectionParams?: string; timeout?: number; + keepAliveEnabled?: boolean; + keepAliveIntervalMinutes?: number; redisDB?: number; // Redis database index uri?: string; // Connection URI for copy/paste clickHouseProtocol?: "auto" | "http" | "native"; // ClickHouse connection protocol override diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 01062c3..b870e6a 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -927,6 +927,8 @@ export namespace connection { dsn?: string; connectionParams?: string; timeout?: number; + keepAliveEnabled?: boolean; + keepAliveIntervalMinutes?: number; redisDB?: number; redisSentinelMaster?: string; redisSentinelUser?: string; @@ -976,6 +978,8 @@ export namespace connection { this.dsn = source["dsn"]; this.connectionParams = source["connectionParams"]; this.timeout = source["timeout"]; + this.keepAliveEnabled = source["keepAliveEnabled"]; + this.keepAliveIntervalMinutes = source["keepAliveIntervalMinutes"]; this.redisDB = source["redisDB"]; this.redisSentinelMaster = source["redisSentinelMaster"]; this.redisSentinelUser = source["redisSentinelUser"]; diff --git a/internal/app/app.go b/internal/app/app.go index 3a7dc5e..061cee7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -42,9 +42,12 @@ var ( ) type cachedDatabase struct { - inst db.Database - lastPing time.Time - config connection.ConnectionConfig + inst db.Database + lastPing time.Time + config connection.ConnectionConfig + keepAliveEnabled bool + keepAliveInterval time.Duration + keepAliveInFlight bool } type cachedConnectFailure struct { @@ -88,6 +91,8 @@ type App struct { jvmPreviewTokenMu sync.Mutex jvmPreviewTokens map[string]jvmPreviewConfirmationToken jvmPreviewTokenTTL time.Duration + keepAliveCancel context.CancelFunc + keepAliveDone chan struct{} } // NewApp creates a new App application struct @@ -185,6 +190,7 @@ func (a *App) startup(ctx context.Context) { installMacNativeWindowDiagnostics(logger.Path()) } applyMacWindowTranslucencyFix() + a.startConnectionKeepAliveLoop() logger.Infof("应用启动完成(首次连接保护窗口=%s,最多重试=%d 次)", startupConnectRetryWindow, startupConnectRetryAttempts) } @@ -233,6 +239,7 @@ func (a *App) LogWindowDiagnostic(stage string, payload string) { // Shutdown is called when the app terminates. func (a *App) Shutdown() { logger.Infof("应用开始关闭,准备释放资源") + a.stopConnectionKeepAliveLoop() a.rollbackPendingSQLTransactionsOnShutdown() a.mu.Lock() defer a.mu.Unlock() @@ -275,6 +282,9 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn } // timeout 仅用于 Query/Ping 控制,不应作为物理连接复用键的一部分。 normalized.Timeout = 0 + // keepalive 仅影响后台保活策略,不应参与物理连接复用键。 + normalized.KeepAliveEnabled = false + normalized.KeepAliveIntervalMinutes = 0 normalized.SavePassword = false if !normalized.UseSSH { @@ -779,6 +789,20 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing entry, ok := a.dbCache[key] a.mu.RUnlock() if ok { + keepAliveEnabled, keepAliveInterval := resolveConnectionKeepAliveSettings(effectiveConfig) + if entry.keepAliveEnabled != keepAliveEnabled || entry.keepAliveInterval != keepAliveInterval { + a.mu.Lock() + if cur, exists := a.dbCache[key]; exists && cur.inst == entry.inst { + cur.keepAliveEnabled = keepAliveEnabled + cur.keepAliveInterval = keepAliveInterval + if !keepAliveEnabled { + cur.keepAliveInFlight = false + } + a.dbCache[key] = cur + entry = cur + } + a.mu.Unlock() + } if isFileDB { logger.Infof("命中文件库连接缓存:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey) } @@ -861,6 +885,7 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing a.clearConnectFailureByKey(key) now := time.Now() + keepAliveEnabled, keepAliveInterval := resolveConnectionKeepAliveSettings(effectiveConfig) a.mu.Lock() if existing, exists := a.dbCache[key]; exists && existing.inst != nil { @@ -872,7 +897,13 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing } return existing.inst, nil } - a.dbCache[key] = cachedDatabase{inst: dbInst, lastPing: now, config: normalizeCacheKeyConfig(effectiveConfig)} + a.dbCache[key] = cachedDatabase{ + inst: dbInst, + lastPing: now, + config: normalizeCacheKeyConfig(effectiveConfig), + keepAliveEnabled: keepAliveEnabled, + keepAliveInterval: keepAliveInterval, + } a.mu.Unlock() logger.Infof("数据库连接成功并写入缓存:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey) diff --git a/internal/app/app_cache_key_test.go b/internal/app/app_cache_key_test.go index 6f487b1..d9c01ad 100644 --- a/internal/app/app_cache_key_test.go +++ b/internal/app/app_cache_key_test.go @@ -43,6 +43,28 @@ func TestGetCacheKey_IgnoreConnectionID(t *testing.T) { } } +func TestGetCacheKey_IgnoreKeepAliveSettings(t *testing.T) { + base := connection.ConnectionConfig{ + Type: "postgres", + Host: "db.local", + Port: 5432, + User: "postgres", + Password: "secret", + Database: "app", + KeepAliveEnabled: false, + KeepAliveIntervalMinutes: 240, + } + modified := base + modified.KeepAliveEnabled = true + modified.KeepAliveIntervalMinutes = 15 + + left := getCacheKey(base) + right := getCacheKey(modified) + if left != right { + t.Fatalf("expected same cache key when only keepalive settings differ, got %s vs %s", left, right) + } +} + func TestGetCacheKey_DuckDBHostAndDatabaseEquivalent(t *testing.T) { withHost := connection.ConnectionConfig{ Type: "duckdb", diff --git a/internal/app/app_keepalive.go b/internal/app/app_keepalive.go new file mode 100644 index 0000000..14476a5 --- /dev/null +++ b/internal/app/app_keepalive.go @@ -0,0 +1,181 @@ +package app + +import ( + "context" + "time" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/db" + "GoNavi-Wails/internal/logger" +) + +const ( + defaultConnectionKeepAliveIntervalMinutes = 240 + minConnectionKeepAliveIntervalMinutes = 1 + maxConnectionKeepAliveIntervalMinutes = 1440 + connectionKeepAliveScanInterval = 30 * time.Second +) + +type cachedDatabaseKeepAliveTarget struct { + key string + inst db.Database + config connection.ConnectionConfig +} + +func resolveConnectionKeepAliveSettings(config connection.ConnectionConfig) (bool, time.Duration) { + if !config.KeepAliveEnabled || isFileDatabaseType(config.Type) { + return false, 0 + } + + minutes := config.KeepAliveIntervalMinutes + switch { + case minutes <= 0: + minutes = defaultConnectionKeepAliveIntervalMinutes + case minutes < minConnectionKeepAliveIntervalMinutes: + minutes = minConnectionKeepAliveIntervalMinutes + case minutes > maxConnectionKeepAliveIntervalMinutes: + minutes = maxConnectionKeepAliveIntervalMinutes + } + + return true, time.Duration(minutes) * time.Minute +} + +func (a *App) startConnectionKeepAliveLoop() { + if a == nil || a.keepAliveCancel != nil { + return + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + a.keepAliveCancel = cancel + a.keepAliveDone = done + + go func() { + defer close(done) + + ticker := time.NewTicker(connectionKeepAliveScanInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + a.runConnectionKeepAliveTick(now) + } + } + }() +} + +func (a *App) stopConnectionKeepAliveLoop() { + if a == nil { + return + } + + cancel := a.keepAliveCancel + done := a.keepAliveDone + a.keepAliveCancel = nil + a.keepAliveDone = nil + + if cancel != nil { + cancel() + } + if done != nil { + <-done + } +} + +func (a *App) runConnectionKeepAliveTick(now time.Time) { + for _, target := range a.collectDueConnectionKeepAliveTargets(now) { + if target.inst == nil { + continue + } + if err := target.inst.Ping(); err != nil { + if closed, summary := a.evictCachedDatabaseAfterKeepAliveFailure(target); closed { + logger.Warnf( + "连接保活失败,已清理缓存连接:%s 缓存Key=%s 原因=%s", + summary, + shortCacheKey(target.key), + normalizeErrorMessage(err), + ) + } + continue + } + a.markCachedDatabaseKeepAliveSuccess(target.key, target.inst, time.Now()) + } +} + +func (a *App) collectDueConnectionKeepAliveTargets(now time.Time) []cachedDatabaseKeepAliveTarget { + if a == nil { + return nil + } + + targets := make([]cachedDatabaseKeepAliveTarget, 0) + a.mu.Lock() + defer a.mu.Unlock() + + for key, entry := range a.dbCache { + if entry.inst == nil || !entry.keepAliveEnabled || entry.keepAliveInterval <= 0 || entry.keepAliveInFlight { + continue + } + if !entry.lastPing.IsZero() && now.Sub(entry.lastPing) < entry.keepAliveInterval { + continue + } + + entry.keepAliveInFlight = true + a.dbCache[key] = entry + targets = append(targets, cachedDatabaseKeepAliveTarget{ + key: key, + inst: entry.inst, + config: entry.config, + }) + } + + return targets +} + +func (a *App) markCachedDatabaseKeepAliveSuccess(key string, inst db.Database, pingedAt time.Time) { + if a == nil { + return + } + + a.mu.Lock() + defer a.mu.Unlock() + + entry, exists := a.dbCache[key] + if !exists || entry.inst != inst { + return + } + + entry.keepAliveInFlight = false + entry.lastPing = pingedAt + a.dbCache[key] = entry +} + +func (a *App) evictCachedDatabaseAfterKeepAliveFailure(target cachedDatabaseKeepAliveTarget) (bool, string) { + if a == nil { + return false, "" + } + + var ( + inst db.Database + summary string + ) + + a.mu.Lock() + entry, exists := a.dbCache[target.key] + if exists && entry.inst == target.inst { + inst = entry.inst + summary = formatConnSummary(entry.config) + delete(a.dbCache, target.key) + } + a.mu.Unlock() + + if inst == nil { + return false, "" + } + if closeErr := inst.Close(); closeErr != nil { + logger.Error(closeErr, "关闭保活失败的缓存连接时出错:缓存Key=%s", shortCacheKey(target.key)) + } + return true, summary +} diff --git a/internal/app/app_keepalive_test.go b/internal/app/app_keepalive_test.go new file mode 100644 index 0000000..b030e38 --- /dev/null +++ b/internal/app/app_keepalive_test.go @@ -0,0 +1,149 @@ +package app + +import ( + "errors" + "testing" + "time" + + "GoNavi-Wails/internal/connection" +) + +type keepAliveRecordingDB struct { + closed int + pings int + pingErr error +} + +func (f *keepAliveRecordingDB) Connect(config connection.ConnectionConfig) error { return nil } +func (f *keepAliveRecordingDB) Close() error { + f.closed++ + return nil +} +func (f *keepAliveRecordingDB) Ping() error { + f.pings++ + return f.pingErr +} +func (f *keepAliveRecordingDB) Query(query string) ([]map[string]interface{}, []string, error) { + return nil, nil, nil +} +func (f *keepAliveRecordingDB) Exec(query string) (int64, error) { return 0, nil } +func (f *keepAliveRecordingDB) GetDatabases() ([]string, error) { return nil, nil } +func (f *keepAliveRecordingDB) GetTables(dbName string) ([]string, error) { return nil, nil } +func (f *keepAliveRecordingDB) GetCreateStatement(dbName, tableName string) (string, error) { + return "", nil +} +func (f *keepAliveRecordingDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { + return nil, nil +} +func (f *keepAliveRecordingDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { + return nil, nil +} +func (f *keepAliveRecordingDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) { + return nil, nil +} +func (f *keepAliveRecordingDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) { + return nil, nil +} +func (f *keepAliveRecordingDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) { + return nil, nil +} + +func TestRunConnectionKeepAliveTick_PingsDueCachedConnection(t *testing.T) { + app := NewApp() + config := connection.ConnectionConfig{Type: "postgres", Host: "db.local", Port: 5432, User: "postgres"} + key := getCacheKey(config) + dbInst := &keepAliveRecordingDB{} + + app.dbCache[key] = cachedDatabase{ + inst: dbInst, + lastPing: time.Now().Add(-5 * time.Hour), + config: normalizeCacheKeyConfig(config), + keepAliveEnabled: true, + keepAliveInterval: 4 * time.Hour, + } + + app.runConnectionKeepAliveTick(time.Now()) + + if dbInst.pings != 1 { + t.Fatalf("expected keepalive ping once, got %d", dbInst.pings) + } + + entry := app.dbCache[key] + if entry.keepAliveInFlight { + t.Fatal("expected keepalive in-flight flag to be cleared") + } + if entry.lastPing.IsZero() { + t.Fatal("expected keepalive success to update lastPing") + } +} + +func TestRunConnectionKeepAliveTick_RemovesFailedCachedConnection(t *testing.T) { + app := NewApp() + config := connection.ConnectionConfig{Type: "postgres", Host: "db.local", Port: 5432, User: "postgres"} + key := getCacheKey(config) + dbInst := &keepAliveRecordingDB{pingErr: errors.New("token expired")} + + app.dbCache[key] = cachedDatabase{ + inst: dbInst, + lastPing: time.Now().Add(-5 * time.Hour), + config: normalizeCacheKeyConfig(config), + keepAliveEnabled: true, + keepAliveInterval: 4 * time.Hour, + } + + app.runConnectionKeepAliveTick(time.Now()) + + if dbInst.pings != 1 { + t.Fatalf("expected keepalive ping once, got %d", dbInst.pings) + } + if dbInst.closed != 1 { + t.Fatalf("expected failed cached connection to be closed once, got %d", dbInst.closed) + } + if len(app.dbCache) != 0 { + t.Fatalf("expected failed cached connection to be evicted, got %d entries", len(app.dbCache)) + } +} + +func TestGetDatabaseWithPing_UpdatesCachedKeepAliveSettings(t *testing.T) { + originalDriverRuntimeSupportStatusFunc := driverRuntimeSupportStatusFunc + defer func() { + driverRuntimeSupportStatusFunc = originalDriverRuntimeSupportStatusFunc + }() + driverRuntimeSupportStatusFunc = func(dbType string) (bool, string) { + return true, "" + } + + app := NewApp() + config := connection.ConnectionConfig{ + Type: "postgres", + Host: "db.local", + Port: 5432, + User: "postgres", + KeepAliveEnabled: true, + KeepAliveIntervalMinutes: 15, + } + key := getCacheKey(config) + dbInst := &keepAliveRecordingDB{} + + app.dbCache[key] = cachedDatabase{ + inst: dbInst, + lastPing: time.Now(), + config: normalizeCacheKeyConfig(config), + } + + inst, err := app.getDatabaseWithPing(config, false) + if err != nil { + t.Fatalf("expected cached database lookup to succeed, got %v", err) + } + if inst != dbInst { + t.Fatal("expected cached database instance to be reused") + } + + entry := app.dbCache[key] + if !entry.keepAliveEnabled { + t.Fatal("expected cached keepalive to be enabled from config") + } + if entry.keepAliveInterval != 15*time.Minute { + t.Fatalf("expected keepalive interval 15m, got %s", entry.keepAliveInterval) + } +} diff --git a/internal/connection/types.go b/internal/connection/types.go index 5968f0b..c2a8f32 100644 --- a/internal/connection/types.go +++ b/internal/connection/types.go @@ -79,48 +79,50 @@ type JVMConfig struct { // ConnectionConfig 存储数据库连接的完整配置,包括 SSH、代理、SSL 等网络层设置。 type ConnectionConfig struct { - ID string `json:"id,omitempty"` - Type string `json:"type"` - Host string `json:"host"` - Port int `json:"port"` - User string `json:"user"` - Password string `json:"password"` - SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection - Database string `json:"database"` - UseSSL bool `json:"useSSL,omitempty"` // MySQL-like SSL/TLS switch - SSLMode string `json:"sslMode,omitempty"` // preferred | required | skip-verify | disable - SSLCAPath string `json:"sslCAPath,omitempty"` // TLS root CA / server certificate path - SSLCertPath string `json:"sslCertPath,omitempty"` // TLS client certificate path (e.g., Dameng) - SSLKeyPath string `json:"sslKeyPath,omitempty"` // TLS client private key path (e.g., Dameng) - UseSSH bool `json:"useSSH"` - SSH SSHConfig `json:"ssh"` - UseProxy bool `json:"useProxy,omitempty"` - Proxy ProxyConfig `json:"proxy,omitempty"` - UseHTTPTunnel bool `json:"useHttpTunnel,omitempty"` - HTTPTunnel HTTPTunnelConfig `json:"httpTunnel,omitempty"` - Driver string `json:"driver,omitempty"` // For custom connection - DSN string `json:"dsn,omitempty"` // For custom connection - ConnectionParams string `json:"connectionParams,omitempty"` // Extra URI query parameters for built-in drivers - Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30) - RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15) - RedisSentinelMaster string `json:"redisSentinelMaster,omitempty"` // Redis Sentinel master name - RedisSentinelUser string `json:"redisSentinelUser,omitempty"` // Redis Sentinel auth user - RedisSentinelPassword string `json:"redisSentinelPassword,omitempty"` // Redis Sentinel auth password - URI string `json:"uri,omitempty"` // Connection URI for copy/paste - ClickHouseProtocol string `json:"clickHouseProtocol,omitempty"` // auto | http | native - OceanBaseProtocol string `json:"oceanBaseProtocol,omitempty"` // OceanBase tenant compatibility protocol: mysql | oracle - Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port - Topology string `json:"topology,omitempty"` // single | replica | cluster | sentinel - MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user - MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password - ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name - AuthSource string `json:"authSource,omitempty"` // MongoDB authSource - ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference - MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme - MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism - MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user - MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password - JVM JVMConfig `json:"jvm,omitempty"` // JVM connector config + ID string `json:"id,omitempty"` + Type string `json:"type"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Password string `json:"password"` + SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection + Database string `json:"database"` + UseSSL bool `json:"useSSL,omitempty"` // MySQL-like SSL/TLS switch + SSLMode string `json:"sslMode,omitempty"` // preferred | required | skip-verify | disable + SSLCAPath string `json:"sslCAPath,omitempty"` // TLS root CA / server certificate path + SSLCertPath string `json:"sslCertPath,omitempty"` // TLS client certificate path (e.g., Dameng) + SSLKeyPath string `json:"sslKeyPath,omitempty"` // TLS client private key path (e.g., Dameng) + UseSSH bool `json:"useSSH"` + SSH SSHConfig `json:"ssh"` + UseProxy bool `json:"useProxy,omitempty"` + Proxy ProxyConfig `json:"proxy,omitempty"` + UseHTTPTunnel bool `json:"useHttpTunnel,omitempty"` + HTTPTunnel HTTPTunnelConfig `json:"httpTunnel,omitempty"` + Driver string `json:"driver,omitempty"` // For custom connection + DSN string `json:"dsn,omitempty"` // For custom connection + ConnectionParams string `json:"connectionParams,omitempty"` // Extra URI query parameters for built-in drivers + Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30) + KeepAliveEnabled bool `json:"keepAliveEnabled,omitempty"` // Enable background keep-alive ping for long-lived cached connections + KeepAliveIntervalMinutes int `json:"keepAliveIntervalMinutes,omitempty"` // Keep-alive ping interval in minutes (default: 240) + RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15) + RedisSentinelMaster string `json:"redisSentinelMaster,omitempty"` // Redis Sentinel master name + RedisSentinelUser string `json:"redisSentinelUser,omitempty"` // Redis Sentinel auth user + RedisSentinelPassword string `json:"redisSentinelPassword,omitempty"` // Redis Sentinel auth password + URI string `json:"uri,omitempty"` // Connection URI for copy/paste + ClickHouseProtocol string `json:"clickHouseProtocol,omitempty"` // auto | http | native + OceanBaseProtocol string `json:"oceanBaseProtocol,omitempty"` // OceanBase tenant compatibility protocol: mysql | oracle + Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port + Topology string `json:"topology,omitempty"` // single | replica | cluster | sentinel + MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user + MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password + ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name + AuthSource string `json:"authSource,omitempty"` // MongoDB authSource + ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference + MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme + MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism + MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user + MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password + JVM JVMConfig `json:"jvm,omitempty"` // JVM connector config } // ResultSetData 表示一个查询结果集(行 + 列名),用于多结果集场景。 diff --git a/shared/i18n/messages.ts b/shared/i18n/messages.ts index 30e4e2b..738a362 100644 --- a/shared/i18n/messages.ts +++ b/shared/i18n/messages.ts @@ -644,6 +644,14 @@ export const messages: Record> = { "connection.modal.network.timeout.label": "连接超时 (秒)", "connection.modal.network.timeout.help": "数据库连接超时时间,默认 30 秒", "connection.modal.network.timeout.range": "超时时间范围: 1-300 秒", + "connection.modal.network.keepAliveEnabled.checkbox": "启用后台定时探活保活", + "connection.modal.network.keepAliveEnabled.help": + "仅在跳板机 token 或长连接会话需要定期续期时开启。", + "connection.modal.network.keepAliveInterval.label": "探活间隔 (分钟)", + "connection.modal.network.keepAliveInterval.help": + "后台会按这个间隔对已建立的缓存连接执行 Ping,默认 240 分钟。", + "connection.modal.network.keepAliveInterval.range": + "探活间隔范围: 1-1440 分钟", "connection.modal.appearance.title": "外观", "connection.modal.appearance.description": "自定义图标与颜色", "connection.modal.appearance.icon": "图标", @@ -1534,6 +1542,16 @@ export const messages: Record> = { "Database connection timeout. Default is 30 seconds.", "connection.modal.network.timeout.range": "Timeout must be between 1 and 300 seconds.", + "connection.modal.network.keepAliveEnabled.checkbox": + "Enable background keep-alive ping", + "connection.modal.network.keepAliveEnabled.help": + "Enable this only when a jump-host token or long-lived session needs periodic renewal.", + "connection.modal.network.keepAliveInterval.label": + "Keep-alive interval (minutes)", + "connection.modal.network.keepAliveInterval.help": + "GoNavi pings established cached connections at this interval. Default is 240 minutes.", + "connection.modal.network.keepAliveInterval.range": + "Keep-alive interval must be between 1 and 1440 minutes.", "connection.modal.appearance.title": "Appearance", "connection.modal.appearance.description": "Custom icon and color", "connection.modal.appearance.icon": "Icon",