= ({
alignItems: "center",
}}
>
-
= ({
停止
)}
-
+
{isV2Ui && pendingTransactionToolbar}
({
DBGetTables: mocks.noop,
DBQuery: mocks.noop,
DBShowCreateTable: mocks.noop,
+ DBReleaseConnection: mocks.noop,
ExportTable: mocks.noop,
OpenSQLFile: mocks.noop,
ExecuteSQLFile: mocks.noop,
@@ -498,6 +499,18 @@ describe('Sidebar locate toolbar', () => {
expect(source).toContain('}> = React.memo(({');
});
+ it('releases backend database connections when disconnecting a sidebar connection', () => {
+ const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
+ const disconnectSource = source.slice(
+ source.indexOf('const releaseConnectionResources = async'),
+ source.indexOf('const deleteConnectionNode ='),
+ );
+
+ expect(source).toContain('DBReleaseConnection');
+ expect(disconnectSource).toContain('await releaseConnectionResources(conn);');
+ expect(source.match(/onClick: \(\) => void disconnectConnectionNode\(node\)/g)).toHaveLength(2);
+ });
+
it('renders the current table locate action in the sidebar toolbar', () => {
const markup = renderToStaticMarkup();
const externalSqlActionIndex = markup.indexOf('data-sidebar-open-external-sql-file-action="true"');
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index a5b8d3e..5688f2e 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -55,7 +55,7 @@ import {
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types';
import { getDbIcon } from './DatabaseIcons';
- import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, DeleteSQLDirectory, RenameSQLFile, RenameSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App';
+ import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, DeleteSQLDirectory, RenameSQLFile, RenameSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
@@ -5095,9 +5095,18 @@ const Sidebar: React.FC<{
loadDatabases(node);
};
- const disconnectConnectionNode = (node: any) => {
+ const releaseConnectionResources = async (conn: SavedConnection | undefined) => {
+ if (!conn?.config) return;
+ const res = await DBReleaseConnection(buildRpcConnectionConfig(conn.config, { id: conn.id }) as any);
+ if (res && res.success === false) {
+ throw new Error(res.message || '释放连接失败');
+ }
+ };
+
+ const disconnectConnectionNode = async (node: any) => {
const connKey = String(node?.key || node?.dataRef?.id || '');
if (!connKey) return;
+ const conn = (connections.find((item) => item.id === connKey) || node?.dataRef) as SavedConnection | undefined;
Array.from(loadingNodesRef.current).forEach((loadingKey) => {
if (loadingKey === `dbs-${connKey}` || loadingKey.startsWith(`tables-${connKey}-`)) {
loadingNodesRef.current.delete(loadingKey);
@@ -5116,6 +5125,11 @@ const Sidebar: React.FC<{
setLoadedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`)));
replaceTreeNodeChildren(connKey, undefined);
closeTabsByConnection(connKey);
+ try {
+ await releaseConnectionResources(conn);
+ } catch (error: any) {
+ message.warning(error?.message || '连接已从侧边栏断开,但后端连接释放失败');
+ }
message.success("已断开连接");
};
@@ -5205,7 +5219,7 @@ const Sidebar: React.FC<{
void handleDuplicateConnection(node.dataRef as SavedConnection);
return;
case 'disconnect':
- disconnectConnectionNode(node);
+ void disconnectConnectionNode(node);
return;
case 'delete':
deleteConnectionNode(node);
@@ -6849,22 +6863,7 @@ const Sidebar: React.FC<{
key: 'disconnect',
label: '断开连接',
icon: ,
- onClick: () => {
- setConnectionStates(prev => {
- const next = { ...prev };
- Object.keys(next).forEach(k => {
- if (k === node.key || k.startsWith(`${node.key}-`)) {
- delete next[k];
- }
- });
- return next;
- });
- setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
- setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
- replaceTreeNodeChildren(node.key, undefined);
- closeTabsByConnection(String(node.key));
- message.success("已断开连接");
- }
+ onClick: () => void disconnectConnectionNode(node)
},
{
key: 'delete',
@@ -6989,33 +6988,7 @@ const Sidebar: React.FC<{
key: 'disconnect',
label: '断开连接',
icon: ,
- onClick: () => {
- const connId = String(node.key || '');
- // 强制清理该连接相关的 loading 标记,避免网络卡住后重连仍被短路。
- Array.from(loadingNodesRef.current).forEach((loadingKey) => {
- if (loadingKey === `dbs-${connId}` || loadingKey.startsWith(`tables-${connId}-`)) {
- loadingNodesRef.current.delete(loadingKey);
- }
- });
- // Reset status recursively
- setConnectionStates(prev => {
- const next = { ...prev };
- Object.keys(next).forEach(k => {
- if (k === node.key || k.startsWith(`${node.key}-`)) {
- delete next[k];
- }
- });
- return next;
- });
- // Collapse node and children
- setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
- // Reset loaded state recursively
- setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
- // Clear children (undefined to trigger reload)
- replaceTreeNodeChildren(node.key, undefined);
- closeTabsByConnection(String(node.key));
- message.success("已断开连接");
- }
+ onClick: () => void disconnectConnectionNode(node)
},
{
key: 'delete',
diff --git a/frontend/src/v2-theme.css b/frontend/src/v2-theme.css
index 23c8c76..4600679 100644
--- a/frontend/src/v2-theme.css
+++ b/frontend/src/v2-theme.css
@@ -4886,7 +4886,7 @@ body[data-ui-version="v2"] .gn-v2-query-toolbar .ant-btn {
font-size: 12.5px !important;
}
-body[data-ui-version="v2"] .gn-v2-query-toolbar-action-group.ant-btn-group {
+body[data-ui-version="v2"] .gn-v2-query-toolbar-action-group {
display: inline-flex !important;
align-items: center;
flex: 0 0 auto;
@@ -4900,16 +4900,16 @@ body[data-ui-version="v2"] .gn-v2-query-toolbar-action-pair {
gap: 8px;
}
-body[data-ui-version="v2"] .gn-v2-query-toolbar-action-group.ant-btn-group > .ant-btn {
+body[data-ui-version="v2"] .gn-v2-query-toolbar-action-group > .ant-btn {
flex: 0 0 auto;
border-radius: 9px !important;
}
-body[data-ui-version="v2"] .gn-v2-query-toolbar-action-group.ant-btn-group > .ant-btn:not(:first-child) {
+body[data-ui-version="v2"] .gn-v2-query-toolbar-action-group > .ant-btn:not(:first-child) {
margin-left: 0 !important;
}
-body[data-ui-version="v2"] .gn-v2-query-toolbar-action-group.ant-btn-group > .ant-btn::before {
+body[data-ui-version="v2"] .gn-v2-query-toolbar-action-group > .ant-btn::before {
display: none !important;
}
diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts
index 6983eda..386c854 100755
--- a/frontend/wailsjs/go/app/App.d.ts
+++ b/frontend/wailsjs/go/app/App.d.ts
@@ -62,6 +62,8 @@ export function DBQueryMultiTransactional(arg1:connection.ConnectionConfig,arg2:
export function DBQueryWithCancel(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise;
+export function DBReleaseConnection(arg1:connection.ConnectionConfig):Promise;
+
export function DBRollbackTransaction(arg1:string):Promise;
export function DBShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise;
diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js
index 72f4c99..8b7f9b7 100755
--- a/frontend/wailsjs/go/app/App.js
+++ b/frontend/wailsjs/go/app/App.js
@@ -114,6 +114,10 @@ export function DBQueryWithCancel(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['DBQueryWithCancel'](arg1, arg2, arg3, arg4);
}
+export function DBReleaseConnection(arg1) {
+ return window['go']['app']['App']['DBReleaseConnection'](arg1);
+}
+
export function DBRollbackTransaction(arg1) {
return window['go']['app']['App']['DBRollbackTransaction'](arg1);
}
diff --git a/internal/app/app.go b/internal/app/app.go
index 4b9b0dd..a033129 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -43,6 +43,7 @@ var (
type cachedDatabase struct {
inst db.Database
lastPing time.Time
+ config connection.ConnectionConfig
}
type cachedConnectFailure struct {
@@ -189,6 +190,7 @@ func (a *App) Shutdown() {
logger.Error(err, "关闭数据库连接失败")
}
}
+ a.dbCache = make(map[string]cachedDatabase)
proxytunnel.CloseAllForwarders()
// Close all Redis connections
CloseAllRedisClients()
@@ -291,6 +293,20 @@ func getCacheKey(config connection.ConnectionConfig) string {
return hex.EncodeToString(sum[:])
}
+func normalizeConnectionReleaseMatchConfig(config connection.ConnectionConfig) connection.ConnectionConfig {
+ normalized := normalizeCacheKeyConfig(config)
+ normalized.Database = ""
+ normalized.RedisDB = 0
+ return normalized
+}
+
+func getConnectionReleaseMatchKey(config connection.ConnectionConfig) string {
+ normalized := normalizeConnectionReleaseMatchConfig(config)
+ b, _ := json.Marshal(normalized)
+ sum := sha256.Sum256(b)
+ return hex.EncodeToString(sum[:])
+}
+
func shortCacheKey(cacheKey string) string {
shortKey := cacheKey
if len(shortKey) > 12 {
@@ -726,7 +742,7 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
}
return existing.inst, nil
}
- a.dbCache[key] = cachedDatabase{inst: dbInst, lastPing: now}
+ a.dbCache[key] = cachedDatabase{inst: dbInst, lastPing: now, config: normalizeCacheKeyConfig(effectiveConfig)}
a.mu.Unlock()
logger.Infof("数据库连接成功并写入缓存:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go
index 2018134..67c1001 100644
--- a/internal/app/methods_db.go
+++ b/internal/app/methods_db.go
@@ -63,6 +63,50 @@ func (a *App) DBConnect(config connection.ConnectionConfig) connection.QueryResu
return connection.QueryResult{Success: true, Message: "连接成功"}
}
+func (a *App) DBReleaseConnection(config connection.ConnectionConfig) connection.QueryResult {
+ dbType := strings.ToLower(strings.TrimSpace(config.Type))
+ if dbType == "redis" {
+ closed, err := a.releaseRedisClientsForConfig(config)
+ if err != nil {
+ logger.Error(err, "DBReleaseConnection 释放 Redis 连接失败:%s", formatConnSummary(config))
+ return connection.QueryResult{Success: false, Message: err.Error()}
+ }
+ logger.Infof("DBReleaseConnection 已释放 Redis 连接:%s 数量=%d", formatConnSummary(config), closed)
+ return connection.QueryResult{Success: true, Message: "连接已释放", Data: map[string]int{"closed": closed}}
+ }
+
+ resolvedConfig, err := a.resolveConnectionSecrets(config)
+ if err != nil {
+ wrapped := wrapConnectError(config, err)
+ logger.Error(wrapped, "DBReleaseConnection 解析连接密文失败:%s", formatConnSummary(config))
+ return connection.QueryResult{Success: false, Message: wrapped.Error()}
+ }
+ targetKey := getConnectionReleaseMatchKey(applyGlobalProxyToConnection(resolvedConfig))
+ closed := 0
+
+ a.mu.Lock()
+ for key, entry := range a.dbCache {
+ entryConfig := entry.config
+ if strings.TrimSpace(entryConfig.Type) == "" {
+ continue
+ }
+ if getConnectionReleaseMatchKey(entryConfig) != targetKey {
+ continue
+ }
+ if entry.inst != nil {
+ if closeErr := entry.inst.Close(); closeErr != nil {
+ logger.Error(closeErr, "DBReleaseConnection 关闭缓存连接失败:缓存Key=%s", shortCacheKey(key))
+ }
+ }
+ delete(a.dbCache, key)
+ closed++
+ }
+ a.mu.Unlock()
+
+ logger.Infof("DBReleaseConnection 已释放数据库连接:%s 数量=%d", formatConnSummary(resolvedConfig), closed)
+ return connection.QueryResult{Success: true, Message: "连接已释放", Data: map[string]int{"closed": closed}}
+}
+
func (a *App) TestConnection(config connection.ConnectionConfig) connection.QueryResult {
testConfig := normalizeTestConnectionConfig(config)
started := time.Now()
diff --git a/internal/app/methods_db_conn_test.go b/internal/app/methods_db_conn_test.go
index ec99d06..c6ab499 100644
--- a/internal/app/methods_db_conn_test.go
+++ b/internal/app/methods_db_conn_test.go
@@ -7,6 +7,41 @@ import (
"GoNavi-Wails/internal/connection"
)
+type releaseRecordingDB struct {
+ closed int
+}
+
+func (f *releaseRecordingDB) Connect(config connection.ConnectionConfig) error { return nil }
+func (f *releaseRecordingDB) Close() error {
+ f.closed++
+ return nil
+}
+func (f *releaseRecordingDB) Ping() error { return nil }
+func (f *releaseRecordingDB) Query(query string) ([]map[string]interface{}, []string, error) {
+ return nil, nil, nil
+}
+func (f *releaseRecordingDB) Exec(query string) (int64, error) { return 0, nil }
+func (f *releaseRecordingDB) GetDatabases() ([]string, error) { return nil, nil }
+func (f *releaseRecordingDB) GetTables(dbName string) ([]string, error) { return nil, nil }
+func (f *releaseRecordingDB) GetCreateStatement(dbName, tableName string) (string, error) {
+ return "", nil
+}
+func (f *releaseRecordingDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
+ return nil, nil
+}
+func (f *releaseRecordingDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
+ return nil, nil
+}
+func (f *releaseRecordingDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
+ return nil, nil
+}
+func (f *releaseRecordingDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
+ return nil, nil
+}
+func (f *releaseRecordingDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
+ return nil, nil
+}
+
func TestNormalizeTestConnectionConfig_CapsTimeout(t *testing.T) {
cfg := connection.ConnectionConfig{Timeout: 60}
got := normalizeTestConnectionConfig(cfg)
@@ -130,3 +165,44 @@ func TestFormatConnSummary_DefaultTimeout(t *testing.T) {
t.Fatalf("formatConnSummary 默认超时应为30s, got=%q", got)
}
}
+
+func TestDBReleaseConnectionClosesAllDatabaseCacheEntriesForSameInstance(t *testing.T) {
+ app := NewApp()
+ mainConfig := connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, User: "root", Database: "main"}
+ analyticsConfig := mainConfig
+ analyticsConfig.Database = "analytics"
+ otherConfig := mainConfig
+ otherConfig.Port = 3307
+ otherConfig.Database = "main"
+
+ mainDB := &releaseRecordingDB{}
+ analyticsDB := &releaseRecordingDB{}
+ otherDB := &releaseRecordingDB{}
+
+ app.dbCache[getCacheKey(mainConfig)] = cachedDatabase{
+ inst: mainDB,
+ config: normalizeCacheKeyConfig(mainConfig),
+ }
+ app.dbCache[getCacheKey(analyticsConfig)] = cachedDatabase{
+ inst: analyticsDB,
+ config: normalizeCacheKeyConfig(analyticsConfig),
+ }
+ app.dbCache[getCacheKey(otherConfig)] = cachedDatabase{
+ inst: otherDB,
+ config: normalizeCacheKeyConfig(otherConfig),
+ }
+
+ result := app.DBReleaseConnection(connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, User: "root"})
+ if !result.Success {
+ t.Fatalf("expected release success, got %s", result.Message)
+ }
+ if mainDB.closed != 1 || analyticsDB.closed != 1 {
+ t.Fatalf("expected both same-instance cached connections closed, got main=%d analytics=%d", mainDB.closed, analyticsDB.closed)
+ }
+ if otherDB.closed != 0 {
+ t.Fatalf("expected other instance cache to remain open, got closed=%d", otherDB.closed)
+ }
+ if len(app.dbCache) != 1 {
+ t.Fatalf("expected only unrelated cache entry to remain, got %d", len(app.dbCache))
+ }
+}
diff --git a/internal/app/methods_redis.go b/internal/app/methods_redis.go
index 8ac2136..f15e03b 100644
--- a/internal/app/methods_redis.go
+++ b/internal/app/methods_redis.go
@@ -19,6 +19,7 @@ import (
// Redis client cache
var (
redisCache = make(map[string]redis.RedisClient)
+ redisCacheConfigs = make(map[string]connection.ConnectionConfig)
redisCacheMu sync.Mutex
newRedisClientFunc = redis.NewRedisClient
)
@@ -60,6 +61,7 @@ func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisCli
}
client.Close()
delete(redisCache, key)
+ delete(redisCacheConfigs, key)
}
logger.Infof("创建 Redis 客户端实例:缓存Key=%s", shortKey)
@@ -71,6 +73,7 @@ func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisCli
}
redisCache[key] = client
+ redisCacheConfigs[key] = normalizeCacheKeyConfig(connectedConfig)
logger.Infof("Redis 连接成功并写入缓存:%s 缓存Key=%s", formatRedisConnSummary(connectedConfig), shortKey)
return client, nil
}
@@ -142,6 +145,35 @@ func getRedisClientCacheKey(config connection.ConnectionConfig) string {
return hex.EncodeToString(sum[:])
}
+func (a *App) releaseRedisClientsForConfig(config connection.ConnectionConfig) (int, error) {
+ resolvedConfig, err := a.resolveConnectionSecrets(config)
+ if err != nil {
+ return 0, wrapConnectError(config, err)
+ }
+ targetKey := getConnectionReleaseMatchKey(applyGlobalProxyToConnection(resolvedConfig))
+ closed := 0
+
+ redisCacheMu.Lock()
+ defer redisCacheMu.Unlock()
+
+ for key, client := range redisCache {
+ entryConfig := redisCacheConfigs[key]
+ if strings.TrimSpace(entryConfig.Type) == "" {
+ continue
+ }
+ if getConnectionReleaseMatchKey(entryConfig) != targetKey {
+ continue
+ }
+ if client != nil {
+ client.Close()
+ }
+ delete(redisCache, key)
+ delete(redisCacheConfigs, key)
+ closed++
+ }
+ return closed, nil
+}
+
func formatRedisConnSummary(config connection.ConnectionConfig) string {
var b strings.Builder
b.WriteString("类型=redis 地址=")
@@ -759,4 +791,5 @@ func CloseAllRedisClients() {
}
}
redisCache = make(map[string]redis.RedisClient)
+ redisCacheConfigs = make(map[string]connection.ConnectionConfig)
}