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:
Lizandro Guarnizo
2026-05-20 21:07:33 -05:00
co-authored by Copilot
parent fc647944b0
commit 63d124215b
4 changed files with 493 additions and 11 deletions
+158 -7
View File
@@ -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) {