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

+271
View File
@@ -0,0 +1,271 @@
package db
// influx.go —— InfluxDB 2.x 原生接入(HTTP API,零第三方依赖)。
//
// 字段映射(连接表单):
//
// 主机/端口 → 服务地址(默认 :8086)
// 用户 → org(如 opsx)
// 密码 → admin token(如 opsxdb-demo-token)
// 库名 → bucket(如 demo)
//
// 能力:对象树 bucket → measurement(Flux schema.measurements);
// SQL 编辑器直写 Flux(from(bucket: ...) |> range(...) ...),结果按注解 CSV 解析为表格。
// Flux 只读(写走 /write 专用通道),危险语句拦截在 ops.go 入口统一过一遍。
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
func isInflux(d string) bool { return driverKey(d) == "influxdb" }
func ifxBase(c ConnReq) string {
host := strings.TrimSpace(c.Host)
if host == "" {
host = "127.0.0.1"
}
port := c.Port
if port == 0 {
port = 8086
}
return fmt.Sprintf("http://%s:%d", host, port)
}
func ifxToken(c ConnReq) string { return c.Password }
// ifxDo 统一请求:Token 鉴权 + org 查询参数。
func ifxDo(ctx context.Context, c ConnReq, method, path string, body io.Reader, out any) error {
req, err := http.NewRequestWithContext(ctx, method, ifxBase(c)+path, body)
if err != nil {
return err
}
if ifxToken(c) != "" {
req.Header.Set("Authorization", "Token "+ifxToken(c))
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("InfluxDB HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
func ifxOrg(c ConnReq) (string, error) {
org := strings.TrimSpace(c.User)
if org == "" {
return "", fmt.Errorf("InfluxDB 2.x 需要填「用户」= org 名(如 opsx)")
}
return org, nil
}
func ifxPing(c ConnReq) error {
org, err := ifxOrg(c)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
var out struct {
Buckets []struct {
Name string `json:"name"`
} `json:"buckets"`
}
return ifxDo(ctx, c, http.MethodGet, "/api/v2/buckets?org="+org, nil, &out)
}
func ifxListDatabases(ctx context.Context, c ConnReq) ([]string, error) {
org, err := ifxOrg(c)
if err != nil {
return nil, err
}
var out struct {
Buckets []struct {
Name string `json:"name"`
} `json:"buckets"`
}
if err := ifxDo(ctx, c, http.MethodGet, "/api/v2/buckets?org="+org, nil, &out); err != nil {
return nil, err
}
names := []string{}
for _, b := range out.Buckets {
names = append(names, b.Name)
}
return names, nil
}
// ifxQueryFlux 跑 Flux 并把注解 CSV 解析为表格。
func ifxQueryFlux(ctx context.Context, c ConnReq, flux string, maxRows int) (ExecResult, error) {
org, err := ifxOrg(c)
if err != nil {
return ExecResult{}, err
}
body := map[string]string{"query": flux, "type": "flux"}
b, err := json.Marshal(body)
if err != nil {
return ExecResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ifxBase(c)+"/api/v2/query?org="+org, strings.NewReader(string(b)))
if err != nil {
return ExecResult{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/csv")
if ifxToken(c) != "" {
req.Header.Set("Authorization", "Token "+ifxToken(c))
}
resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(req)
if err != nil {
return ExecResult{}, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return ExecResult{}, err
}
if resp.StatusCode >= 400 {
return ExecResult{}, fmt.Errorf("InfluxDB HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
return parseAnnotatedCSV(string(raw), maxRows)
}
// parseAnnotatedCSV 解析 InfluxDB 注解 CSV:
// #group/#datatype/#default 注释行 + 空行 + header 行 + 数据行。
func parseAnnotatedCSV(text string, maxRows int) (ExecResult, error) {
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
headerIdx := -1
var header []string
for i, ln := range lines {
s := strings.TrimSpace(ln)
if s == "" || strings.HasPrefix(s, "#") {
continue
}
headerIdx = i
header = strings.Split(ln, ",")
break
}
if headerIdx < 0 {
return ExecResult{Columns: []string{}, Rows: []map[string]interface{}{}}, nil
}
res := ExecResult{Columns: header, Rows: []map[string]interface{}{}}
for _, ln := range lines[headerIdx+1:] {
if strings.TrimSpace(ln) == "" {
continue
}
cells := strings.Split(ln, ",")
row := make(map[string]interface{}, len(header))
for i, col := range header {
v := ""
if i < len(cells) {
v = cells[i]
}
row[col] = v
}
res.Rows = append(res.Rows, row)
if len(res.Rows) >= maxRows {
res.Truncated = true
break
}
}
res.RowCount = len(res.Rows)
return res, nil
}
func ifxListTables(ctx context.Context, c ConnReq) ([]string, error) {
bucket := strings.TrimSpace(c.Database)
if bucket == "" {
return nil, fmt.Errorf("InfluxDB 列表需要库名(bucket)")
}
flux := fmt.Sprintf("import \"influxdata/influxdb/schema\"\nschema.measurements(bucket: \"%s\")", strings.ReplaceAll(bucket, "\"", ""))
res, err := ifxQueryFlux(ctx, c, flux, 10000)
if err != nil {
return nil, err
}
out := []string{}
if len(res.Columns) == 0 {
return out, nil
}
// schema.measurements 输出列为 [,result,table,_value]:优先 _value 列,退化为最后一列
idx := len(res.Columns) - 1
for i, col := range res.Columns {
if col == "_value" {
idx = i
break
}
}
col := res.Columns[idx]
for _, row := range res.Rows {
if v, ok := row[col].(string); ok && v != "" {
out = append(out, v)
}
}
return out, nil
}
// ifxExecSQL 编辑器内容按 Flux 处理(只读)。
func ifxExecSQL(ctx context.Context, c ConnReq, start time.Time) ExecResult {
if c.Offset > 0 {
return ExecResult{Columns: []string{}, Rows: []map[string]interface{}{}, Error: "InfluxDB Flux 结果暂不支持翻页(可缩小 range 重查)", TimeMs: time.Since(start).Milliseconds()}
}
limit := rowLimit(c)
if limit > MaxRowLimit {
limit = MaxRowLimit
}
res, err := ifxQueryFlux(ctx, c, strings.TrimSpace(c.SQL), limit)
if err != nil {
return execFail(start, err)
}
res.TimeMs = time.Since(start).Milliseconds()
return res
}
// ifxListColumns measurement 的字段探查:Flux schema.fieldKeys + tagKeys 合并。
func ifxListColumns(ctx context.Context, c ConnReq) ([]ColumnInfo, error) {
bucket := strings.TrimSpace(c.Database)
table := strings.TrimSpace(c.Table)
if bucket == "" || table == "" {
return nil, fmt.Errorf("InfluxDB 列探查需要库名与 measurement")
}
safeB := strings.ReplaceAll(bucket, "\"", "")
safeM := strings.ReplaceAll(table, "\"", "")
flux := fmt.Sprintf(`import "influxdata/influxdb/schema"
union(tables: [
schema.fieldKeys(bucket: "%s", predicate: (r) => r._measurement == "%s"),
schema.tagKeys(bucket: "%s", predicate: (r) => r._measurement == "%s")
])`, safeB, safeM, safeB, safeM)
res, err := ifxQueryFlux(ctx, c, flux, 1000)
if err != nil {
return nil, err
}
cols := []ColumnInfo{}
seen := map[string]bool{}
if len(res.Columns) == 0 {
return cols, nil
}
last := res.Columns[len(res.Columns)-1]
for _, row := range res.Rows {
name, _ := row[last].(string)
if name == "" || seen[name] {
continue
}
seen[name] = true
cols = append(cols, ColumnInfo{Name: name, Type: "tag/field", Nullable: true})
}
return cols, nil
}