feat(db-connection): 新增连接后台定时探活保活能力

- 为连接配置新增 keepAlive 开关与探活间隔
- 在后端缓存连接上增加后台定时 Ping 调度与失效剔除
- 补充前端表单、Wails 模型映射与定向测试覆盖

Close #579
This commit is contained in:
Syngnat
2026-06-20 11:24:44 +08:00
parent 0f67305100
commit e7935db84b
15 changed files with 1490 additions and 852 deletions

View File

@@ -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",

View File

@@ -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<string, any>;
@@ -53,6 +56,7 @@ const ConnectionModalNetworkSecuritySection: React.FC<ConnectionModalNetworkSecu
const effectiveUseProxy =
!effectiveUseHttpTunnel &&
(useProxy || !!form.getFieldValue("useProxy"));
const keepAliveEnabled = !!Form.useWatch("keepAliveEnabled", form);
const networkItems: Array<{
key: "ssl" | "ssh" | "proxy" | "httpTunnel";
title: string;
@@ -993,6 +997,40 @@ const ConnectionModalNetworkSecuritySection: React.FC<ConnectionModalNetworkSecu
})}
/>
</Form.Item>
<Form.Item
name="keepAliveEnabled"
valuePropName="checked"
help={t("connection.modal.network.keepAliveEnabled.help")}
style={{ marginTop: 12, marginBottom: 12 }}
>
<Checkbox>
{t("connection.modal.network.keepAliveEnabled.checkbox")}
</Checkbox>
</Form.Item>
<Form.Item
name="keepAliveIntervalMinutes"
label={t("connection.modal.network.keepAliveInterval.label")}
help={t("connection.modal.network.keepAliveInterval.help")}
rules={[
{
type: "number",
min: MIN_KEEPALIVE_INTERVAL_MINUTES,
max: MAX_KEEPALIVE_INTERVAL_MINUTES,
message: t("connection.modal.network.keepAliveInterval.range"),
},
]}
style={{ marginBottom: 0 }}
>
<InputNumber
style={{ width: "100%" }}
min={MIN_KEEPALIVE_INTERVAL_MINUTES}
max={MAX_KEEPALIVE_INTERVAL_MINUTES}
disabled={!keepAliveEnabled}
placeholder={t("connection.modal.example", {
value: String(DEFAULT_KEEPALIVE_INTERVAL_MINUTES),
})}
/>
</Form.Item>
</div>
</div>
);

View File

@@ -2301,6 +2301,8 @@ const renderStep2 = () => {
useHttpTunnel: false,
httpTunnelPort: 8080,
timeout: 30,
keepAliveEnabled: false,
keepAliveIntervalMinutes: 240,
uri: "",
connectionParams: "",
oceanBaseProtocol: "mysql",

View File

@@ -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);
});
});

View File

@@ -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();

View File

@@ -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") {

View File

@@ -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

View File

@@ -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"];

View File

@@ -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)

View File

@@ -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",

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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 表示一个查询结果集(行 + 列名),用于多结果集场景。

View File

@@ -644,6 +644,14 @@ export const messages: Record<SupportedLanguage, Record<MessageKey, string>> = {
"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<SupportedLanguage, Record<MessageKey, string>> = {
"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",