Files
opsxdb/apps/server/internal/db/redis.go
T
logan f8740d27f7 feat: OpsxDB 桌面数据库客户端首版入库
含工作台/连库多引擎字段与 SSH·SSL、结果编辑、Redis 键浏览、
Tauri 桌面与 docs/落地页;排除本地构建产物与 sidecar 二进制。
2026-09-19 22:43:45 +08:00

598 lines
17 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
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(),
}
}