Files
opsxdb/apps/server/internal/db/influx.go
T
logan a9510235bb
CI / Go server (push) Canceled after 0s
CI / Web + core packages (push) Canceled after 0s
CI / Desktop (Tauri) cargo check (push) Canceled after 0s
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 同步
2026-09-22 01:07:46 +08:00

272 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}