驱动接入(全部本机真实例验收): - 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 同步
194 lines
5.2 KiB
Go
194 lines
5.2 KiB
Go
package db
|
||
|
||
// es.go —— Elasticsearch 原生接入(HTTP + X-Pack SQL REST API)。
|
||
//
|
||
// 为什么不用官方 JDBC:x-pack-sql-jdbc 在 basic 许可下直接拒绝工作
|
||
// ("current license is non-compliant for [jdbc]",实测 8.15.3),而 SQL REST API
|
||
// 在 basic 许可可用 —— 走 net/http 零依赖接入,也免除 JVM 负担。
|
||
//
|
||
// 能力边界(v1,对标 SQL 工作台):
|
||
// - 对象树:单库节点 "default" → SHOW TABLES 列出索引
|
||
// - 查询:SQL 编辑器里的 ES SQL(SELECT/SHOW/DESC…)直发 /_sql
|
||
// - ES SQL 只读:DDL/DML 由 ES 自身拒绝;危险语句拦截仍在本层先过一遍
|
||
// - 分页:/_sql 为游标式,前端 offset 翻页暂不支持(首屏 fetch_size 截断)
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
func isES(d string) bool { return driverKey(d) == "elasticsearch" }
|
||
|
||
func esBase(c ConnReq) string {
|
||
host := strings.TrimSpace(c.Host)
|
||
if host == "" {
|
||
host = "127.0.0.1"
|
||
}
|
||
port := c.Port
|
||
if port == 0 {
|
||
port = 9200
|
||
}
|
||
return fmt.Sprintf("http://%s:%d", host, port)
|
||
}
|
||
|
||
// esDo 发请求并在设置了用户时启用 Basic Auth(ES 开启 security 时用)。
|
||
func esDo(ctx context.Context, c ConnReq, method, path string, body, out any) error {
|
||
var rd io.Reader
|
||
if body != nil {
|
||
b, err := json.Marshal(body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rd = bytes.NewReader(b)
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, method, esBase(c)+path, rd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
if c.User != "" {
|
||
req.SetBasicAuth(c.User, c.Password)
|
||
}
|
||
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("ES 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 esPing(c ConnReq) error {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||
defer cancel()
|
||
var info struct {
|
||
Tagline string `json:"tagline"`
|
||
}
|
||
if err := esDo(ctx, c, http.MethodGet, "/", nil, &info); err != nil {
|
||
return err
|
||
}
|
||
if info.Tagline == "" {
|
||
return fmt.Errorf("ES 响应异常:缺少 tagline")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// esSQLResult /_sql 的 JSON 响应(format=json)。
|
||
type esSQLResult struct {
|
||
Columns []struct {
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
} `json:"columns"`
|
||
Rows [][]interface{} `json:"rows"`
|
||
}
|
||
|
||
func esRunSQL(ctx context.Context, c ConnReq, sqlText string, fetchSize int) (ExecResult, error) {
|
||
var r esSQLResult
|
||
body := map[string]any{"query": sqlText}
|
||
if fetchSize > 0 {
|
||
body["fetch_size"] = fetchSize
|
||
}
|
||
if err := esDo(ctx, c, http.MethodPost, "/_sql", body, &r); err != nil {
|
||
return ExecResult{}, err
|
||
}
|
||
cols := make([]string, 0, len(r.Columns))
|
||
for _, col := range r.Columns {
|
||
cols = append(cols, col.Name)
|
||
}
|
||
rows := make([]map[string]interface{}, 0, len(r.Rows))
|
||
for _, row := range r.Rows {
|
||
m := make(map[string]interface{}, len(cols))
|
||
for i, v := range row {
|
||
if i < len(cols) {
|
||
m[cols[i]] = v
|
||
}
|
||
}
|
||
rows = append(rows, m)
|
||
}
|
||
return ExecResult{
|
||
Columns: cols,
|
||
Rows: rows,
|
||
RowCount: len(rows),
|
||
Truncated: fetchSize > 0 && len(rows) >= fetchSize,
|
||
Limit: fetchSize,
|
||
}, nil
|
||
}
|
||
|
||
func esListDatabases() []string { return []string{"default"} }
|
||
|
||
func esListTables(ctx context.Context, c ConnReq) ([]string, error) {
|
||
res, err := esRunSQL(ctx, c, "SHOW TABLES", 10000)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := []string{}
|
||
if len(res.Columns) == 0 {
|
||
return out, nil
|
||
}
|
||
// SHOW TABLES 列为 [catalog, name, type, kind]:取 "name" 列(ES 8 实测),退化为首列
|
||
idx := 0
|
||
for i, col := range res.Columns {
|
||
if col == "name" {
|
||
idx = i
|
||
break
|
||
}
|
||
}
|
||
for _, row := range res.Rows {
|
||
if v, ok := row[res.Columns[idx]].(string); ok && v != "" {
|
||
out = append(out, v)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func esListColumns(ctx context.Context, c ConnReq) ([]ColumnInfo, error) {
|
||
table := strings.TrimSpace(c.Table)
|
||
if table == "" {
|
||
return nil, fmt.Errorf("缺少表名")
|
||
}
|
||
res, err := esRunSQL(ctx, c, "DESCRIBE \""+strings.ReplaceAll(table, "\"", "")+"\"", 1000)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
cols := []ColumnInfo{}
|
||
for _, row := range res.Rows {
|
||
name, _ := row["column"].(string)
|
||
typ, _ := row["type"].(string)
|
||
if name == "" {
|
||
continue
|
||
}
|
||
cols = append(cols, ColumnInfo{Name: name, Type: typ, Nullable: true})
|
||
}
|
||
return cols, nil
|
||
}
|
||
|
||
// esExecSQL 全部按查询处理:ES SQL 只读,写语句由 ES 拒绝;危险语句在 ops.go 入口已拦。
|
||
func esExecSQL(ctx context.Context, c ConnReq, start time.Time) ExecResult {
|
||
if c.Offset > 0 {
|
||
return ExecResult{Columns: []string{}, Rows: []map[string]interface{}{}, Error: "ES 游标分页暂不支持翻页(仅首屏,可缩小范围重查)", TimeMs: time.Since(start).Milliseconds()}
|
||
}
|
||
limit := rowLimit(c)
|
||
if limit > 10000 {
|
||
limit = 10000
|
||
}
|
||
res, err := esRunSQL(ctx, c, strings.TrimSpace(c.SQL), limit)
|
||
if err != nil {
|
||
return execFail(start, err)
|
||
}
|
||
res.TimeMs = time.Since(start).Milliseconds()
|
||
return res
|
||
}
|