feat: OpsxDB 桌面数据库客户端首版入库

含工作台/连库多引擎字段与 SSH·SSL、结果编辑、Redis 键浏览、
Tauri 桌面与 docs/落地页;排除本地构建产物与 sidecar 二进制。
This commit is contained in:
logan committed 2026-09-19 22:43:45 +08:00
commit f8740d27f7
240 files changed
+21625

No files matched your search

+597
View File
@@ -0,0 +1,597 @@
package db
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
func redisOpts(c ConnReq) (*redis.Options, error) {
host := c.Host
if host == "" {
host = "127.0.0.1"
}
port := c.Port
if port == 0 {
port = 6379
}
db := 0
if c.Database != "" {
if n, err := strconv.Atoi(c.Database); err == nil {
db = n
}
}
opts := &redis.Options{
Addr: fmt.Sprintf("%s:%d", host, port),
Password: c.Password,
Username: c.User,
DB: db,
DialTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
if SSLEnabled(c) {
tlsCfg, err := tlsConfigFrom(c)
if err != nil {
return nil, err
}
opts.TLSConfig = tlsCfg
}
return opts, nil
}
func openRedis(c ConnReq) (*redis.Client, error) {
opts, err := redisOpts(c)
if err != nil {
return nil, err
}
return redis.NewClient(opts), nil
}
func testRedis(c ConnReq) error {
cli, err := openRedis(c)
if err != nil {
return err
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return cli.Ping(ctx).Err()
}
func listDatabasesRedis(c ConnReq) ([]string, error) {
cli, err := openRedis(c)
if err != nil {
return nil, err
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// INFO keyspace 可拿已用 DB;统一暴露 0-15 便于切换
_ = ctx
out := make([]string, 16)
for i := 0; i < 16; i++ {
out[i] = strconv.Itoa(i)
}
return out, nil
}
func listTablesRedis(c ConnReq) ([]string, error) {
cli, err := openRedis(c)
if err != nil {
return nil, err
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
var keys []string
var cursor uint64
for {
batch, next, err := cli.Scan(ctx, cursor, "*", 200).Result()
if err != nil {
return nil, err
}
keys = append(keys, batch...)
cursor = next
if cursor == 0 || len(keys) >= 500 {
break
}
}
if keys == nil {
keys = []string{}
}
return keys, nil
}
func execRedis(parent context.Context, c ConnReq) ExecResult {
start := time.Now()
sql := strings.TrimSpace(c.SQL)
if sql == "" {
return ExecResult{Error: "empty sql", TimeMs: 0}
}
cli, err := openRedis(c)
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
defer cli.Close()
if parent == nil {
parent = context.Background()
}
ctx, cancel := context.WithTimeout(parent, 10*time.Second)
defer cancel()
fields := splitRedisCmd(sql)
if len(fields) == 0 {
return ExecResult{Error: "empty command", TimeMs: time.Since(start).Milliseconds()}
}
cmd := strings.ToUpper(fields[0])
args := fields[1:]
switch cmd {
case "PING":
val, err := cli.Ping(ctx).Result()
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": val}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
case "GET":
if len(args) < 1 {
return ExecResult{Error: "GET key", TimeMs: time.Since(start).Milliseconds()}
}
val, err := cli.Get(ctx, args[0]).Result()
if err == redis.Nil {
return ExecResult{Columns: []string{"key", "value"}, Rows: []map[string]interface{}{}, RowCount: 0, TimeMs: time.Since(start).Milliseconds()}
}
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
return ExecResult{Columns: []string{"key", "value"}, Rows: []map[string]interface{}{{"key": args[0], "value": val}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
case "SET":
if len(args) < 2 {
return ExecResult{Error: "SET key value", TimeMs: time.Since(start).Milliseconds()}
}
if err := cli.Set(ctx, args[0], strings.Join(args[1:], " "), 0).Err(); err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
return ExecResult{Columns: []string{"affected_rows"}, Rows: []map[string]interface{}{{"affected_rows": 1}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
case "DEL":
if len(args) < 1 {
return ExecResult{Error: "DEL key [key...]", TimeMs: time.Since(start).Milliseconds()}
}
n, err := cli.Del(ctx, args...).Result()
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
return ExecResult{Columns: []string{"affected_rows"}, Rows: []map[string]interface{}{{"affected_rows": n}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
case "KEYS":
pat := "*"
if len(args) > 0 {
pat = args[0]
}
keys, err := cli.Keys(ctx, pat).Result()
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
rows := make([]map[string]interface{}, 0, len(keys))
for _, k := range keys {
rows = append(rows, map[string]interface{}{"key": k})
}
return ExecResult{Columns: []string{"key"}, Rows: rows, RowCount: len(rows), TimeMs: time.Since(start).Milliseconds()}
case "SCAN":
keys, err := listTablesRedis(c)
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
rows := make([]map[string]interface{}, 0, len(keys))
for _, k := range keys {
rows = append(rows, map[string]interface{}{"key": k})
}
return ExecResult{Columns: []string{"key"}, Rows: rows, RowCount: len(rows), TimeMs: time.Since(start).Milliseconds()}
case "HGETALL":
if len(args) < 1 {
return ExecResult{Error: "HGETALL key", TimeMs: time.Since(start).Milliseconds()}
}
c2 := c
c2.Key = args[0]
c2.Limit = redisValueRowCap
v := GetRedisKey(c2)
if v.Error != "" && v.Type == "" {
return ExecResult{Error: v.Error, TimeMs: time.Since(start).Milliseconds()}
}
return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error}
case "LRANGE":
if len(args) < 1 {
return ExecResult{Error: "LRANGE key [start stop]", TimeMs: time.Since(start).Milliseconds()}
}
c2 := c
c2.Key = args[0]
c2.Limit = redisValueRowCap
v := GetRedisKey(c2)
return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error}
case "SMEMBERS":
if len(args) < 1 {
return ExecResult{Error: "SMEMBERS key", TimeMs: time.Since(start).Milliseconds()}
}
c2 := c
c2.Key = args[0]
v := GetRedisKey(c2)
return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error}
case "ZRANGE":
if len(args) < 1 {
return ExecResult{Error: "ZRANGE key [start stop]", TimeMs: time.Since(start).Milliseconds()}
}
c2 := c
c2.Key = args[0]
v := GetRedisKey(c2)
return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error}
case "TYPE":
if len(args) < 1 {
return ExecResult{Error: "TYPE key", TimeMs: time.Since(start).Milliseconds()}
}
t, err := cli.Type(ctx, args[0]).Result()
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
return ExecResult{Columns: []string{"key", "type"}, Rows: []map[string]interface{}{{"key": args[0], "type": t}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
case "TTL":
if len(args) < 1 {
return ExecResult{Error: "TTL key", TimeMs: time.Since(start).Milliseconds()}
}
n, err := cli.TTL(ctx, args[0]).Result()
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
return ExecResult{Columns: []string{"key", "ttl_sec"}, Rows: []map[string]interface{}{{"key": args[0], "ttl_sec": int64(n.Seconds())}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
default:
return ExecResult{Error: fmt.Sprintf("暂不支持命令 %s(可用: PING/GET/SET/DEL/KEYS/SCAN/TYPE/TTL/HGETALL/LRANGE/SMEMBERS/ZRANGE)", cmd), TimeMs: time.Since(start).Milliseconds()}
}
}
func splitRedisCmd(s string) []string {
var out []string
var cur strings.Builder
inQ := false
esc := false
for _, r := range s {
switch {
case esc:
cur.WriteRune(r)
esc = false
case r == '\\':
esc = true
case r == '"':
inQ = !inQ
case !inQ && (r == ' ' || r == '\t' || r == '\n'):
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
default:
cur.WriteRune(r)
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
func isRedis(d string) bool {
return driverKey(d) == "redis"
}
// IsRedisDriver exported for router guards.
func IsRedisDriver(d string) bool {
return isRedis(d)
}
func listColumnsRedis(c ConnReq, key string) ([]ColumnInfo, error) {
cli, err := openRedis(c)
if err != nil {
return nil, err
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
typ, err := cli.Type(ctx, key).Result()
if err != nil {
return nil, err
}
ttl, _ := cli.TTL(ctx, key).Result()
return []ColumnInfo{
{Name: key, Type: typ, Comment: fmt.Sprintf("ttl=%ds", int64(ttl.Seconds()))},
}, nil
}
const redisKeyListCap = 500
const redisValueRowCap = 500
func redisKeyName(c ConnReq) string {
if c.Key != "" {
return c.Key
}
return c.Table
}
func redisScanPattern(c ConnReq) string {
p := strings.TrimSpace(c.Pattern)
if p == "" {
return "*"
}
return p
}
func redisValueLimit(c ConnReq) int {
n := c.Limit
if n <= 0 {
n = redisValueRowCap
}
if n > MaxRowLimit {
n = MaxRowLimit
}
return n
}
func ttlSeconds(d time.Duration) int64 {
// go-redis maps Redis TTL -1 (no expire) / -2 (missing) to -1ns/-2ns
// (some versions use -1s/-2s). Truncating with d/time.Second turns those into 0.
switch d {
case time.Duration(-1), -1 * time.Second:
return -1
case time.Duration(-2), -2 * time.Second:
return -2
}
return int64(d / time.Second)
}
// ListRedisKeys SCAN + pipeline TYPE/TTL. Cap ~500.
func ListRedisKeys(c ConnReq) RedisKeysResult {
start := time.Now()
cli, err := openRedis(c)
if err != nil {
return RedisKeysResult{Keys: []RedisKeyInfo{}, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
pat := redisScanPattern(c)
var names []string
var cursor uint64
truncated := false
for {
batch, next, err := cli.Scan(ctx, cursor, pat, 200).Result()
if err != nil {
return RedisKeysResult{Keys: []RedisKeyInfo{}, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
names = append(names, batch...)
cursor = next
if len(names) >= redisKeyListCap {
truncated = true
names = names[:redisKeyListCap]
break
}
if cursor == 0 {
break
}
}
if names == nil {
names = []string{}
}
out := make([]RedisKeyInfo, len(names))
if len(names) == 0 {
return RedisKeysResult{Keys: out, Truncated: truncated, TimeMs: time.Since(start).Milliseconds()}
}
// Pipeline TYPE + TTL (avoid N+1)
pipe := cli.Pipeline()
typeCmds := make([]*redis.StatusCmd, len(names))
ttlCmds := make([]*redis.DurationCmd, len(names))
for i, k := range names {
typeCmds[i] = pipe.Type(ctx, k)
ttlCmds[i] = pipe.TTL(ctx, k)
}
_, _ = pipe.Exec(ctx)
for i, k := range names {
typ := "none"
if t, err := typeCmds[i].Result(); err == nil {
typ = t
}
ttl := int64(-2)
if d, err := ttlCmds[i].Result(); err == nil {
ttl = ttlSeconds(d)
}
out[i] = RedisKeyInfo{Name: k, Type: typ, TTL: ttl}
}
return RedisKeysResult{Keys: out, Truncated: truncated, TimeMs: time.Since(start).Milliseconds()}
}
// GetRedisKey typed value → ResultPane columns/rows.
func GetRedisKey(c ConnReq) RedisKeyValue {
start := time.Now()
key := redisKeyName(c)
if key == "" {
return RedisKeyValue{Error: "缺少 key", TimeMs: time.Since(start).Milliseconds()}
}
cli, err := openRedis(c)
if err != nil {
return RedisKeyValue{Name: key, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
typ, err := cli.Type(ctx, key).Result()
if err != nil {
return RedisKeyValue{Name: key, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
ttl := int64(-2)
if d, err := cli.TTL(ctx, key).Result(); err == nil {
ttl = ttlSeconds(d)
}
limit := redisValueLimit(c)
base := RedisKeyValue{Name: key, Type: typ, TTL: ttl, Limit: limit}
switch typ {
case "none":
base.Columns = []string{"value"}
base.Rows = []map[string]interface{}{}
base.RowCount = 0
base.Error = "key 不存在"
base.TimeMs = time.Since(start).Milliseconds()
return base
case "string":
val, err := cli.Get(ctx, key).Result()
if err == redis.Nil {
base.Columns = []string{"value"}
base.Rows = []map[string]interface{}{}
base.RowCount = 0
base.TimeMs = time.Since(start).Milliseconds()
return base
}
if err != nil {
base.Error = errMsg(err)
base.TimeMs = time.Since(start).Milliseconds()
return base
}
base.Columns = []string{"value"}
base.Rows = []map[string]interface{}{{"value": val}}
base.RowCount = 1
base.TimeMs = time.Since(start).Milliseconds()
return base
case "hash":
fields, err := cli.HGetAll(ctx, key).Result()
if err != nil {
base.Error = errMsg(err)
base.TimeMs = time.Since(start).Milliseconds()
return base
}
base.Columns = []string{"field", "value"}
rows := make([]map[string]interface{}, 0, len(fields))
for f, v := range fields {
rows = append(rows, map[string]interface{}{"field": f, "value": v})
if len(rows) >= limit {
base.Truncated = true
break
}
}
base.Rows = rows
base.RowCount = len(rows)
base.TimeMs = time.Since(start).Milliseconds()
return base
case "list":
vals, err := cli.LRange(ctx, key, 0, int64(limit)-1).Result()
if err != nil {
base.Error = errMsg(err)
base.TimeMs = time.Since(start).Milliseconds()
return base
}
truncated := false
n, _ := cli.LLen(ctx, key).Result()
if n > int64(len(vals)) {
truncated = true
}
base.Columns = []string{"index", "value"}
rows := make([]map[string]interface{}, 0, len(vals))
for i, v := range vals {
rows = append(rows, map[string]interface{}{"index": i, "value": v})
}
base.Rows = rows
base.RowCount = len(rows)
base.Truncated = truncated
base.TimeMs = time.Since(start).Milliseconds()
return base
case "set":
base.Columns = []string{"member"}
var rows []map[string]interface{}
var scursor uint64
truncated := false
for {
batch, next, err := cli.SScan(ctx, key, scursor, "*", 200).Result()
if err != nil {
base.Error = errMsg(err)
base.TimeMs = time.Since(start).Milliseconds()
return base
}
for _, m := range batch {
rows = append(rows, map[string]interface{}{"member": m})
if len(rows) >= limit {
truncated = true
break
}
}
scursor = next
if truncated || scursor == 0 {
break
}
}
if rows == nil {
rows = []map[string]interface{}{}
}
base.Rows = rows
base.RowCount = len(rows)
base.Truncated = truncated
base.TimeMs = time.Since(start).Milliseconds()
return base
case "zset":
vals, err := cli.ZRangeWithScores(ctx, key, 0, int64(limit-1)).Result()
if err != nil {
base.Error = errMsg(err)
base.TimeMs = time.Since(start).Milliseconds()
return base
}
truncated := false
n, _ := cli.ZCard(ctx, key).Result()
if n > int64(len(vals)) {
truncated = true
}
base.Columns = []string{"score", "member"}
rows := make([]map[string]interface{}, 0, len(vals))
for _, z := range vals {
rows = append(rows, map[string]interface{}{"score": z.Score, "member": z.Member})
}
base.Rows = rows
base.RowCount = len(rows)
base.Truncated = truncated
base.TimeMs = time.Since(start).Milliseconds()
return base
default:
// stream / recon / module types: meta only
base.Columns = []string{"info"}
base.Rows = []map[string]interface{}{{"info": fmt.Sprintf("类型 %s 暂不展开,可用命令查看", typ)}}
base.RowCount = 1
base.TimeMs = time.Since(start).Milliseconds()
return base
}
}
// DeleteRedisKey DEL one key.
func DeleteRedisKey(c ConnReq) ExecResult {
start := time.Now()
key := redisKeyName(c)
if key == "" {
return ExecResult{Error: "缺少 key", TimeMs: time.Since(start).Milliseconds()}
}
cli, err := openRedis(c)
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
n, err := cli.Del(ctx, key).Result()
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
return ExecResult{
Columns: []string{"affected_rows"},
Rows: []map[string]interface{}{{"affected_rows": n}},
RowCount: 1,
TimeMs: time.Since(start).Milliseconds(),
}
}