feat: 18 引擎驱动全家桶 + 驱动管理/离线包 + 设置中心 + 工作台交互打磨
CI / Go server (push) Canceled after 0s
CI / Web + core packages (push) Canceled after 0s
CI / Desktop (Tauri) cargo check (push) Canceled after 0s

驱动接入(全部本机真实例验收):
- 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:
logan committed 2026-09-22 01:07:46 +08:00
1 parent 26f459d1b8
commit a9510235bb
156 files changed
+17200 -2520

No files matched your search

+379
View File
@@ -0,0 +1,379 @@
package db
// agent_e2e_test.go —— JDBC agent 桥端到端验证(真实 JVM + 真实 JDBC 驱动)。
// 前置:本机 JDK、agent jar(OPSXDB_AGENT_JAR 或默认 ~/.opsxdb/agents/jdbc/)。
// 触发:OPSXDB_E2E_JDBC=1 才运行(CI 无 JVM 时自动跳过)。
// 覆盖:StartAgent 全链路(gson 校验/JVM 启动/握手)→ open → DDL/DML → query → 元数据。
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestE2EAgentSQLiteBridge(t *testing.T) {
if os.Getenv("OPSXDB_E2E_JDBC") != "1" {
t.Skip("置 OPSXDB_E2E_JDBC=1 并备好 JDK 与 agent jar 后运行")
}
home, _ := os.UserHomeDir()
jars := filepath.Join(home, ".opsxdb", "agents", "jdbc", "drivers", "sqlite-e2e")
if _, err := os.Stat(filepath.Join(jars, "sqlite-jdbc-3.46.1.3.jar")); err != nil {
t.Skipf("缺 sqlite-jdbc jar: %s", jars)
}
t.Setenv("OPSXDB_E2E_JARS", jars)
spec := AgentSpec{
DriverKey: "e2e-sqlite",
DriverClass: "org.sqlite.JDBC",
URLTemplate: "jdbc:sqlite:{{.Database}}",
JarsDirEnv: "OPSXDB_E2E_JARS",
}
agentDriverSpecs[spec.DriverKey] = spec // 注册进表,AgentConfigured 才能按 JarsDirEnv 解析
t.Cleanup(func() { delete(agentDriverSpecs, spec.DriverKey) })
cli, err := StartAgent(spec)
if err != nil {
t.Fatalf("StartAgent: %v", err)
}
t.Cleanup(func() { cli.Shutdown(context.Background()) })
dbPath := filepath.Join(t.TempDir(), "e2e.db")
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
handle := ""
{
var res struct {
Handle string `json:"handle"`
}
if err := cli.call(ctx, "open", map[string]any{"url": "jdbc:sqlite:" + dbPath, "user": "", "password": ""}, &res); err != nil {
t.Fatalf("open: %v (stderr=%s)", err, cli.stderrTail.String())
}
handle = res.Handle
}
mustExec := func(sql string) {
t.Helper()
var r struct {
AffectedRows int64 `json:"affectedRows"`
}
if err := cli.call(ctx, "exec", map[string]any{"handle": handle, "sql": sql, "timeoutSec": 30}, &r); err != nil {
t.Fatalf("exec %q: %v (stderr=%s)", sql, err, cli.stderrTail.String())
}
}
mustExec("CREATE TABLE demo(id INTEGER PRIMARY KEY, name TEXT)")
mustExec("INSERT INTO demo(name) VALUES ('opsx-e2e')")
{
var res struct {
Columns []string `json:"columns"`
Rows []map[string]interface{} `json:"rows"`
}
if err := cli.call(ctx, "query", map[string]any{"handle": handle, "sql": "SELECT id, name FROM demo", "maxRows": 100, "timeoutSec": 30}, &res); err != nil {
t.Fatalf("query: %v", err)
}
if len(res.Rows) != 1 || res.Rows[0]["name"] != "opsx-e2e" {
t.Fatalf("rows = %+v", res.Rows)
}
}
{
var res struct {
Names []string `json:"names"`
}
if err := cli.call(ctx, "metaTables", map[string]any{"handle": handle, "schema": ""}, &res); err != nil {
t.Fatalf("metaTables: %v", err)
}
found := false
for _, n := range res.Names {
if n == "demo" {
found = true
}
}
if !found {
t.Fatalf("metaTables 未包含 demo: %v", res.Names)
}
}
{
var res struct {
Columns []ColumnInfo `json:"columns"`
}
if err := cli.call(ctx, "metaColumns", map[string]any{"handle": handle, "schema": "", "table": "demo"}, &res); err != nil {
t.Fatalf("metaColumns: %v", err)
}
if len(res.Columns) != 2 {
t.Fatalf("columns = %+v", res.Columns)
}
}
}
// TestE2EElasticsearch —— 真实 ES 8(docker,安全关闭)+ 官方 x-pack-sql-jdbc 走 agent 桥全链路。
// 触发:OPSXDB_E2E_ES=1;前置 ES :9200 可连、~/.opsxdb/agents/jdbc/drivers/elasticsearch/ 有官方 JDBC jar。
func TestE2EElasticsearch(t *testing.T) {
if os.Getenv("OPSXDB_E2E_ES") != "1" {
t.Skip("置 OPSXDB_E2E_ES=1 并启动 ES :9200 后运行")
}
// 种子文档(让 SHOW TABLES / SELECT 有真实对象)
req, _ := http.NewRequest(http.MethodPut, "http://127.0.0.1:9200/e2e_smoke/_doc/1?refresh=true", strings.NewReader(`{"name":"opsx","n":42}`))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Skipf("ES :9200 不可达: %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode >= 400 {
t.Fatalf("种子文档写入失败: HTTP %d", resp.StatusCode)
}
c := ConnReq{Driver: "elasticsearch", Host: "127.0.0.1", Port: 9200}
if err := TestConnection(c); err != nil {
t.Fatalf("TestConnection: %v", err)
}
dbs, err := ListDatabases(context.Background(), c)
if err != nil || len(dbs) != 1 || dbs[0] != "default" {
t.Fatalf("ListDatabases = %v, %v", dbs, err)
}
cc := c
cc.Database = "default"
tables, err := ListTables(context.Background(), cc)
if err != nil {
t.Fatalf("ListTables: %v", err)
}
found := false
for _, n := range tables {
if n == "e2e_smoke" {
found = true
}
}
if !found {
t.Fatalf("tables 未含 e2e_smoke: %v", tables)
}
r := ExecSQL(context.Background(), ConnReq{Driver: "elasticsearch", Host: "127.0.0.1", Port: 9200, Database: "default", SQL: "SELECT name, n FROM e2e_smoke", Limit: 100})
if r.Error != "" {
t.Fatalf("ExecSQL: %s (stderr 见测试日志)", r.Error)
}
if r.RowCount < 1 {
t.Fatalf("rows = %+v", r.Rows)
}
}
// TestE2EZooKeeper —— 真实 ZK(docker)+ OpsxZkAgent 全链路:连接/浏览/读写/删除。
// 触发:OPSXDB_E2E_ZK=1;前置 ZK :2181、~/.opsxdb/agents/jdbc/drivers/zookeeper/ 有官方 jar。
func TestE2EZooKeeper(t *testing.T) {
if os.Getenv("OPSXDB_E2E_ZK") != "1" {
t.Skip("置 OPSXDB_E2E_ZK=1 并启动 ZK :2181 后运行")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
c := ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181}
if err := TestConnection(c); err != nil {
t.Skipf("ZK :2181 不可达: %v", err)
}
dbs, err := ListDatabases(ctx, c)
if err != nil || len(dbs) == 0 {
t.Fatalf("ListDatabases = %v, %v", dbs, err)
}
hasZk := false
for _, n := range dbs {
if n == "zookeeper" {
hasZk = true
}
}
if !hasZk {
t.Fatalf("根下应含 zookeeper: %v", dbs)
}
cc := c
cc.Database = "zookeeper"
tables, err := ListTables(ctx, cc)
if err != nil {
t.Fatalf("ListTables: %v", err)
}
hasConfig := false
for _, n := range tables {
if n == "config" {
hasConfig = true
}
}
if !hasConfig {
t.Fatalf("/zookeeper 下应含 config: %v", tables)
}
// 读:GET
r := ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, Database: "zookeeper", SQL: "GET /zookeeper/config", Limit: 100})
if r.Error != "" {
t.Fatalf("GET: %s", r.Error)
}
if r.RowCount != 1 || r.Rows[0]["path"] != "/zookeeper/config" {
t.Fatalf("GET rows = %+v", r.Rows)
}
// 写:CREATE → GET → SET → DELETE(DELETE 无 WHERE 属危险语句,走确认通道)
w := func(sql string) {
t.Helper()
res := ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, SQL: sql, ConfirmDanger: true})
if res.Error != "" {
t.Fatalf("%s → %s", sql, res.Error)
}
}
w("CREATE /opsxe2e hello-opsx")
r = ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, SQL: "GET /opsxe2e", Limit: 100})
if r.Rows[0]["data"] != "hello-opsx" {
t.Fatalf("CREATE 后 data = %v", r.Rows[0]["data"])
}
w("SET /opsxe2e updated")
r = ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, SQL: "GET /opsxe2e", Limit: 100})
if r.Rows[0]["data"] != "updated" {
t.Fatalf("SET 后 data = %v", r.Rows[0]["data"])
}
// 危险语句未确认时必须先返回 NeedConfirm
guard := ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, SQL: "DELETE /opsxe2e"})
if !guard.NeedConfirm {
t.Fatalf("DELETE 未确认应返回 NeedConfirm,got %+v", guard)
}
w("DELETE /opsxe2e")
}
// TestE2EInfluxDB —— 真实 InfluxDB 2.7(docker)原生 HTTP 全链路:bucket/measurement/Flux 查询。
// 触发:OPSXDB_E2E_INFLUX=1;前置 InfluxDB :8086(org opsx / token opsxdb-demo-token / bucket demo)。
func TestE2EInfluxDB(t *testing.T) {
if os.Getenv("OPSXDB_E2E_INFLUX") != "1" {
t.Skip("置 OPSXDB_E2E_INFLUX=1 并启动 InfluxDB :8086 后运行")
}
// 种子点(行协议写入)
wreq, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:8086/api/v2/write?org=opsx&bucket=demo&precision=s",
strings.NewReader("e2e_m,site=local v=42"))
wreq.Header.Set("Authorization", "Token opsxdb-demo-token")
wreq.Header.Set("Content-Type", "text/plain; charset=utf-8")
wresp, err := http.DefaultClient.Do(wreq)
if err != nil {
t.Skipf("InfluxDB :8086 不可达: %v", err)
}
_, _ = io.Copy(io.Discard, wresp.Body)
wresp.Body.Close()
if wresp.StatusCode >= 400 {
t.Fatalf("种子写入失败: HTTP %d", wresp.StatusCode)
}
time.Sleep(500 * time.Millisecond) // 存储引擎落盘
c := ConnReq{Driver: "influxdb", Host: "127.0.0.1", Port: 8086, User: "opsx", Password: "opsxdb-demo-token"}
if err := TestConnection(c); err != nil {
t.Fatalf("TestConnection: %v", err)
}
dbs, err := ListDatabases(context.Background(), c)
if err != nil {
t.Fatalf("ListDatabases: %v", err)
}
hasDemo := false
for _, n := range dbs {
if n == "demo" {
hasDemo = true
}
}
if !hasDemo {
t.Fatalf("buckets 未含 demo: %v", dbs)
}
cc := c
cc.Database = "demo"
tables, err := ListTables(context.Background(), cc)
if err != nil {
t.Fatalf("ListTables: %v", err)
}
hasM := false
for _, n := range tables {
if n == "e2e_m" {
hasM = true
}
}
if !hasM {
t.Fatalf("measurements 未含 e2e_m: %v", tables)
}
r := ExecSQL(context.Background(), ConnReq{
Driver: "influxdb", Host: "127.0.0.1", Port: 8086, User: "opsx", Password: "opsxdb-demo-token",
SQL: `from(bucket: "demo") |> range(start: -1h) |> filter(fn: (r) => r._measurement == "e2e_m")`,
Limit: 100,
})
if r.Error != "" {
t.Fatalf("Flux 查询: %s", r.Error)
}
if r.RowCount < 1 {
t.Fatalf("rows = %+v", r.Rows)
}
}
// TestE2EMongoDB —— 真实 MongoDB 7 + OpsxMongoAgent 全链路:库/集合浏览、DSL 查改增删、危险确认。
// 触发:OPSXDB_E2E_MONGO=1;前置 MongoDB :27017、~/.opsxdb/agents/jdbc/drivers/mongodb/ 有官方 jar。
func TestE2EMongoDB(t *testing.T) {
if os.Getenv("OPSXDB_E2E_MONGO") != "1" {
t.Skip("置 OPSXDB_E2E_MONGO=1 并启动 MongoDB :27017 后运行")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
c := ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017}
if err := TestConnection(c); err != nil {
t.Skipf("MongoDB :27017 不可达: %v", err)
}
// 种子:插入文档(确认通道)
seed := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.insertOne({"name":"opsx","n":7})`, ConfirmDanger: false})
if seed.Error != "" {
t.Fatalf("insertOne: %s", seed.Error)
}
dbs, err := ListDatabases(ctx, c)
if err != nil || len(dbs) == 0 {
t.Fatalf("ListDatabases = %v, %v", dbs, err)
}
hasDb := false
for _, n := range dbs {
if n == "opsxe2e" {
hasDb = true
}
}
if !hasDb {
t.Fatalf("库列表未含 opsxe2e: %v", dbs)
}
cc := c
cc.Database = "opsxe2e"
tables, err := ListTables(ctx, cc)
if err != nil || len(tables) == 0 {
t.Fatalf("ListTables = %v, %v", tables, err)
}
found := false
for _, n := range tables {
if n == "c1" {
found = true
}
}
if !found {
t.Fatalf("集合列表未含 c1: %v", tables)
}
cols, err := ListColumns(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", Table: "c1"})
if err != nil || len(cols) == 0 {
t.Fatalf("ListColumns = %v, %v", cols, err)
}
// 查询
q := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.find({"name":"opsx"})`, Limit: 100})
if q.Error != "" || q.RowCount < 1 {
t.Fatalf("find: err=%s rows=%+v", q.Error, q.Rows)
}
// 危险确认:deleteMany 未确认必须先返回 NeedConfirm
guard := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.deleteMany({"name":"opsx"})`})
if !guard.NeedConfirm {
t.Fatalf("deleteMany 未确认应 NeedConfirm,got %+v", guard)
}
// 更新 + 删除(确认通道)
u := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.updateOne({"name":"opsx"}, {"$set":{"n":8}})`, ConfirmDanger: true})
if u.Error != "" {
t.Fatalf("updateOne: %s", u.Error)
}
d := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.deleteMany({"name":"opsx"})`, ConfirmDanger: true})
if d.Error != "" {
t.Fatalf("deleteMany: %s", d.Error)
}
cnt := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.count({"name":"opsx"})`})
if fmt.Sprintf("%v", cnt.Rows[0]["n"]) != "0" {
t.Fatalf("count after delete = %v", cnt.Rows[0]["n"])
}
}