mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 01:49:42 +08:00
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容 - DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败 - DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试 - 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致 - 增强查询异常日志与重试路径,降低大表场景卡顿与误报 * ✨ feat(frontend-driver): 驱动管理支持快速搜索并优化信息展示 - 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动 - 显示“匹配 x / y”统计与无结果提示 - 优化头部区域排版,提升透明/暗色场景下的视觉对齐 * 🔧 fix(connection-modal): 修复多数据源URI导入解析并校正Oracle服务名校验 - 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle - 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为 - Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑 - 连接弹窗补充 Oracle 服务名输入项与 URI 示例 * 🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径 - 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈 - DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级 - QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致 - 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性 * 🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失 - 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度 - 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串 - 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页 - refs #142 * 🐛 fix(driver-manager): 修复驱动管理网络告警重复并强化代理引导 - 新增下载链路域名探测,区分“GitHub可达但驱动下载链路不可达” - 网络不可达场景仅保留红色强提醒,移除重复二级告警 - 强提醒增加“打开全局代理设置”入口,优先引导使用 GoNavi 全局代理 - 统一网络检测与目录说明提示图标尺寸,修复加载期视觉不一致 - refs #141 * ♻️ refactor(frontend-interaction): 统一标签拖拽与暗色主题交互实现 - 重构Tab拖拽排序实现,统一为可配置拖拽引擎 - 规范拖拽与点击事件边界,提升交互一致性 - 统一多组件暗色透明样式策略,减少硬编码色值 - 提升Redis/表格/连接面板在透明模式下的观感一致性 - refs #144 * ♻️ refactor(update-state): 重构在线更新状态流并按版本统一进度展示 - 重构更新检查与下载状态同步流程,减少前后端状态分叉 - 进度展示严格绑定 latestVersion,避免跨版本状态串用 - 优化 about 打开场景的静默检查状态回填逻辑 - 统一下载弹窗关闭/后台隐藏行为 - 保持现有安装流程并补齐目录打开能力 * 🎨 style(sidebar-log): 将SQL执行日志入口调整为悬浮胶囊样式 - 移除侧栏底部整条日志入口容器 - 新增悬浮按钮阴影/边框/透明背景并适配明暗主题 - 为树区域预留底部空间避免入口遮挡内容 * ✨ feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换 - 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示 - 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离 - 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则 - 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空 - refs #145 * ✨ feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复 - 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题 - 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM - 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条 - 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动) - 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题 - 新增白色主题全局滚动条样式适配透明模式(App.css) - App.tsx主题token与组件样式优化 - refs #147 * 🔧 chore(app): 清理 App.tsx 类型告警并收敛前端壳层实现 - 清除未使用代码和冗余状态 - 替换弃用 API 以消除 IDE 提示 - 显式处理浮动 Promise 避免告警 - 保持现有更新检查和代理设置行为不变 --------- Co-authored-by: Syngnat <yangguofeng919@gmail.com>
411 lines
13 KiB
Go
411 lines
13 KiB
Go
package app
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"GoNavi-Wails/internal/connection"
|
||
"GoNavi-Wails/internal/db"
|
||
"GoNavi-Wails/internal/logger"
|
||
proxytunnel "GoNavi-Wails/internal/proxy"
|
||
)
|
||
|
||
const dbCachePingInterval = 30 * time.Second
|
||
|
||
type cachedDatabase struct {
|
||
inst db.Database
|
||
lastPing time.Time
|
||
}
|
||
|
||
// App struct
|
||
type App struct {
|
||
ctx context.Context
|
||
dbCache map[string]cachedDatabase // Cache for DB connections
|
||
mu sync.RWMutex // Mutex for cache access
|
||
updateMu sync.Mutex
|
||
updateState updateState
|
||
}
|
||
|
||
// NewApp creates a new App application struct
|
||
func NewApp() *App {
|
||
return &App{
|
||
dbCache: make(map[string]cachedDatabase),
|
||
}
|
||
}
|
||
|
||
// Startup is called when the app starts. The context is saved
|
||
// so we can call the runtime methods
|
||
func (a *App) Startup(ctx context.Context) {
|
||
a.ctx = ctx
|
||
logger.Init()
|
||
applyMacWindowTranslucencyFix()
|
||
logger.Infof("应用启动完成")
|
||
}
|
||
|
||
// SetWindowTranslucency 动态调整 macOS 窗口透明度。
|
||
// 前端在加载用户外观设置后、以及用户修改外观时调用此方法。
|
||
// opacity=1.0 且 blur=0 时窗口标记为 opaque,GPU 不再持续计算窗口背后的模糊合成。
|
||
func (a *App) SetWindowTranslucency(opacity float64, blur float64) {
|
||
setMacWindowTranslucency(opacity, blur)
|
||
}
|
||
|
||
// Shutdown is called when the app terminates
|
||
func (a *App) Shutdown(ctx context.Context) {
|
||
logger.Infof("应用开始关闭,准备释放资源")
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
for _, dbInst := range a.dbCache {
|
||
if err := dbInst.inst.Close(); err != nil {
|
||
logger.Error(err, "关闭数据库连接失败")
|
||
}
|
||
}
|
||
proxytunnel.CloseAllForwarders()
|
||
// Close all Redis connections
|
||
CloseAllRedisClients()
|
||
logger.Infof("资源释放完成,应用已关闭")
|
||
logger.Close()
|
||
}
|
||
|
||
func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.ConnectionConfig {
|
||
normalized := config
|
||
normalized.Type = strings.ToLower(strings.TrimSpace(normalized.Type))
|
||
// timeout 仅用于 Query/Ping 控制,不应作为物理连接复用键的一部分。
|
||
normalized.Timeout = 0
|
||
normalized.SavePassword = false
|
||
|
||
if !normalized.UseSSH {
|
||
normalized.SSH = connection.SSHConfig{}
|
||
}
|
||
if !normalized.UseProxy {
|
||
normalized.Proxy = connection.ProxyConfig{}
|
||
}
|
||
|
||
if isFileDatabaseType(normalized.Type) {
|
||
dsn := strings.TrimSpace(normalized.Host)
|
||
if dsn == "" {
|
||
dsn = strings.TrimSpace(normalized.Database)
|
||
}
|
||
if dsn == "" {
|
||
dsn = ":memory:"
|
||
}
|
||
|
||
// DuckDB/SQLite 仅基于文件来源识别连接,其他网络字段不参与键计算。
|
||
normalized.Host = dsn
|
||
normalized.Database = ""
|
||
normalized.Port = 0
|
||
normalized.User = ""
|
||
normalized.Password = ""
|
||
normalized.URI = ""
|
||
normalized.Hosts = nil
|
||
normalized.Topology = ""
|
||
normalized.MySQLReplicaUser = ""
|
||
normalized.MySQLReplicaPassword = ""
|
||
normalized.ReplicaSet = ""
|
||
normalized.AuthSource = ""
|
||
normalized.ReadPreference = ""
|
||
normalized.MongoSRV = false
|
||
normalized.MongoAuthMechanism = ""
|
||
normalized.MongoReplicaUser = ""
|
||
normalized.MongoReplicaPassword = ""
|
||
}
|
||
|
||
return normalized
|
||
}
|
||
|
||
func resolveFileDatabaseDSN(config connection.ConnectionConfig) string {
|
||
dsn := strings.TrimSpace(config.Host)
|
||
if dsn == "" {
|
||
dsn = strings.TrimSpace(config.Database)
|
||
}
|
||
if dsn == "" {
|
||
dsn = ":memory:"
|
||
}
|
||
return dsn
|
||
}
|
||
|
||
// Helper: Generate a unique key for the connection config
|
||
func getCacheKey(config connection.ConnectionConfig) string {
|
||
normalized := normalizeCacheKeyConfig(config)
|
||
b, _ := json.Marshal(normalized)
|
||
sum := sha256.Sum256(b)
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func wrapConnectError(config connection.ConnectionConfig, err error) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
|
||
var netErr net.Error
|
||
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) {
|
||
dbName := config.Database
|
||
if dbName == "" {
|
||
dbName = "(default)"
|
||
}
|
||
err = fmt.Errorf("数据库连接超时:%s %s:%d/%s:%w", config.Type, config.Host, config.Port, dbName, err)
|
||
}
|
||
|
||
return withLogHint{err: err, logPath: logger.Path()}
|
||
}
|
||
|
||
type withLogHint struct {
|
||
err error
|
||
logPath string
|
||
}
|
||
|
||
func (e withLogHint) Error() string {
|
||
message := normalizeErrorMessage(e.err)
|
||
if strings.TrimSpace(e.logPath) == "" {
|
||
return message
|
||
}
|
||
return fmt.Sprintf("%s(详细日志:%s)", message, e.logPath)
|
||
}
|
||
|
||
func (e withLogHint) Unwrap() error {
|
||
return e.err
|
||
}
|
||
|
||
func formatConnSummary(config connection.ConnectionConfig) string {
|
||
timeoutSeconds := config.Timeout
|
||
if timeoutSeconds <= 0 {
|
||
timeoutSeconds = 30
|
||
}
|
||
|
||
dbName := config.Database
|
||
if strings.TrimSpace(dbName) == "" {
|
||
dbName = "(default)"
|
||
}
|
||
|
||
var b strings.Builder
|
||
normalizedType := strings.ToLower(strings.TrimSpace(config.Type))
|
||
if normalizedType == "sqlite" || normalizedType == "duckdb" {
|
||
path := strings.TrimSpace(config.Host)
|
||
if path == "" {
|
||
path = "(未配置)"
|
||
}
|
||
b.WriteString(fmt.Sprintf("类型=%s 路径=%s 超时=%ds", config.Type, path, timeoutSeconds))
|
||
} else {
|
||
b.WriteString(fmt.Sprintf("类型=%s 地址=%s:%d 数据库=%s 用户=%s 超时=%ds",
|
||
config.Type, config.Host, config.Port, dbName, config.User, timeoutSeconds))
|
||
}
|
||
|
||
if len(config.Hosts) > 0 {
|
||
b.WriteString(fmt.Sprintf(" 节点数=%d", len(config.Hosts)))
|
||
}
|
||
if strings.TrimSpace(config.Topology) != "" {
|
||
b.WriteString(fmt.Sprintf(" 拓扑=%s", strings.TrimSpace(config.Topology)))
|
||
}
|
||
if strings.TrimSpace(config.URI) != "" {
|
||
b.WriteString(fmt.Sprintf(" URI=已配置(长度=%d)", len(config.URI)))
|
||
}
|
||
if strings.TrimSpace(config.MySQLReplicaUser) != "" {
|
||
b.WriteString(" MySQL从库凭据=已配置")
|
||
}
|
||
if strings.EqualFold(strings.TrimSpace(config.Type), "mongodb") {
|
||
if strings.TrimSpace(config.MongoReplicaUser) != "" {
|
||
b.WriteString(" Mongo从库凭据=已配置")
|
||
}
|
||
if strings.TrimSpace(config.ReplicaSet) != "" {
|
||
b.WriteString(fmt.Sprintf(" 副本集=%s", strings.TrimSpace(config.ReplicaSet)))
|
||
}
|
||
if strings.TrimSpace(config.ReadPreference) != "" {
|
||
b.WriteString(fmt.Sprintf(" 读偏好=%s", strings.TrimSpace(config.ReadPreference)))
|
||
}
|
||
if strings.TrimSpace(config.AuthSource) != "" {
|
||
b.WriteString(fmt.Sprintf(" 认证库=%s", strings.TrimSpace(config.AuthSource)))
|
||
}
|
||
}
|
||
|
||
if config.UseSSH {
|
||
b.WriteString(fmt.Sprintf(" SSH=%s:%d 用户=%s", config.SSH.Host, config.SSH.Port, config.SSH.User))
|
||
}
|
||
if config.UseProxy {
|
||
b.WriteString(fmt.Sprintf(" 代理=%s://%s:%d", strings.ToLower(strings.TrimSpace(config.Proxy.Type)), config.Proxy.Host, config.Proxy.Port))
|
||
if strings.TrimSpace(config.Proxy.User) != "" {
|
||
b.WriteString(" 代理认证=已配置")
|
||
}
|
||
}
|
||
|
||
if config.Type == "custom" {
|
||
driver := strings.TrimSpace(config.Driver)
|
||
if driver == "" {
|
||
driver = "(未配置)"
|
||
}
|
||
dsnState := "未配置"
|
||
if strings.TrimSpace(config.DSN) != "" {
|
||
dsnState = fmt.Sprintf("已配置(长度=%d)", len(config.DSN))
|
||
}
|
||
b.WriteString(fmt.Sprintf(" 驱动=%s DSN=%s", driver, dsnState))
|
||
}
|
||
|
||
return b.String()
|
||
}
|
||
|
||
func (a *App) getDatabaseForcePing(config connection.ConnectionConfig) (db.Database, error) {
|
||
return a.getDatabaseWithPing(config, true)
|
||
}
|
||
|
||
// Helper: Get or create a database connection
|
||
func (a *App) getDatabase(config connection.ConnectionConfig) (db.Database, error) {
|
||
return a.getDatabaseWithPing(config, false)
|
||
}
|
||
|
||
func (a *App) openDatabaseIsolated(config connection.ConnectionConfig) (db.Database, error) {
|
||
effectiveConfig := applyGlobalProxyToConnection(config)
|
||
if supported, reason := db.DriverRuntimeSupportStatus(effectiveConfig.Type); !supported {
|
||
if strings.TrimSpace(reason) == "" {
|
||
reason = fmt.Sprintf("%s 驱动未启用,请先在驱动管理中安装启用", strings.TrimSpace(effectiveConfig.Type))
|
||
}
|
||
return nil, withLogHint{err: fmt.Errorf("%s", reason), logPath: logger.Path()}
|
||
}
|
||
|
||
dbInst, err := db.NewDatabase(effectiveConfig.Type)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig)
|
||
if proxyErr != nil {
|
||
_ = dbInst.Close()
|
||
return nil, wrapConnectError(effectiveConfig, proxyErr)
|
||
}
|
||
if err := dbInst.Connect(connectConfig); err != nil {
|
||
_ = dbInst.Close()
|
||
return nil, wrapConnectError(effectiveConfig, err)
|
||
}
|
||
return dbInst, nil
|
||
}
|
||
|
||
func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing bool) (db.Database, error) {
|
||
effectiveConfig := applyGlobalProxyToConnection(config)
|
||
isFileDB := isFileDatabaseType(effectiveConfig.Type)
|
||
|
||
key := getCacheKey(effectiveConfig)
|
||
shortKey := key
|
||
if len(shortKey) > 12 {
|
||
shortKey = shortKey[:12]
|
||
}
|
||
if isFileDB {
|
||
rawDSN := resolveFileDatabaseDSN(effectiveConfig)
|
||
normalizedDSN := resolveFileDatabaseDSN(normalizeCacheKeyConfig(effectiveConfig))
|
||
logger.Infof("文件库连接缓存探测:类型=%s 原始DSN=%s 归一化DSN=%s timeout=%ds forcePing=%t 缓存Key=%s",
|
||
strings.TrimSpace(effectiveConfig.Type), rawDSN, normalizedDSN, effectiveConfig.Timeout, forcePing, shortKey)
|
||
}
|
||
|
||
if supported, reason := db.DriverRuntimeSupportStatus(effectiveConfig.Type); !supported {
|
||
if strings.TrimSpace(reason) == "" {
|
||
reason = fmt.Sprintf("%s 驱动未启用,请先在驱动管理中安装启用", strings.TrimSpace(effectiveConfig.Type))
|
||
}
|
||
// Best-effort cleanup: if cached instance exists for this exact config, close it.
|
||
a.mu.Lock()
|
||
if cur, exists := a.dbCache[key]; exists && cur.inst != nil {
|
||
_ = cur.inst.Close()
|
||
delete(a.dbCache, key)
|
||
}
|
||
a.mu.Unlock()
|
||
return nil, withLogHint{err: fmt.Errorf("%s", reason), logPath: logger.Path()}
|
||
}
|
||
|
||
a.mu.RLock()
|
||
entry, ok := a.dbCache[key]
|
||
a.mu.RUnlock()
|
||
if ok {
|
||
if isFileDB {
|
||
logger.Infof("命中文件库连接缓存:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
|
||
}
|
||
needPing := forcePing
|
||
if !needPing {
|
||
lastPing := entry.lastPing
|
||
if lastPing.IsZero() || time.Since(lastPing) >= dbCachePingInterval {
|
||
needPing = true
|
||
}
|
||
}
|
||
|
||
if !needPing {
|
||
if isFileDB {
|
||
logger.Infof("复用文件库连接缓存(免 Ping):类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
|
||
}
|
||
return entry.inst, nil
|
||
}
|
||
|
||
if err := entry.inst.Ping(); err == nil {
|
||
// Update lastPing (best effort)
|
||
a.mu.Lock()
|
||
if cur, exists := a.dbCache[key]; exists && cur.inst == entry.inst {
|
||
cur.lastPing = time.Now()
|
||
a.dbCache[key] = cur
|
||
}
|
||
a.mu.Unlock()
|
||
if isFileDB {
|
||
logger.Infof("复用文件库连接缓存(Ping 成功):类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
|
||
}
|
||
return entry.inst, nil
|
||
} else {
|
||
logger.Error(err, "缓存连接不可用,准备重建:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
|
||
}
|
||
|
||
// Ping failed: remove cached instance (best effort)
|
||
a.mu.Lock()
|
||
if cur, exists := a.dbCache[key]; exists && cur.inst == entry.inst {
|
||
if err := cur.inst.Close(); err != nil {
|
||
logger.Error(err, "关闭失效缓存连接失败:缓存Key=%s", shortKey)
|
||
}
|
||
delete(a.dbCache, key)
|
||
}
|
||
a.mu.Unlock()
|
||
if isFileDB {
|
||
logger.Infof("文件库缓存连接已剔除,准备新建连接:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
|
||
}
|
||
}
|
||
if isFileDB {
|
||
logger.Infof("未命中文件库连接缓存,开始创建连接:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
|
||
}
|
||
|
||
logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
|
||
logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s", effectiveConfig.Type, shortKey)
|
||
dbInst, err := db.NewDatabase(effectiveConfig.Type)
|
||
if err != nil {
|
||
logger.Error(err, "创建数据库驱动实例失败:类型=%s 缓存Key=%s", effectiveConfig.Type, shortKey)
|
||
return nil, err
|
||
}
|
||
|
||
connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig)
|
||
if proxyErr != nil {
|
||
wrapped := wrapConnectError(effectiveConfig, proxyErr)
|
||
logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
|
||
return nil, wrapped
|
||
}
|
||
|
||
if err := dbInst.Connect(connectConfig); err != nil {
|
||
wrapped := wrapConnectError(effectiveConfig, err)
|
||
logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
|
||
return nil, wrapped
|
||
}
|
||
|
||
now := time.Now()
|
||
|
||
a.mu.Lock()
|
||
if existing, exists := a.dbCache[key]; exists && existing.inst != nil {
|
||
a.mu.Unlock()
|
||
// Prefer existing cached connection to avoid cache racing duplicates.
|
||
_ = dbInst.Close()
|
||
if isFileDB {
|
||
logger.Infof("并发创建命中已存在文件库连接,关闭新建连接并复用缓存:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
|
||
}
|
||
return existing.inst, nil
|
||
}
|
||
a.dbCache[key] = cachedDatabase{inst: dbInst, lastPing: now}
|
||
a.mu.Unlock()
|
||
|
||
logger.Infof("数据库连接成功并写入缓存:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
|
||
return dbInst, nil
|
||
}
|