feat: 18 引擎驱动全家桶 + 驱动管理/离线包 + 设置中心 + 工作台交互打磨
驱动接入(全部本机真实例验收): - Elasticsearch 原生 SQL API、InfluxDB 原生 HTTP+Flux(官方 JDBC 付费许可,实测弃用) - ZooKeeper/MongoDB JVM agent(连接时自动下载官方驱动 jar,sha256 钉版本到 ~/.opsxdb/drivers/) - 达梦 JDBC agent 桥就绪(待厂商 jar+实例);TiDB 真实例验收 - 协议族代码审查放行 PolarDB/TDSQL/OceanBase/Kingbase/GaussDB/Oracle - 驱动能力模型:按驱动开关对象树分组/右键菜单/行编辑,NoSQL 树拍平(对象直挂库节点) 驱动管理: - 驱动管理弹框(状态/预装/离线包导入/JDK 检测)+ /api/drivers/* 三接口 - opsxdb-driver 驱动商店仓库(清单/fetch.sh/pack.sh 离线包/import.sh) - 连接时懒加载:DMG 零 Java 产物,首连自动下载(实测 ZK 九件套 1.35s),离线包自动回退 UI/交互: - 设置中心(⚙):常规/执行/结果/驱动/快捷键/关于,左导航+右面板(对标 opsxshell) - 实例级展开箭头(行首 SVG chevron,替代字体回退失真的 Unicode 三角) - 树内嵌套渲染修复:对象树挂在激活连接下;工具栏窄窗口不断行(按钮 nowrap+整颗换行) - 侧栏连接行精简(图标+连接名)、SQLite SSH 守卫、Oracle 死参数清理 质量: - Go 协议层/离线回退单测、E2E 门控测试(ES/ZK/Influx/Mongo/TiDB/JDBC 桥) - 驱动注册表单一事实来源 connection-types.json,README/BACKLOG/AGENTS 同步
This commit is contained in:
1 parent
26f459d1b8
commit
a9510235bb
156 files changed
+17200
-2520
No files matched your search
@@ -1,10 +1,12 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -14,14 +16,21 @@ import (
|
||||
const sessionTTL = 45 * time.Minute
|
||||
const maxSessions = 64
|
||||
|
||||
// maxPoolHandles 每会话缓存的 *sql.DB 句柄上限(按 database 分键),超出时关闭最久未用。
|
||||
const maxPoolHandles = 8
|
||||
|
||||
type Session struct {
|
||||
ID string
|
||||
Conn ConnReq
|
||||
mu sync.Mutex
|
||||
sqlPool map[string]*sql.DB
|
||||
redisCli *redis.Client
|
||||
tunnel *SSHTunnel
|
||||
LastUsed time.Time
|
||||
ID string
|
||||
Conn ConnReq
|
||||
mu sync.Mutex
|
||||
sqlPool map[string]*sql.DB
|
||||
poolUsed map[string]time.Time // sqlPool 各键最近使用时间,供 LRU 轮换
|
||||
redisClis map[int]redis.UniversalClient // 按 DB 序号缓存的 Redis 客户端(DB 是连接级参数)
|
||||
// agentHandles: agent 驱动(如达梦)按 database 缓存的 JDBC 句柄;
|
||||
// JVM 进程走全局池(agentPool),会话关闭只关句柄、不杀进程。
|
||||
agentHandles map[string]string
|
||||
tunnel *SSHTunnel
|
||||
LastUsed time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -39,12 +48,14 @@ func init() {
|
||||
}()
|
||||
}
|
||||
|
||||
func newSessionID() string {
|
||||
// newSessionID 生成 128bit 随机会话 ID;crypto/rand 失败时返回错误,
|
||||
// 不再静默降级为低熵时间戳。
|
||||
func newSessionID() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
return "", fmt.Errorf("生成会话 ID 失败: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
return hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
func OpenSession(c ConnReq) (string, error) {
|
||||
@@ -54,6 +65,10 @@ func OpenSession(c ConnReq) (string, error) {
|
||||
c.Pattern = ""
|
||||
c.SessionID = ""
|
||||
|
||||
// agent 驱动(JDBC 桥)不走本地 SSH 端口转发(转发对 JVM 侧 TCP 无效)
|
||||
if isAgentDriver(c.Driver) && c.SSHEnabled {
|
||||
return "", fmt.Errorf("%s 走 JDBC agent,暂不支持 SSH 隧道", c.Driver)
|
||||
}
|
||||
dial, tun, err := ApplySSHTunnel(c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -73,16 +88,25 @@ func OpenSession(c ConnReq) (string, error) {
|
||||
}
|
||||
return "", fmt.Errorf("连接会话已满,请关闭不用的连接后重试")
|
||||
}
|
||||
id := newSessionID()
|
||||
id, err := newSessionID()
|
||||
if err != nil {
|
||||
sessionsMu.Unlock()
|
||||
if tun != nil {
|
||||
tun.Close()
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
// Persist dialed endpoints (127.0.0.1:local) so later ops reuse the tunnel.
|
||||
stored := dial
|
||||
stored.SSHEnabled = false
|
||||
s := &Session{
|
||||
ID: id,
|
||||
Conn: stored,
|
||||
sqlPool: map[string]*sql.DB{},
|
||||
tunnel: tun,
|
||||
LastUsed: time.Now(),
|
||||
ID: id,
|
||||
Conn: stored,
|
||||
sqlPool: map[string]*sql.DB{},
|
||||
poolUsed: map[string]time.Time{},
|
||||
agentHandles: map[string]string{},
|
||||
tunnel: tun,
|
||||
LastUsed: time.Now(),
|
||||
}
|
||||
sessions[id] = s
|
||||
sessionsMu.Unlock()
|
||||
@@ -143,9 +167,21 @@ func (s *Session) closeAll() {
|
||||
_ = dbh.Close()
|
||||
delete(s.sqlPool, k)
|
||||
}
|
||||
if s.redisCli != nil {
|
||||
_ = s.redisCli.Close()
|
||||
s.redisCli = nil
|
||||
s.poolUsed = map[string]time.Time{}
|
||||
for k, cli := range s.redisClis {
|
||||
_ = cli.Close()
|
||||
delete(s.redisClis, k)
|
||||
}
|
||||
// agent 句柄随会话释放;JVM 进程归全局池,进程生命周期不挂钩会话
|
||||
if len(s.agentHandles) > 0 {
|
||||
if cli, err := getAgentClient(s.Conn.Driver); err == nil {
|
||||
for _, h := range s.agentHandles {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
_ = cli.call(ctx, "close", map[string]any{"handle": h}, nil)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
s.agentHandles = map[string]string{}
|
||||
}
|
||||
if s.tunnel != nil {
|
||||
s.tunnel.Close()
|
||||
@@ -153,6 +189,151 @@ func (s *Session) closeAll() {
|
||||
}
|
||||
}
|
||||
|
||||
// agentConn 按 database 懒建/复用 agent JDBC 句柄(模式同 sqlDB)。
|
||||
// 调用方不得持有 s.mu(本函数内部自行加锁;getAgentClient 用独立的全局池锁)。
|
||||
func (s *Session) agentConn(c ConnReq) (*AgentClient, string, error) {
|
||||
cli, err := getAgentClient(c.Driver)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
key := c.Database
|
||||
s.mu.Lock()
|
||||
if h, ok := s.agentHandles[key]; ok {
|
||||
s.mu.Unlock()
|
||||
return cli, h, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
h, err := agentOpen(ctx, cli, c)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if old, ok := s.agentHandles[key]; ok {
|
||||
// 双检:并发下已有别的 goroutine 建好同键句柄,回收多余句柄
|
||||
go func() {
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel2()
|
||||
_ = cli.call(ctx2, "close", map[string]any{"handle": h}, nil)
|
||||
}()
|
||||
return cli, old, nil
|
||||
}
|
||||
s.agentHandles[key] = h
|
||||
return cli, h, nil
|
||||
}
|
||||
|
||||
// touch 滚动续期(GetSession 已调用,池化路径同样经 GetSession 进入)。
|
||||
func (s *Session) touch() {
|
||||
s.mu.Lock()
|
||||
s.LastUsed = time.Now()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// sqlDB 按 database 懒加载缓存 *sql.DB,会话内复用连接池;
|
||||
// 超过 maxPoolHandles 个句柄时关闭最久未用的池。
|
||||
func (s *Session) sqlDB(database string) (*sql.DB, error) {
|
||||
// SQLite 的 database 是文件路径,对象树的 "main" 不能覆盖它
|
||||
if driverKey(s.Conn.Driver) == "sqlite" {
|
||||
database = ""
|
||||
}
|
||||
s.mu.Lock()
|
||||
if dbh, ok := s.sqlPool[database]; ok {
|
||||
s.poolUsed[database] = time.Now()
|
||||
s.mu.Unlock()
|
||||
return dbh, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
conn := s.Conn
|
||||
if database != "" {
|
||||
conn.Database = database
|
||||
}
|
||||
dbh, err := Open(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// 双检:并发下可能已有别的 goroutine 建好同键池
|
||||
if old, ok := s.sqlPool[database]; ok {
|
||||
s.poolUsed[database] = time.Now()
|
||||
_ = dbh.Close()
|
||||
return old, nil
|
||||
}
|
||||
// 超上限先淘汰最久未用
|
||||
for len(s.sqlPool) >= maxPoolHandles {
|
||||
var lruKey string
|
||||
var lruTime time.Time
|
||||
for k := range s.sqlPool {
|
||||
t := s.poolUsed[k]
|
||||
if lruKey == "" || t.Before(lruTime) {
|
||||
lruKey, lruTime = k, t
|
||||
}
|
||||
}
|
||||
_ = s.sqlPool[lruKey].Close()
|
||||
delete(s.sqlPool, lruKey)
|
||||
delete(s.poolUsed, lruKey)
|
||||
}
|
||||
s.sqlPool[database] = dbh
|
||||
s.poolUsed[database] = time.Now()
|
||||
return dbh, nil
|
||||
}
|
||||
|
||||
// redisClient 懒建会话级 Redis 单例,避免每次命令新建连接。
|
||||
func (s *Session) redisClient() (redis.UniversalClient, error) {
|
||||
// 按连接配置缓存:DB 序号是 go-redis 的连接级参数(建连时钉死),
|
||||
// 对象树里切换 db0/db1 必须各建一个客户端,否则全部打到首个 db。
|
||||
return s.redisClientFor(s.Conn)
|
||||
}
|
||||
|
||||
func (s *Session) redisClientFor(c ConnReq) (redis.UniversalClient, error) {
|
||||
db := redisDBIndex(c)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.redisClis == nil {
|
||||
s.redisClis = map[int]redis.UniversalClient{}
|
||||
}
|
||||
if cli, ok := s.redisClis[db]; ok {
|
||||
return cli, nil
|
||||
}
|
||||
// 以会话凭据 + 目标 DB 建连(Sentinel/Cluster 模式 DB 恒为 0,天然只有一份)
|
||||
cli, err := openRedis(ConnReq{
|
||||
Driver: s.Conn.Driver,
|
||||
Host: s.Conn.Host,
|
||||
Port: s.Conn.Port,
|
||||
User: s.Conn.User,
|
||||
Password: s.Conn.Password,
|
||||
Database: strconv.Itoa(db),
|
||||
RedisMode: s.Conn.RedisMode,
|
||||
RedisNodes: s.Conn.RedisNodes,
|
||||
MasterName: s.Conn.MasterName,
|
||||
SentinelPassword: s.Conn.SentinelPassword,
|
||||
SSL: s.Conn.SSL,
|
||||
SSLMode: s.Conn.SSLMode,
|
||||
SSLCA: s.Conn.SSLCA,
|
||||
SSLCert: s.Conn.SSLCert,
|
||||
SSLKey: s.Conn.SSLKey,
|
||||
SSHEnabled: s.Conn.SSHEnabled,
|
||||
SSHHost: s.Conn.SSHHost,
|
||||
SSHPort: s.Conn.SSHPort,
|
||||
SSHUser: s.Conn.SSHUser,
|
||||
SSHAuth: s.Conn.SSHAuth,
|
||||
SSHPassword: s.Conn.SSHPassword,
|
||||
SSHPrivateKey: s.Conn.SSHPrivateKey,
|
||||
SSHPassphrase: s.Conn.SSHPassphrase,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.redisClis[db] = cli
|
||||
return cli, nil
|
||||
}
|
||||
|
||||
func (s *Session) ConnFor(req ConnReq) ConnReq {
|
||||
c := s.Conn
|
||||
// SQLite 的 database 是文件路径,对象树里的 "main" 不能覆盖它
|
||||
@@ -163,7 +344,16 @@ func (s *Session) ConnFor(req ConnReq) ConnReq {
|
||||
c.Table = req.Table
|
||||
c.Key = req.Key
|
||||
c.Pattern = req.Pattern
|
||||
c.Keyword = req.Keyword
|
||||
c.Format = req.Format
|
||||
c.Limit = req.Limit
|
||||
c.Offset = req.Offset
|
||||
c.ConfirmDanger = req.ConfirmDanger
|
||||
// 逐请求字段:新增 ConnReq 字段时若属于"每次请求携带"语义,必须在此补拷贝,
|
||||
// 否则会话路径会静默丢弃(前端全部走会话,等于功能失效)
|
||||
c.TimeoutMs = req.TimeoutMs
|
||||
c.Cursor = req.Cursor
|
||||
c.RequestID = req.RequestID
|
||||
c.SessionID = s.ID
|
||||
return c
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user