feat(query-runner): soporte completo Redis
Backend: - Agrega github.com/redis/go-redis/v9 como dependencia - isRedisDriver() detecta 'redis' y 'valkey' en nombre del tipo de BD - redisConnect(): abre cliente Redis con auth, host, port, db index - redisTestConnection(): PING para probar conexión - redisListDatabases(): lee CONFIG GET databases → devuelve db0..dbN - redisListKeys(): SCAN iterativo, hasta 200 keys del db seleccionado - redisExecuteCommand(): parsea comandos línea a línea, multi-comando devuelve tabla command/result/error; comando único devuelve QueryResult tipado (string, int64, []any para KEYS/SMEMBERS/LRANGE, HGETALL como field/value table) - redisParseArgs(): tokenizador respetando comillas simples y dobles - Todos los comandos se guardan en query_history Frontend: - isRedis: false estado y reset en onConxChange() - Detecta redis/valkey en tipo_db.nombre - Editor cambia label 'Redis CLI', placeholder con ejemplos Redis - Sidebar: 'Keys' en lugar de 'Tablas' para Redis - insertTable(): TYPE + TTL + GET para inspeccionar una key de Redis - Panel de chips rojos: KEYS *, DBSIZE, INFO, CONFIG GET, CLIENT LIST, SLOWLOG - Generador Redis: select de 24 comandos + formulario contextual (key, value, ttl, field según el comando elegido) - generateRedisCmd(): genera el comando en el editor - Badge MySQL no aparece cuando es Redis Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
parent
fc647944b0
commit
63d124215b
@@ -1,8 +1,6 @@
|
||||
module github.com/sujit-baniya/fiber-boilerplate
|
||||
|
||||
go 1.22.0
|
||||
|
||||
toolchain go1.22.3
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.4.0 // indirect
|
||||
@@ -32,7 +30,7 @@ require (
|
||||
github.com/valyala/bytebufferpool v1.0.0
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
@@ -114,6 +112,7 @@ require (
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
||||
github.com/pyroscope-io/dotnetdiag v1.2.1 // indirect
|
||||
github.com/redis/go-redis/v9 v9.19.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||
@@ -130,6 +129,7 @@ require (
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/oauth2 v0.23.0 // indirect
|
||||
|
||||
@@ -586,6 +586,8 @@ github.com/pyroscope-io/dotnetdiag v1.2.1 h1:3XEMrfFJnZ87BiEhozyQKmCUAuMd/Spq7KC
|
||||
github.com/pyroscope-io/dotnetdiag v1.2.1/go.mod h1:eFUEHCp4eD1TgcXMlJihC+R4MrqGf7nTRdWxNADbDHA=
|
||||
github.com/pyroscope-io/pyroscope v0.37.2 h1:MOgLU/oO7VfV6jWqb0xoFH/YPSVbWD5pGsX+tZVGh98=
|
||||
github.com/pyroscope-io/pyroscope v0.37.2/go.mod h1:r4wq4ajJvN7g1OeXGyNvmwzBfQ+Tm5alYvmxqEQSTsc=
|
||||
github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
|
||||
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
@@ -712,6 +714,8 @@ go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
|
||||
go.opentelemetry.io/otel v0.13.0/go.mod h1:dlSNewoRYikTkotEnxdmuBHgzT+k/idJSfDv/FxEnOY=
|
||||
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
@@ -960,6 +964,8 @@ golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
_ "github.com/lib/pq"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
_ "github.com/microsoft/go-mssqldb"
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
@@ -84,6 +86,9 @@ func ExecuteSQL(conx models.ConxDb, database, sqlText string) QueryResult {
|
||||
if isMongoDriver(strings.ToLower(conx.TipoDb.Nombre)) {
|
||||
return mongoExecuteSQL(conx, database, sqlText)
|
||||
}
|
||||
if isRedisDriver(strings.ToLower(conx.TipoDb.Nombre)) {
|
||||
return redisExecuteCommand(conx, database, sqlText)
|
||||
}
|
||||
start := time.Now()
|
||||
|
||||
db, err := openDynamicDB(conx)
|
||||
@@ -204,6 +209,9 @@ func ListDatabases(conx models.ConxDb) ([]string, error) {
|
||||
if isMongoDriver(driver) {
|
||||
return mongoListDatabases(conx)
|
||||
}
|
||||
if isRedisDriver(driver) {
|
||||
return redisListDatabases(conx)
|
||||
}
|
||||
db, err := openDynamicDB(conx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -245,6 +253,9 @@ func ListTables(conx models.ConxDb, database string) ([]string, error) {
|
||||
if isMongoDriver(driver) {
|
||||
return mongoListCollections(conx, database)
|
||||
}
|
||||
if isRedisDriver(driver) {
|
||||
return redisListKeys(conx, database)
|
||||
}
|
||||
|
||||
var db *sql.DB
|
||||
var err error
|
||||
@@ -294,6 +305,9 @@ func TestDBConnection(conx models.ConxDb) error {
|
||||
if isMongoDriver(strings.ToLower(conx.TipoDb.Nombre)) {
|
||||
return mongoTestConnection(conx)
|
||||
}
|
||||
if isRedisDriver(strings.ToLower(conx.TipoDb.Nombre)) {
|
||||
return redisTestConnection(conx)
|
||||
}
|
||||
db, err := openDynamicDB(conx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -465,6 +479,317 @@ func saveHistory(conxID uint, sqlText, status, errMsg string, rows, durationMs i
|
||||
})
|
||||
}
|
||||
|
||||
// ── Redis ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func isRedisDriver(driver string) bool {
|
||||
return strings.Contains(driver, "redis") || strings.Contains(driver, "valkey")
|
||||
}
|
||||
|
||||
func redisConnect(c models.ConxDb, dbIndex int) (*goredis.Client, error) {
|
||||
host := c.Servidor.IpServidor
|
||||
port := c.Puerto
|
||||
pass := c.Password
|
||||
if port == "" {
|
||||
port = "6379"
|
||||
}
|
||||
client := goredis.NewClient(&goredis.Options{
|
||||
Addr: fmt.Sprintf("%s:%s", host, port),
|
||||
Password: pass,
|
||||
DB: dbIndex,
|
||||
DialTimeout: 8 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), connTimeout)
|
||||
defer cancel()
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("no se pudo conectar a Redis: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func redisTestConnection(c models.ConxDb) error {
|
||||
client, err := redisConnect(c, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// redisListDatabases devuelve db0..dbN según CONFIG GET databases.
|
||||
func redisListDatabases(c models.ConxDb) ([]string, error) {
|
||||
client, err := redisConnect(c, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
total := 16 // default
|
||||
vals, err2 := client.ConfigGet(ctx, "databases").Result()
|
||||
if err2 == nil && len(vals) >= 2 {
|
||||
if n, parseErr := strconv.Atoi(fmt.Sprint(vals["databases"])); parseErr == nil && n > 0 {
|
||||
total = n
|
||||
}
|
||||
}
|
||||
|
||||
dbs := make([]string, total)
|
||||
for i := range dbs {
|
||||
dbs[i] = fmt.Sprintf("db%d", i)
|
||||
}
|
||||
return dbs, nil
|
||||
}
|
||||
|
||||
// redisListKeys devuelve hasta 200 keys del db seleccionado (SCAN con limit).
|
||||
func redisListKeys(c models.ConxDb, database string) ([]string, error) {
|
||||
dbIndex := 0
|
||||
if strings.HasPrefix(database, "db") {
|
||||
if n, err := strconv.Atoi(database[2:]); err == nil {
|
||||
dbIndex = n
|
||||
}
|
||||
}
|
||||
client, err := redisConnect(c, dbIndex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var keys []string
|
||||
iter := client.Scan(ctx, 0, "*", 200).Iterator()
|
||||
for iter.Next(ctx) {
|
||||
keys = append(keys, iter.Val())
|
||||
if len(keys) >= 200 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := iter.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// redisExecuteCommand parsea y ejecuta un comando Redis.
|
||||
// Los comandos se escriben como en redis-cli: GET key / SET key value / etc.
|
||||
// Soporta múltiples líneas: cada línea no vacía es un comando independiente.
|
||||
func redisExecuteCommand(conx models.ConxDb, database, cmdText string) QueryResult {
|
||||
start := time.Now()
|
||||
|
||||
dbIndex := 0
|
||||
if strings.HasPrefix(database, "db") {
|
||||
if n, err := strconv.Atoi(database[2:]); err == nil {
|
||||
dbIndex = n
|
||||
}
|
||||
}
|
||||
|
||||
client, err := redisConnect(conx, dbIndex)
|
||||
if err != nil {
|
||||
saveHistory(conx.ID, cmdText, "error", err.Error(), 0, time.Since(start).Milliseconds())
|
||||
return QueryResult{Error: err.Error()}
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Dividir en líneas, ignorar vacías y comentarios (#)
|
||||
var lines []string
|
||||
for _, line := range strings.Split(cmdText, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, trimmed)
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
return QueryResult{Error: "comando vacío"}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Si hay múltiples comandos, ejecutarlos en pipeline y devolver tabla de resultados
|
||||
if len(lines) > 1 {
|
||||
var rows []map[string]any
|
||||
for _, line := range lines {
|
||||
args := redisParseArgs(line)
|
||||
if len(args) == 0 {
|
||||
continue
|
||||
}
|
||||
ifaces := make([]any, len(args))
|
||||
for i, a := range args {
|
||||
ifaces[i] = a
|
||||
}
|
||||
val, execErr := client.Do(ctx, ifaces...).Result()
|
||||
row := map[string]any{
|
||||
"command": line,
|
||||
"result": redisValToString(val),
|
||||
"error": "",
|
||||
}
|
||||
if execErr != nil && execErr != goredis.Nil {
|
||||
row["error"] = execErr.Error()
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
elapsed := time.Since(start).Milliseconds()
|
||||
result := QueryResult{
|
||||
Columns: []string{"command", "result", "error"},
|
||||
Rows: rows,
|
||||
RowCount: len(rows),
|
||||
IsSelect: true,
|
||||
DurationMs: elapsed,
|
||||
}
|
||||
saveHistory(conx.ID, cmdText, "ok", "", int64(len(rows)), elapsed)
|
||||
return result
|
||||
}
|
||||
|
||||
// Comando único
|
||||
args := redisParseArgs(lines[0])
|
||||
if len(args) == 0 {
|
||||
return QueryResult{Error: "comando vacío"}
|
||||
}
|
||||
ifaces := make([]any, len(args))
|
||||
for i, a := range args {
|
||||
ifaces[i] = a
|
||||
}
|
||||
|
||||
val, execErr := client.Do(ctx, ifaces...).Result()
|
||||
elapsed := time.Since(start).Milliseconds()
|
||||
|
||||
if execErr != nil && execErr != goredis.Nil {
|
||||
saveHistory(conx.ID, cmdText, "error", execErr.Error(), 0, elapsed)
|
||||
return QueryResult{Error: execErr.Error(), DurationMs: elapsed}
|
||||
}
|
||||
|
||||
result := redisResultToQueryResult(val, lines[0])
|
||||
result.DurationMs = elapsed
|
||||
saveHistory(conx.ID, cmdText, "ok", "", int64(result.RowCount), elapsed)
|
||||
return result
|
||||
}
|
||||
|
||||
// redisResultToQueryResult convierte la respuesta de Redis en QueryResult presentable.
|
||||
func redisResultToQueryResult(val any, cmd string) QueryResult {
|
||||
upper := strings.ToUpper(strings.Fields(cmd)[0])
|
||||
|
||||
switch v := val.(type) {
|
||||
case nil:
|
||||
return QueryResult{
|
||||
IsSelect: true,
|
||||
Columns: []string{"result"},
|
||||
Rows: []map[string]any{{"result": "(nil)"}},
|
||||
RowCount: 1,
|
||||
}
|
||||
case string:
|
||||
return QueryResult{
|
||||
IsSelect: true,
|
||||
Columns: []string{"result"},
|
||||
Rows: []map[string]any{{"result": v}},
|
||||
RowCount: 1,
|
||||
}
|
||||
case int64:
|
||||
label := "result"
|
||||
if upper == "DEL" || upper == "EXISTS" || upper == "SREM" || upper == "LREM" {
|
||||
label = "affected"
|
||||
} else if upper == "TTL" || upper == "PTTL" {
|
||||
label = "ttl_seconds"
|
||||
} else if upper == "DBSIZE" || upper == "LLEN" || upper == "SCARD" || upper == "ZCARD" || upper == "HLEN" {
|
||||
label = "count"
|
||||
}
|
||||
return QueryResult{
|
||||
IsSelect: false,
|
||||
AffectedRows: v,
|
||||
Columns: []string{label},
|
||||
Rows: []map[string]any{{label: v}},
|
||||
RowCount: 1,
|
||||
}
|
||||
case []any:
|
||||
// Lista o conjunto de valores
|
||||
if upper == "HGETALL" && len(v)%2 == 0 {
|
||||
// Alternar field/value → formato tabla
|
||||
var rows []map[string]any
|
||||
for i := 0; i+1 < len(v); i += 2 {
|
||||
rows = append(rows, map[string]any{
|
||||
"field": redisValToString(v[i]),
|
||||
"value": redisValToString(v[i+1]),
|
||||
})
|
||||
}
|
||||
return QueryResult{IsSelect: true, Columns: []string{"field", "value"}, Rows: rows, RowCount: len(rows)}
|
||||
}
|
||||
// KEYS, SMEMBERS, LRANGE, etc.
|
||||
var rows []map[string]any
|
||||
for _, item := range v {
|
||||
rows = append(rows, map[string]any{"value": redisValToString(item)})
|
||||
}
|
||||
return QueryResult{IsSelect: true, Columns: []string{"value"}, Rows: rows, RowCount: len(rows)}
|
||||
case map[any]any:
|
||||
var rows []map[string]any
|
||||
for k, mv := range v {
|
||||
rows = append(rows, map[string]any{
|
||||
"field": redisValToString(k),
|
||||
"value": redisValToString(mv),
|
||||
})
|
||||
}
|
||||
return QueryResult{IsSelect: true, Columns: []string{"field", "value"}, Rows: rows, RowCount: len(rows)}
|
||||
default:
|
||||
return QueryResult{
|
||||
IsSelect: true,
|
||||
Columns: []string{"result"},
|
||||
Rows: []map[string]any{{"result": fmt.Sprintf("%v", val)}},
|
||||
RowCount: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func redisValToString(v any) string {
|
||||
if v == nil {
|
||||
return "(nil)"
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
// redisParseArgs divide un comando Redis en tokens respetando comillas.
|
||||
// Ej: SET mykey "hello world" → ["SET", "mykey", "hello world"]
|
||||
func redisParseArgs(cmd string) []string {
|
||||
var args []string
|
||||
var cur strings.Builder
|
||||
inQ := false
|
||||
qChar := byte(0)
|
||||
|
||||
for i := 0; i < len(cmd); i++ {
|
||||
ch := cmd[i]
|
||||
if inQ {
|
||||
if ch == qChar {
|
||||
inQ = false
|
||||
} else if ch == '\\' && i+1 < len(cmd) {
|
||||
i++
|
||||
cur.WriteByte(cmd[i])
|
||||
} else {
|
||||
cur.WriteByte(ch)
|
||||
}
|
||||
} else {
|
||||
if ch == '"' || ch == '\'' {
|
||||
inQ = true
|
||||
qChar = ch
|
||||
} else if ch == ' ' || ch == '\t' {
|
||||
if cur.Len() > 0 {
|
||||
args = append(args, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
} else {
|
||||
cur.WriteByte(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
args = append(args, cur.String())
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// ── MongoDB ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func isMongoDriver(driver string) bool {
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<!-- Árbol de tablas / colecciones + visor de campos -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden" x-show="tables.length > 0">
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wide px-1 mb-1" x-text="isMongo ? 'Colecciones' : 'Tablas'"></p>
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wide px-1 mb-1" x-text="isMongo ? 'Colecciones' : isRedis ? 'Keys' : 'Tablas'"></p>
|
||||
<template x-for="t in tables" :key="t">
|
||||
<div class="flex items-center group mb-0.5">
|
||||
<button @click="insertTable(t)"
|
||||
@@ -163,19 +163,19 @@
|
||||
<div class="px-4 pt-3 pb-0">
|
||||
<div class="border rounded-lg overflow-hidden bg-gray-900">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 bg-gray-800 border-b border-gray-700">
|
||||
<span class="text-xs text-gray-400" x-text="isMongo ? 'MongoDB Shell' : 'SQL Editor'"></span>
|
||||
<span class="text-xs text-gray-400" x-text="isMongo ? 'MongoDB Shell' : isRedis ? 'Redis CLI' : 'SQL Editor'"></span>
|
||||
<div class="flex-1"></div>
|
||||
<span x-show="selectedDb" class="text-xs text-[#4ade80] font-mono" x-text="selectedDb"></span>
|
||||
</div>
|
||||
<textarea id="sql-editor" x-model="sqlText"
|
||||
@keydown.ctrl.enter.prevent="runQuery()"
|
||||
@keydown.meta.enter.prevent="runQuery()"
|
||||
:placeholder="isMongo ? 'db.coleccion.find({})\n// Ctrl+Enter para ejecutar\n// db.coleccion.insertOne({campo: \'valor\'})' : '-- Escribe tu consulta SQL aquí\n-- Ctrl+Enter para ejecutar\nSELECT * FROM tabla LIMIT 100;'"
|
||||
:placeholder="isMongo ? 'db.coleccion.find({})\n// Ctrl+Enter para ejecutar\n// db.coleccion.insertOne({campo: \'valor\'})' : isRedis ? '# Comandos Redis (uno por línea)\nKEYS *\nGET mi_clave\nHGETALL mi_hash\n# Ctrl+Enter para ejecutar' : '-- Escribe tu consulta SQL aquí\n-- Ctrl+Enter para ejecutar\nSELECT * FROM tabla LIMIT 100;'"
|
||||
class="w-full bg-gray-900 text-gray-100 font-mono text-sm p-4 resize-none outline-none leading-relaxed"
|
||||
style="height:180px; tab-size:2;"></textarea>
|
||||
</div>
|
||||
<!-- ─── Helpers SQL (solo MySQL/PostgreSQL) ─── -->
|
||||
<div x-show="!isMongo && selectedConxId" class="mt-1.5">
|
||||
<!-- ─── Helpers SQL (solo MySQL/PostgreSQL, no Redis) ─── -->
|
||||
<div x-show="!isMongo && !isRedis && selectedConxId" class="mt-1.5">
|
||||
<!-- Chips rápidos -->
|
||||
<div class="flex flex-wrap gap-1 mb-1.5">
|
||||
<template x-for="h in sqlHelpers" :key="h.label">
|
||||
@@ -193,7 +193,7 @@
|
||||
</svg>
|
||||
<span class="font-semibold">Generador DBA</span>
|
||||
<span x-show="isPostgres" class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-600 font-medium">PostgreSQL</span>
|
||||
<span x-show="!isPostgres" class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-600 font-medium">MySQL</span>
|
||||
<span x-show="!isPostgres && !isRedis" class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-600 font-medium">MySQL</span>
|
||||
<span class="ml-auto text-[10px] text-gray-400">CREATE · GRANT · DROP · Setup</span>
|
||||
</button>
|
||||
<div x-show="showDbHelper" x-transition class="p-3 bg-white border-t">
|
||||
@@ -342,6 +342,98 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Helpers Redis ─── -->
|
||||
<div x-show="isRedis && selectedConxId" class="mt-1.5">
|
||||
<div class="flex flex-wrap gap-1 mb-1.5">
|
||||
<template x-for="h in redisHelpers" :key="h.label">
|
||||
<button @click="sqlText = h.cmd" class="text-[10px] px-2 py-0.5 rounded bg-red-50 hover:bg-red-100 text-red-700 font-mono transition" x-text="h.label"></button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ══ Generador Redis ══ -->
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<button @click="showRedisBuilder = !showRedisBuilder"
|
||||
class="w-full flex items-center gap-1.5 px-3 py-1.5 bg-gray-50 hover:bg-red-50 text-xs text-gray-600 transition text-left select-none">
|
||||
<svg class="w-3 h-3 transition-transform duration-150" :class="showRedisBuilder ? 'rotate-90' : ''"
|
||||
fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<span class="font-semibold">Generador Redis</span>
|
||||
<span class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-red-100 text-red-600 font-medium">Redis</span>
|
||||
<span class="ml-auto text-[10px] text-gray-400">GET · SET · DEL · HGETALL · TTL</span>
|
||||
</button>
|
||||
<div x-show="showRedisBuilder" x-transition class="p-3 bg-white border-t">
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">Comando</label>
|
||||
<select x-model="redisBuilder.cmd" class="w-full border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-red-400">
|
||||
<option value="GET">GET — obtener valor</option>
|
||||
<option value="SET">SET — asignar valor</option>
|
||||
<option value="SETEX">SETEX — asignar con TTL</option>
|
||||
<option value="DEL">DEL — eliminar clave(s)</option>
|
||||
<option value="EXISTS">EXISTS — ¿existe la clave?</option>
|
||||
<option value="TTL">TTL — tiempo de vida (seg)</option>
|
||||
<option value="EXPIRE">EXPIRE — establecer TTL</option>
|
||||
<option value="TYPE">TYPE — tipo de la clave</option>
|
||||
<option value="KEYS">KEYS — buscar por patrón</option>
|
||||
<option value="SCAN">SCAN — iterar claves</option>
|
||||
<option value="HSET">HSET — asignar campo hash</option>
|
||||
<option value="HGET">HGET — obtener campo hash</option>
|
||||
<option value="HGETALL">HGETALL — todo el hash</option>
|
||||
<option value="HDEL">HDEL — eliminar campo hash</option>
|
||||
<option value="LPUSH">LPUSH — añadir a lista</option>
|
||||
<option value="LRANGE">LRANGE — leer rango de lista</option>
|
||||
<option value="SADD">SADD — añadir a set</option>
|
||||
<option value="SMEMBERS">SMEMBERS — todos los miembros</option>
|
||||
<option value="ZADD">ZADD — añadir a sorted set</option>
|
||||
<option value="ZRANGE">ZRANGE — rango sorted set</option>
|
||||
<option value="DBSIZE">DBSIZE — total de claves</option>
|
||||
<option value="FLUSHDB">FLUSHDB — vaciar base de datos</option>
|
||||
<option value="INFO">INFO — info del servidor</option>
|
||||
<option value="CONFIG GET">CONFIG GET — ver config</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">Clave</label>
|
||||
<input x-model="redisBuilder.key" list="redis-keys"
|
||||
class="w-full border rounded px-2 py-1 font-mono focus:outline-none focus:ring-1 focus:ring-red-400"
|
||||
placeholder="mi:clave:aqui">
|
||||
<datalist id="redis-keys">
|
||||
<template x-for="k in tables" :key="k"><option :value="k"></option></template>
|
||||
</datalist>
|
||||
</div>
|
||||
<div x-show="['SET','SETEX','HSET','LPUSH','SADD','ZADD'].includes(redisBuilder.cmd)" class="col-span-2">
|
||||
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">Valor</label>
|
||||
<input x-model="redisBuilder.value"
|
||||
class="w-full border rounded px-2 py-1 font-mono focus:outline-none focus:ring-1 focus:ring-red-400"
|
||||
placeholder="valor">
|
||||
</div>
|
||||
<div x-show="['SETEX','EXPIRE'].includes(redisBuilder.cmd)">
|
||||
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">TTL (segundos)</label>
|
||||
<input x-model="redisBuilder.ttl" type="number"
|
||||
class="w-full border rounded px-2 py-1 font-mono focus:outline-none focus:ring-1 focus:ring-red-400"
|
||||
placeholder="3600">
|
||||
</div>
|
||||
<div x-show="['HSET','HGET','HDEL'].includes(redisBuilder.cmd)">
|
||||
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">Campo (field)</label>
|
||||
<input x-model="redisBuilder.field"
|
||||
class="w-full border rounded px-2 py-1 font-mono focus:outline-none focus:ring-1 focus:ring-red-400"
|
||||
placeholder="campo">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-2.5 pt-2.5 border-t">
|
||||
<button @click="generateRedisCmd()"
|
||||
class="flex-1 py-1.5 text-xs text-white font-semibold rounded transition"
|
||||
style="background-color:#dc2626"
|
||||
onmouseover="this.style.backgroundColor='#b91c1c'"
|
||||
onmouseout="this.style.backgroundColor='#dc2626'">
|
||||
↗ Generar en editor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel de resultados / historial -->
|
||||
@@ -444,12 +536,27 @@ document.addEventListener('alpine:init', () => {
|
||||
toast: { show: false, msg: '', type: 'ok' },
|
||||
isMongo: false,
|
||||
isPostgres: false,
|
||||
isRedis: false,
|
||||
selectedCollection: '',
|
||||
collectionFields: [],
|
||||
loadingFields: false,
|
||||
showUpdateBuilder: false,
|
||||
updateBuilder: { collection: '', field: '', value: '', filterField: '_id', filterValue: '""', op: '$set', multi: 'one' },
|
||||
|
||||
// Redis helpers
|
||||
showRedisBuilder: false,
|
||||
redisBuilder: { cmd: 'GET', key: '', value: '', ttl: '3600', field: '' },
|
||||
redisHelpers: [
|
||||
{ label: 'KEYS *', cmd: 'KEYS *' },
|
||||
{ label: 'DBSIZE', cmd: 'DBSIZE' },
|
||||
{ label: 'INFO', cmd: 'INFO server' },
|
||||
{ label: 'INFO memory',cmd: 'INFO memory' },
|
||||
{ label: 'CONFIG GET maxmemory', cmd: 'CONFIG GET maxmemory' },
|
||||
{ label: 'CONFIG GET save', cmd: 'CONFIG GET save' },
|
||||
{ label: 'CLIENT LIST',cmd: 'CLIENT LIST' },
|
||||
{ label: 'SLOWLOG GET',cmd: 'SLOWLOG GET 10' },
|
||||
],
|
||||
|
||||
// SQL DBA helpers
|
||||
showDbHelper: false,
|
||||
dbHelper: { db: '', user: '', pass: '', host: '%', op: 'full_setup' },
|
||||
@@ -514,6 +621,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.statusMsg = '';
|
||||
this.history = [];
|
||||
this.isMongo = false;
|
||||
this.isRedis = false;
|
||||
this.selectedCollection = '';
|
||||
this.collectionFields = [];
|
||||
if (!this.selectedConxId) return;
|
||||
@@ -521,6 +629,7 @@ document.addEventListener('alpine:init', () => {
|
||||
const dbType = conx?.tipo_db?.nombre?.toLowerCase() || '';
|
||||
this.isMongo = dbType.includes('mongo');
|
||||
this.isPostgres = dbType.includes('postgres');
|
||||
this.isRedis = dbType.includes('redis') || dbType.includes('valkey');
|
||||
try {
|
||||
const res = await axios.get('/app/query-runner/databases?conx_db_id=' + this.selectedConxId);
|
||||
this.databases = res.data.data || [];
|
||||
@@ -632,7 +741,14 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
insertTable(name) {
|
||||
const sel = this.isMongo ? `db.${name}.find({})` : `SELECT * FROM ${name} LIMIT 100;`;
|
||||
let sel;
|
||||
if (this.isMongo) {
|
||||
sel = `db.${name}.find({})`;
|
||||
} else if (this.isRedis) {
|
||||
sel = `TYPE ${name}\nTTL ${name}\nGET ${name}`;
|
||||
} else {
|
||||
sel = `SELECT * FROM ${name} LIMIT 100;`;
|
||||
}
|
||||
this.sqlText = sel;
|
||||
document.getElementById('sql-editor')?.focus();
|
||||
this.runQuery();
|
||||
@@ -765,6 +881,41 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
clearEditor() { this.sqlText = ''; this.results = []; this.columns = []; this.statusMsg = ''; },
|
||||
|
||||
generateRedisCmd() {
|
||||
const { cmd, key, value, ttl, field } = this.redisBuilder;
|
||||
const k = key || 'mi_clave';
|
||||
let sql = '';
|
||||
switch (cmd) {
|
||||
case 'GET': sql = `GET ${k}`; break;
|
||||
case 'SET': sql = `SET ${k} "${value || 'valor'}"`; break;
|
||||
case 'SETEX': sql = `SETEX ${k} ${ttl || 3600} "${value || 'valor'}"`; break;
|
||||
case 'DEL': sql = `DEL ${k}`; break;
|
||||
case 'EXISTS': sql = `EXISTS ${k}`; break;
|
||||
case 'TTL': sql = `TTL ${k}`; break;
|
||||
case 'EXPIRE': sql = `EXPIRE ${k} ${ttl || 3600}`; break;
|
||||
case 'TYPE': sql = `TYPE ${k}`; break;
|
||||
case 'KEYS': sql = `KEYS ${k}`; break;
|
||||
case 'SCAN': sql = `SCAN 0 MATCH ${k} COUNT 100`; break;
|
||||
case 'HSET': sql = `HSET ${k} ${field || 'campo'} "${value || 'valor'}"`; break;
|
||||
case 'HGET': sql = `HGET ${k} ${field || 'campo'}`; break;
|
||||
case 'HGETALL': sql = `HGETALL ${k}`; break;
|
||||
case 'HDEL': sql = `HDEL ${k} ${field || 'campo'}`; break;
|
||||
case 'LPUSH': sql = `LPUSH ${k} "${value || 'elemento'}"`; break;
|
||||
case 'LRANGE': sql = `LRANGE ${k} 0 -1`; break;
|
||||
case 'SADD': sql = `SADD ${k} "${value || 'miembro'}"`; break;
|
||||
case 'SMEMBERS': sql = `SMEMBERS ${k}`; break;
|
||||
case 'ZADD': sql = `ZADD ${k} 1 "${value || 'miembro'}"`; break;
|
||||
case 'ZRANGE': sql = `ZRANGE ${k} 0 -1 WITHSCORES`; break;
|
||||
case 'DBSIZE': sql = `DBSIZE`; break;
|
||||
case 'FLUSHDB': sql = `FLUSHDB`; break;
|
||||
case 'INFO': sql = `INFO server`; break;
|
||||
case 'CONFIG GET': sql = `CONFIG GET maxmemory`; break;
|
||||
default: sql = `${cmd} ${k}`;
|
||||
}
|
||||
this.sqlText = sql;
|
||||
document.getElementById('sql-editor')?.focus();
|
||||
},
|
||||
|
||||
nullStr(v) { return v === null || v === undefined ? 'NULL' : String(v); },
|
||||
|
||||
formatDate(d) {
|
||||
|
||||
Reference in New Issue
Block a user