驱动接入(全部本机真实例验收): - 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 同步
272 lines
7.6 KiB
Go
272 lines
7.6 KiB
Go
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
|
||
}
|