Files
Lizandro GD 21d8cd5260 Agrega diagrama de relaciones (FK) al Query Runner
- GetSchemaRelations: trae todas las llaves foráneas de la base con
  consulta específica por motor (Postgres/MySQL/MSSQL/SQLite). No
  aplica a Redis/Mongo (schemaless).
- Endpoint GET /query-runner/relations, con el mismo chequeo de
  autorización por conexión que ya usa RunQuery — de paso se lo
  agrego también a GetDatabases/GetTables, que no lo tenían.
- UI: botón "Ver relaciones" (diagrama completo) y un ícono por tabla
  en el árbol lateral (relaciones solo de esa tabla), renderizado con
  Mermaid.js servido localmente (public/js/mermaid.min.js, sin CDN).
2026-08-08 23:11:48 +00:00

1883 lines
112 KiB
HTML

<div x-data="queryRunner" class="h-full bg-white rounded-lg shadow flex flex-col">
<!-- Overlay carga -->
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-70 flex justify-center items-center z-50">
<img src="../img/loading.gif" class="w-14 h-14" />
</div>
<div class="flex h-full" style="min-height:calc(100vh - 80px)">
<!-- ══════════════════ SIDEBAR IZQUIERDO (árbol) ══════════════════ -->
<aside class="w-64 shrink-0 border-r flex flex-col bg-gray-50">
<!-- Selector de conexión -->
<div class="p-3 border-b">
<label class="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Conexión DB</label>
<select x-model="selectedConxId" @change="onConxChange()"
class="w-full border rounded px-2 py-1.5 text-xs">
<option value="">— Selecciona —</option>
<template x-for="c in conexiones" :key="c.ID">
<option :value="c.ID"
x-text="(c.servidor?.nombre || c.servidor_id) + ' · ' + (c.tipo_db?.nombre || '') + ' :' + c.puerto">
</option>
</template>
</select>
<!-- Test conexión -->
<button @click="testConn()" x-show="selectedConxId" :disabled="testLoading"
class="mt-1.5 w-full text-xs py-1 rounded border border-gray-300 hover:bg-white transition flex items-center justify-center gap-1">
<span x-show="!testLoading">⚡ Probar conexión</span>
<span x-show="testLoading">Probando…</span>
</button>
<p x-show="testMsg" class="text-xs mt-1 font-medium"
:class="testOk ? 'text-green-600' : 'text-red-500'" x-text="testMsg"></p>
</div>
<!-- Selector de base de datos -->
<div class="p-3 border-b" x-show="selectedConxId">
<label class="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Base de datos</label>
<select x-model="selectedDb" @change="loadTables()"
class="w-full border rounded px-2 py-1.5 text-xs">
<option value="">— Selecciona DB —</option>
<template x-for="db in databases" :key="db">
<option :value="db" x-text="db"></option>
</template>
</select>
</div>
<!-- Á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' : 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)"
class="flex-1 text-left text-xs px-2 py-1 rounded-l hover:bg-[#e9f0cf] text-gray-700 truncate flex items-center gap-1">
<svg class="w-3 h-3 shrink-0 text-[#8eb02f]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/>
<line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/>
</svg>
<span x-text="t"></span>
</button>
<!-- Botón ver campos (solo MongoDB) -->
<button x-show="isMongo" @click.stop="loadCollectionFields(t)"
class="shrink-0 px-1.5 py-1 rounded-r hover:bg-[#d4e89c] text-gray-400 transition"
:class="selectedCollection === t ? 'bg-[#d4e89c] !text-[#5a7a1e] opacity-100' : 'opacity-0 group-hover:opacity-100'"
title="Ver campos de la colección">
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h7"/>
</svg>
</button>
<!-- Botón ver relaciones de esta tabla (motores SQL) -->
<button x-show="!isMongo && !isRedis" @click.stop="verRelaciones(t)"
class="shrink-0 px-1.5 py-1 rounded-r hover:bg-[#d4e89c] text-gray-400 opacity-0 group-hover:opacity-100 transition"
title="Ver relaciones de esta tabla">
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 010 5.656l-3 3a4 4 0 01-5.656-5.656l1.5-1.5M10.172 13.828a4 4 0 010-5.656l3-3a4 4 0 015.656 5.656l-1.5 1.5"/>
</svg>
</button>
</div>
</template>
</div>
<!-- Visor de campos de colección (MongoDB) -->
<div x-show="isMongo && selectedCollection" class="border-t shrink-0 bg-white flex flex-col" style="max-height:220px">
<div class="flex items-center justify-between px-2 py-1.5 bg-[#e9f0cf] border-b shrink-0">
<div class="flex items-center gap-1.5 min-w-0">
<svg class="w-3 h-3 shrink-0 text-[#5a7a1e]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<span class="text-[10px] font-semibold text-[#4a6e18] font-mono truncate" x-text="selectedCollection"></span>
</div>
<div class="flex items-center gap-1 shrink-0 ml-1">
<span x-show="loadingFields" class="text-[10px] text-gray-500"></span>
<span x-show="!loadingFields && collectionFields.length" class="text-[10px] text-gray-500" x-text="collectionFields.length + ' campos'"></span>
<button @click="selectedCollection=''; collectionFields=[]" class="text-gray-400 hover:text-gray-700 ml-1 text-xs leading-none" title="Cerrar"></button>
</div>
</div>
<div class="overflow-y-auto flex-1">
<div x-show="loadingFields && !collectionFields.length" class="px-3 py-2 text-xs text-gray-400">Cargando campos…</div>
<div x-show="!loadingFields && !collectionFields.length && selectedCollection" class="px-3 py-2 text-xs text-gray-400">Sin documentos en esta colección.</div>
<template x-for="f in collectionFields" :key="f.name">
<div class="flex items-center gap-1 px-2 py-1.5 hover:bg-[#f0f7d8] group border-b border-gray-50">
<div class="flex-1 min-w-0">
<div class="font-mono text-xs text-gray-800 truncate" x-text="f.name"></div>
<div class="font-mono text-[9px] text-gray-400 truncate" x-text="f.preview"></div>
</div>
<div class="shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition">
<span class="text-[9px] px-1 py-0.5 rounded bg-gray-100 text-gray-500" x-text="f.type"></span>
<button @click="buildUpdateField(f.name)"
class="text-[10px] px-1.5 py-0.5 rounded text-white"
style="background-color:#8eb02f" title="Generar updateOne para este campo"></button>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- Mensaje sin conexión -->
<div x-show="!selectedConxId" class="flex-1 flex items-center justify-center p-4">
<p class="text-xs text-gray-400 text-center">Selecciona una conexión para explorar bases de datos y tablas.</p>
</div>
</aside>
<!-- ══════════════════ ÁREA PRINCIPAL ══════════════════ -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Barra de herramientas -->
<div class="flex items-center gap-2 px-4 py-2.5 border-b bg-white">
<button @click="runQuery()"
:disabled="!selectedConxId || loadingQuery"
class="flex items-center gap-1.5 px-4 py-1.5 text-white text-xs font-semibold rounded-lg transition disabled:opacity-40"
style="background-color:#8eb02f"
onmouseover="this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
<span x-text="loadingQuery ? 'Ejecutando…' : 'Ejecutar'"></span>
<kbd class="ml-1 text-[10px] opacity-70">Ctrl+↵</kbd>
</button>
<button @click="clearEditor()" class="px-3 py-1.5 text-xs border rounded hover:bg-gray-50 transition">
Limpiar
</button>
<!-- Batch mode toggle -->
<button @click="batchMode = !batchMode; if(!batchMode) batchResults = []"
class="px-3 py-1.5 text-xs border rounded transition"
:class="batchMode ? 'bg-amber-500 text-white border-amber-500' : 'hover:bg-gray-50'">
<span x-text="batchMode ? '📚 Lote ON' : '📄 Lote OFF'"></span>
</button>
<!-- Subir .sql -->
<label x-show="!isMongo && !isRedis" class="px-3 py-1.5 text-xs border rounded hover:bg-gray-50 cursor-pointer transition">
📁 Subir .sql
<input type="file" accept=".sql" @change="uploadSQLFile($event)" class="hidden">
</label>
<!-- Ver relaciones -->
<button x-show="!isMongo && !isRedis && selectedDb" @click="verRelaciones()" :disabled="relacionesLoading"
class="px-3 py-1.5 text-xs border rounded hover:bg-gray-50 transition disabled:opacity-40">
<span x-text="relacionesLoading ? 'Cargando…' : '🔗 Ver relaciones'"></span>
</button>
<!-- Botón AI -->
<button @click="askAI()" :disabled="aiLoading || !sqlText.trim()"
class="flex items-center gap-1 px-3 py-1.5 text-xs rounded transition disabled:opacity-40"
:class="aiLoading ? 'bg-purple-100 text-purple-700 border border-purple-200' : 'bg-purple-50 text-purple-700 border border-purple-200 hover:bg-purple-100'">
<svg x-show="!aiLoading" class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 007.92 12.446A9 9 0 1112 2.992z"/>
<path d="M17 4a2 2 0 012 2M19 4a2 2 0 012 2M19 8a2 2 0 01-2 2M17 8a2 2 0 01-2-2"/>
</svg>
<svg x-show="aiLoading" class="w-3.5 h-3.5 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<span x-text="aiLoading ? 'Pensando…' : 'AI Sugerir'"></span>
</button>
<div class="flex-1"></div>
<!-- Exportar -->
<div class="flex items-center gap-1" x-show="results.length > 0">
<span class="text-xs text-gray-400" x-text="results.length + ' filas'"></span>
<button @click="exportData('csv')"
class="px-3 py-1.5 text-xs border border-green-300 text-green-700 rounded hover:bg-green-50 transition">
↓ CSV
</button>
<button @click="exportData('json')"
class="px-3 py-1.5 text-xs border border-blue-300 text-blue-700 rounded hover:bg-blue-50 transition">
↓ JSON
</button>
</div>
<!-- Tab historial -->
<button @click="activeTab = activeTab === 'history' ? 'results' : 'history'"
class="px-3 py-1.5 text-xs border rounded transition"
:class="activeTab === 'history' ? 'bg-[#8eb02f] text-white border-[#8eb02f]' : 'hover:bg-gray-50'">
Historial
</button>
</div>
<!-- Editor SQL / MongoDB -->
<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' : 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>
<div class="relative">
<textarea id="sql-editor" x-model="sqlText"
@input.debounce.150ms="onSqlInput()"
@keydown="onSqlKeydown($event)"
@keydown.ctrl.enter.prevent="runQuery()"
@keydown.meta.enter.prevent="runQuery()"
@scroll="closeAutocomplete()"
@click="closeAutocomplete()"
: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>
<!-- Autocomplete dropdown -->
<div x-show="autocompleteVisible" x-cloak
:style="'position:absolute;left:'+autocompletePos.left+'px;top:'+autocompletePos.top+'px;z-index:100;'"
class="bg-white border border-gray-300 rounded-lg shadow-xl max-h-48 overflow-y-auto min-w-[180px]">
<template x-for="(item, i) in autocompleteItems" :key="i">
<div @mousedown.prevent="selectAutocomplete(i)"
class="px-3 py-1.5 text-xs font-mono cursor-pointer flex items-center gap-2"
:class="i === autocompleteIdx ? 'bg-[#8eb02f] text-white' : 'hover:bg-gray-100 text-gray-700'">
<!-- Icono por tipo -->
<span class="text-[9px] shrink-0 w-12 truncate opacity-60 font-sans"
:class="{
'text-blue-500': item.type === 'keyword',
'text-amber-600': item.type === 'table',
'text-green-600': item.type.startsWith('col')
}"
x-text="item.type === 'keyword' ? 'KW' : item.type === 'table' ? 'tabla' : item.type.replace('col·','')">
</span>
<span class="flex-1 truncate" x-text="item.label"></span>
</div>
</template>
</div>
<!-- Typo suggestions -->
<div x-show="typoSuggestions.length > 0" x-cloak
class="absolute bottom-0 left-0 right-0 px-3 py-1.5 bg-yellow-900 bg-opacity-90 flex flex-wrap gap-1.5 items-center">
<span class="text-yellow-300 text-[10px] shrink-0">¿Quisiste decir?</span>
<template x-for="(s, i) in typoSuggestions" :key="i">
<button @mousedown.prevent="applyTypoFix(s)"
class="text-[10px] px-2 py-0.5 rounded bg-yellow-400 text-yellow-900 hover:bg-yellow-300 transition font-mono font-semibold">
<span x-text="s"></span>
</button>
</template>
</div>
</div>
</div>
<!-- ═══ BARRA NL2SQL (cool) ═══ -->
<div x-show="!isMongo && !isRedis && selectedConxId" class="mt-2 rounded-xl overflow-hidden"
style="background: linear-gradient(135deg, #1e1b4b 0%, #312e81 40%, #1e3a5f 100%); border: 1px solid #4c1d95">
<div class="flex items-center gap-2 px-3 py-2">
<!-- Icono mágico animado -->
<div class="shrink-0 flex items-center justify-center w-7 h-7 rounded-lg"
style="background: linear-gradient(135deg,#7c3aed,#2563eb)"
:class="nl2sqlLoading ? 'animate-pulse' : ''">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/>
<path stroke-linecap="round" stroke-linejoin="round"
d="M18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"/>
</svg>
</div>
<input id="nl2sql-input" x-model="nl2sqlText"
@keydown.enter="generateNL2SQL()"
:disabled="nl2sqlLoading"
type="text"
placeholder="Describe en español lo que quieres consultar... ej: trae los usuarios activos creados este mes"
class="flex-1 bg-transparent text-white text-xs placeholder-indigo-300 outline-none font-sans">
<button @click="generateNL2SQL()" :disabled="nl2sqlLoading || !nl2sqlText.trim()"
class="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition disabled:opacity-40"
style="background: linear-gradient(135deg,#7c3aed,#2563eb); color:white"
onmouseover="this.style.opacity='0.85'"
onmouseout="this.style.opacity='1'">
<svg x-show="!nl2sqlLoading" class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M12 5l7 7-7 7"/>
</svg>
<svg x-show="nl2sqlLoading" class="w-3.5 h-3.5 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<span x-text="nl2sqlLoading ? 'Generando…' : 'Generar SQL'"></span>
</button>
</div>
<!-- Respuesta inline -->
<div x-show="nl2sqlResult" x-cloak class="px-3 pb-2">
<div class="rounded-lg overflow-hidden border border-indigo-600">
<div class="flex items-center justify-between px-3 py-1.5 bg-indigo-900 bg-opacity-60">
<span class="text-[10px] text-indigo-200 font-semibold">SQL generado</span>
<div class="flex gap-1.5">
<button @click="applyNL2SQL()"
class="text-[10px] px-2 py-0.5 rounded font-semibold transition"
style="background:#8eb02f;color:white"
onmouseover="this.style.background='#6d8c24'"
onmouseout="this.style.background='#8eb02f'">
Aplicar al editor
</button>
<button @click="nl2sqlResult=''"
class="text-[10px] px-1.5 text-indigo-300 hover:text-white transition"></button>
</div>
</div>
<pre class="px-3 py-2 text-xs font-mono text-green-300 bg-gray-950 whitespace-pre-wrap max-h-24 overflow-y-auto" x-text="nl2sqlResult"></pre>
</div>
</div>
</div>
<!-- ─── 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">
<button @click="sqlText = h.cmd" class="text-[10px] px-2 py-0.5 rounded bg-gray-100 hover:bg-gray-200 text-gray-600 font-mono transition" x-text="h.label"></button>
</template>
</div>
<!-- ══ Generador DBA ══ -->
<div class="border rounded-lg overflow-hidden">
<button @click="showDbHelper = !showDbHelper"
class="w-full flex items-center gap-1.5 px-3 py-1.5 bg-gray-50 hover:bg-[#e9f0cf] text-xs text-gray-600 transition text-left select-none">
<svg class="w-3 h-3 transition-transform duration-150" :class="showDbHelper ? '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 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 && !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">
<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">Nombre BD</label>
<input x-model="dbHelper.db" list="dba-dbs"
class="w-full border rounded px-2 py-1 font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]"
placeholder="mi_base_datos">
<datalist id="dba-dbs">
<template x-for="d in databases" :key="d"><option :value="d"></option></template>
</datalist>
</div>
<div>
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">Operación</label>
<select x-model="dbHelper.op" class="w-full border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
<option value="full_setup">✦ Setup completo (BD + usuario + permisos)</option>
<option value="create_db">Crear base de datos</option>
<option value="create_user">Crear usuario</option>
<option value="grant">Dar permisos totales (GRANT ALL)</option>
<option value="revoke">Revocar permisos</option>
<option value="show_grants">Ver permisos de usuario</option>
<option value="drop_user">Eliminar usuario</option>
<option value="drop_db">Eliminar base de datos</option>
</select>
</div>
<div x-show="['full_setup','create_user','grant','revoke','show_grants','drop_user'].includes(dbHelper.op)">
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">Usuario</label>
<input x-model="dbHelper.user"
class="w-full border rounded px-2 py-1 font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]"
placeholder="usuario_app">
</div>
<div x-show="['full_setup','create_user','grant','revoke','show_grants','drop_user'].includes(dbHelper.op) && !isPostgres">
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">Host</label>
<input x-model="dbHelper.host"
class="w-full border rounded px-2 py-1 font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]"
placeholder="%">
</div>
<div x-show="['full_setup','create_user'].includes(dbHelper.op)" class="col-span-2">
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">Contraseña</label>
<input x-model="dbHelper.pass" type="text"
class="w-full border rounded px-2 py-1 font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]"
placeholder="Contraseña segura">
<p class="text-[9px] text-gray-400 mt-0.5">Se insertará tal cual en el SQL generado.</p>
</div>
</div>
<div class="flex items-center gap-2 mt-2.5 pt-2.5 border-t">
<button @click="generateDbHelper()"
class="flex-1 py-1.5 text-xs text-white font-semibold rounded transition"
style="background-color:#8eb02f"
onmouseover="this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
↗ Generar en editor
</button>
</div>
</div>
</div>
</div>
<!-- Ayuda rápida MongoDB -->
<div x-show="isMongo" class="mt-1.5">
<div class="flex flex-wrap gap-1 mb-1.5">
<template x-for="ex in mongoExamples" :key="ex.label">
<button @click="sqlText = ex.cmd" class="text-[10px] px-2 py-0.5 rounded bg-gray-100 hover:bg-gray-200 text-gray-600 font-mono transition" x-text="ex.label"></button>
</template>
</div>
<!-- ══ Generador de actualización rápida ══ -->
<div class="border rounded-lg overflow-hidden">
<button @click="showUpdateBuilder = !showUpdateBuilder"
class="w-full flex items-center gap-1.5 px-3 py-1.5 bg-gray-50 hover:bg-[#e9f0cf] text-xs text-gray-600 transition text-left select-none">
<svg class="w-3 h-3 transition-transform duration-150" :class="showUpdateBuilder ? '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 de actualización</span>
<span class="ml-auto text-[10px] text-gray-400">updateOne / updateMany</span>
</button>
<div x-show="showUpdateBuilder" 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">Colección</label>
<input x-model="updateBuilder.collection" list="ub-collections"
class="w-full border rounded px-2 py-1 font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]"
:placeholder="selectedCollection || 'coleccion'">
<datalist id="ub-collections">
<template x-for="t in tables" :key="t"><option :value="t"></option></template>
</datalist>
</div>
<div>
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">Operación</label>
<select x-model="updateBuilder.op" class="w-full border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
<option value="$set">$set — asignar valor</option>
<option value="$unset">$unset — eliminar campo</option>
<option value="$inc">$inc — incrementar número</option>
<option value="$push">$push — añadir a array</option>
<option value="$pull">$pull — quitar de array</option>
<option value="$rename">$rename — renombrar campo</option>
</select>
</div>
<div>
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">Campo a actualizar</label>
<input x-model="updateBuilder.field" list="ub-fields"
class="w-full border rounded px-2 py-1 font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" placeholder="nombre_campo">
<datalist id="ub-fields">
<template x-for="f in collectionFields" :key="f.name"><option :value="f.name"></option></template>
</datalist>
</div>
<div>
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">
<span x-text="updateBuilder.op === '$rename' ? 'Nuevo nombre' : 'Nuevo valor'"></span>
</label>
<input x-model="updateBuilder.value"
class="w-full border rounded px-2 py-1 font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]"
:placeholder="updateBuilder.op === '$inc' ? '1' : updateBuilder.op === '$rename' ? 'nuevo_nombre' : 'Admin2026!'"
x-show="updateBuilder.op !== '$unset'">
<p x-show="updateBuilder.op !== '$unset'" class="text-[9px] text-gray-400 mt-0.5 leading-tight">
Los strings se auto-encierran en comillas. Para JSON explícito usa <span class="font-mono">"valor"</span>, <span class="font-mono">123</span>, <span class="font-mono">true</span>, <span class="font-mono">{}</span>.
</p>
<span x-show="updateBuilder.op === '$unset'" class="text-[10px] text-gray-400 block py-1.5 italic">El campo será eliminado del documento.</span>
</div>
<div>
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">Filtro — campo</label>
<input x-model="updateBuilder.filterField" list="ub-fields"
class="w-full border rounded px-2 py-1 font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" placeholder="_id">
</div>
<div>
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">Filtro — valor</label>
<input x-model="updateBuilder.filterValue"
class="w-full border rounded px-2 py-1 font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" placeholder='"id_del_doc"'>
</div>
</div>
<div class="flex items-center gap-2 mt-2.5 pt-2.5 border-t">
<select x-model="updateBuilder.multi" class="border rounded px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
<option value="one">updateOne</option>
<option value="many">updateMany</option>
</select>
<button @click="generateUpdate()"
class="flex-1 py-1.5 text-xs text-white font-semibold rounded transition"
style="background-color:#8eb02f"
onmouseover="this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
↗ Generar en editor
</button>
</div>
</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 -->
<div class="flex-1 overflow-hidden flex flex-col px-4 pt-3 pb-4">
<!-- Status bar -->
<div x-show="statusMsg" class="mb-2 px-3 py-1.5 rounded text-xs font-medium"
:class="statusOk ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-600 border border-red-200'"
x-text="statusMsg"></div>
<!-- ── TAB: Resultados ── -->
<div x-show="activeTab === 'results'" class="flex-1 overflow-auto border rounded-lg">
<!-- Sin resultados -->
<div x-show="!batchMode && results.length === 0 && !loadingQuery" class="flex flex-col items-center justify-center h-40 text-gray-400 text-sm gap-2">
<svg class="w-10 h-10 opacity-30" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 5.625c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"/>
</svg>
<span>Sin resultados. Ejecuta una consulta.</span>
</div>
<!-- Batch results tabs -->
<div x-show="batchMode && batchResults.length > 0" class="flex flex-col h-full">
<div class="flex items-center gap-1 px-2 py-1.5 border-b bg-gray-50 sticky top-0 overflow-x-auto shrink-0">
<template x-for="(br, i) in batchResults" :key="i">
<button @click="batchActiveIdx = i"
class="px-2.5 py-1 text-[10px] rounded whitespace-nowrap font-mono transition flex items-center gap-1"
:class="batchActiveIdx === i
? (br.status === 'error' ? 'bg-red-100 text-red-700 border border-red-200' : 'bg-[#8eb02f] text-white')
: (br.status === 'error' ? 'bg-red-50 text-red-500 border border-red-100' : 'bg-white text-gray-600 border border-gray-200 hover:bg-gray-50')">
<span x-text="'#' + (i+1)"></span>
<span class="truncate max-w-[100px]" x-text="br.sql.substring(0, 30)"></span>
<span x-show="br.status === 'ok'" class="text-green-500"></span>
<span x-show="br.status === 'error'" class="text-red-500"></span>
</button>
</template>
</div>
<!-- Resultado activo del batch -->
<template x-for="(br, i) in batchResults" :key="i">
<div x-show="batchActiveIdx === i" class="flex-1 overflow-auto">
<div x-show="br.status === 'error'" class="p-4 text-xs text-red-600 bg-red-50 font-mono" x-text="br.error"></div>
<div x-show="br.status === 'ok' && (!br.columns || br.columns.length === 0)" class="p-4 text-xs text-green-700 bg-green-50">
✅ Consulta ejecutada — <span x-text="br.rows + ' filas afectadas'"></span> · <span x-text="br.duration"></span>
</div>
<table x-show="br.status === 'ok' && br.columns && br.columns.length > 0" class="table-auto w-full text-xs">
<thead class="sticky top-0 bg-gray-50 z-10">
<tr>
<th class="py-2 px-3 border-b text-left font-semibold text-gray-500 w-10">#</th>
<template x-for="col in br.columns" :key="col">
<th class="py-2 px-3 border-b text-left font-semibold text-gray-600 whitespace-nowrap" x-text="col"></th>
</template>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template x-for="(row, idx) in br.data" :key="idx">
<tr class="hover:bg-gray-50">
<td class="py-2 px-3 text-gray-400 select-none" x-text="idx+1"></td>
<template x-for="col in br.columns" :key="col">
<td class="py-2 px-3 font-mono max-w-xs truncate" :title="nullStr(row[col])" x-text="nullStr(row[col])"></td>
</template>
</tr>
</template>
</tbody>
</table>
<div x-show="br.status === 'ok'" class="px-3 py-1.5 text-[10px] text-gray-400 border-t bg-gray-50">
<span x-text="(br.data || []).length + ' filas'"></span> ·
<span x-text="br.duration"></span>
</div>
</div>
</template>
</div>
<!-- Single results table (modo normal) -->
<table x-show="!batchMode && results.length > 0" class="table-auto w-full text-xs">
<thead class="sticky top-0 bg-gray-50 z-10">
<tr>
<th class="py-2 px-3 border-b text-left font-semibold text-gray-500 w-10">#</th>
<template x-for="col in columns" :key="col">
<th class="py-2 px-3 border-b text-left font-semibold text-gray-600 whitespace-nowrap" x-text="col"></th>
</template>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template x-for="(row, idx) in results" :key="idx">
<tr class="hover:bg-gray-50">
<td class="py-2 px-3 text-gray-400 select-none" x-text="idx+1"></td>
<template x-for="col in columns" :key="col">
<td class="py-2 px-3 font-mono max-w-xs truncate relative"
:title="nullStr(row[col])"
@dblclick="startEdit(idx, col, row, $event)">
<div x-show="!(editCell && editCell.rowIdx === idx && editCell.col === col)" x-text="nullStr(row[col])"></div>
<div x-show="editCell && editCell.rowIdx === idx && editCell.col === col" class="flex items-center gap-1">
<input x-model="editValue" type="text"
@keydown.enter.prevent="saveEdit(idx, col)"
@keydown.escape.prevent="cancelEdit()"
@click.stop
class="w-full border border-[#8eb02f] rounded px-1.5 py-0.5 text-xs font-mono outline-none bg-white"
:class="editSaving ? 'opacity-50' : ''"
:disabled="editSaving"
x-ref="editInput">
<button @click="saveEdit(idx, col)" :disabled="editSaving"
class="shrink-0 px-1 py-0.5 rounded bg-green-500 text-white text-[10px] hover:bg-green-600"></button>
<button @click="cancelEdit()"
class="shrink-0 px-1 py-0.5 rounded bg-gray-300 text-gray-700 text-[10px] hover:bg-gray-400"></button>
</div>
</td>
</template>
</tr>
</template>
</tbody>
</table>
</div>
<!-- ── TAB: Historial ── -->
<div x-show="activeTab === 'history'" class="flex-1 overflow-auto border rounded-lg">
<div class="flex items-center justify-between px-3 py-2 border-b bg-gray-50 sticky top-0">
<span class="text-xs font-semibold text-gray-500">Historial de consultas</span>
<button @click="clearHistory()" x-show="history.length > 0"
class="text-xs text-red-500 hover:underline">Borrar historial</button>
</div>
<div x-show="history.length === 0" class="py-10 text-center text-xs text-gray-400">Sin historial para esta conexión.</div>
<template x-for="(h, i) in history" :key="h.ID">
<div class="px-3 py-2.5 border-b hover:bg-gray-50 group cursor-pointer"
@click="sqlText = h.sql">
<div class="flex items-start justify-between gap-2">
<pre class="text-xs font-mono text-gray-700 whitespace-pre-wrap break-all flex-1 max-h-12 overflow-hidden" x-text="h.sql"></pre>
<div class="shrink-0 flex items-center gap-2">
<span class="px-1.5 py-0.5 rounded text-[10px] font-medium"
:class="h.status === 'ok' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'"
x-text="h.status"></span>
<span class="text-[10px] text-gray-400" x-text="h.duration_ms + 'ms'"></span>
</div>
</div>
<p x-show="h.error_msg" class="text-[10px] text-red-400 mt-0.5 truncate" x-text="h.error_msg"></p>
<p class="text-[10px] text-gray-400 mt-0.5" x-text="formatDate(h.executed_at)"></p>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- Modal AI Sugerencia -->
<div x-show="aiModalOpen" x-cloak x-transition class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div @click.outside="aiModalOpen = false" class="bg-white rounded-xl shadow-2xl w-full max-w-2xl mx-4 overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b bg-gray-50">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 007.92 12.446A9 9 0 1112 2.992z"/>
</svg>
<span class="text-sm font-semibold text-gray-700">Sugerencia IA</span>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-purple-100 text-purple-600 font-medium">AI</span>
</div>
<button @click="aiModalOpen = false" class="text-gray-400 hover:text-gray-600 text-lg leading-none">&times;</button>
</div>
<div class="p-4">
<div class="mb-3">
<label class="text-[10px] text-gray-500 font-semibold uppercase tracking-wide block mb-1">Acción</label>
<div class="flex gap-1.5">
<template x-for="act in aiActions" :key="act.value">
<button @click="aiAction = act.value"
class="px-3 py-1.5 text-xs rounded border transition"
:class="aiAction === act.value ? 'bg-purple-600 text-white border-purple-600' : 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50'"
x-text="act.label"></button>
</template>
</div>
</div>
<div class="border rounded-lg bg-gray-900 p-3 mb-3 max-h-40 overflow-y-auto">
<pre class="text-xs font-mono text-gray-100 whitespace-pre-wrap" x-text="aiModalSql"></pre>
</div>
<button @click="askAI()" :disabled="aiLoading || !sqlText.trim()"
class="w-full py-2 text-xs font-semibold text-white rounded-lg transition disabled:opacity-40 flex items-center justify-center gap-2"
:class="aiLoading ? 'bg-purple-400' : 'bg-purple-600 hover:bg-purple-700'">
<svg x-show="aiLoading" class="w-3.5 h-3.5 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<span x-text="aiLoading ? 'Procesando…' : 'Obtener sugerencia'"></span>
</button>
<div x-show="aiSuggestion" class="mt-3 border rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-3 py-2 bg-gray-50 border-b">
<span class="text-xs font-semibold text-gray-500">Resultado</span>
<button @click="applyAISuggestion()"
class="text-[10px] px-2 py-1 rounded bg-[#8eb02f] text-white hover:bg-[#6d8c24] transition font-semibold">
Aplicar al editor
</button>
</div>
<pre class="p-3 text-xs font-mono text-gray-800 whitespace-pre-wrap bg-white max-h-48 overflow-y-auto" x-text="aiSuggestion"></pre>
</div>
</div>
</div>
</div>
<!-- Modal: diagrama de relaciones -->
<div x-show="relacionesModalOpen" x-cloak x-transition class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
<div @click.outside="relacionesModalOpen = false" class="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b bg-gray-50 shrink-0">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-[#5a7a1e]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 010 5.656l-3 3a4 4 0 01-5.656-5.656l1.5-1.5M10.172 13.828a4 4 0 010-5.656l3-3a4 4 0 015.656 5.656l-1.5 1.5"/>
</svg>
<span class="text-sm font-semibold text-gray-700">Relaciones</span>
<span x-show="relacionesFiltro" class="text-[10px] px-1.5 py-0.5 rounded bg-[#e9f0cf] text-[#5a7a1e] font-mono" x-text="relacionesFiltro"></span>
<button x-show="relacionesFiltro" @click="relacionesFiltro = ''; renderRelacionesDiagrama()"
class="text-[10px] text-gray-400 hover:text-gray-600 underline">ver todas</button>
</div>
<button @click="relacionesModalOpen = false" class="text-gray-400 hover:text-gray-600 text-lg leading-none">&times;</button>
</div>
<div class="flex-1 overflow-auto p-4">
<template x-if="relacionesData.length === 0 && !relacionesLoading">
<p class="text-sm text-gray-400 text-center py-10">No se encontraron llaves foráneas en esta base de datos.</p>
</template>
<template x-if="relacionesError">
<p class="text-sm text-red-500 text-center py-10" x-text="relacionesError"></p>
</template>
<div id="relaciones-diagrama" class="flex justify-center"></div>
</div>
</div>
</div>
<!-- Toast -->
<div x-show="toast.show" x-cloak x-transition
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-sm text-white"
:class="toast.type === 'error' ? 'bg-red-500' : 'bg-[#8eb02f]'"
x-text="toast.msg"></div>
</div>
<!-- Mermaid servido localmente (no CDN) — usado para el diagrama de relaciones -->
<script src="/js/mermaid.min.js"></script>
<script>
if (window.mermaid) {
mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'strict' });
}
</script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('queryRunner', () => ({
loading: false,
loadingQuery: false,
testLoading: false,
selectedConxId: '',
selectedDb: '',
conexiones: [],
databases: [],
tables: [],
sqlText: '',
columns: [],
results: [],
history: [],
activeTab: 'results',
batchMode: false,
batchResults: [],
batchActiveIdx: 0,
statusMsg: '',
statusOk: true,
testMsg: '',
testOk: true,
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' },
// ── Relaciones (diagrama FK) ──
relacionesModalOpen: false,
relacionesLoading: false,
relacionesData: [],
relacionesFiltro: '',
relacionesError: '',
relacionesSeq: 0,
// ── Autocomplete ──
autocompleteVisible: false,
autocompleteItems: [],
autocompleteIdx: 0,
autocompletePos: { left: 0, top: 0 },
columnCache: {}, // table name -> columns array
sqlKeywords: [
'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'IN', 'NOT', 'NULL', 'AS', 'ON',
'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'CROSS', 'FULL',
'GROUP', 'BY', 'ORDER', 'ASC', 'DESC', 'HAVING',
'LIMIT', 'OFFSET', 'INSERT', 'INTO', 'VALUES',
'UPDATE', 'SET', 'DELETE', 'CREATE', 'TABLE', 'ALTER', 'DROP', 'INDEX',
'PRIMARY', 'KEY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'UNIQUE',
'CHECK', 'CASCADE', 'TRUNCATE', 'DISTINCT', 'COUNT', 'SUM', 'AVG', 'MIN', 'MAX',
'BETWEEN', 'LIKE', 'ILIKE', 'EXISTS', 'UNION', 'ALL', 'INTERSECT', 'EXCEPT',
'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'CAST', 'COALESCE', 'NULLIF',
'TRUE', 'FALSE', 'GRANT', 'REVOKE', 'COMMIT', 'ROLLBACK', 'BEGIN', 'TRANSACTION',
'IS', 'ANY', 'SOME', 'DATABASE', 'SCHEMA', 'USER', 'GRANT', 'REVOKE',
'PRIVILEGES', 'IDENTIFIED', 'PASSWORD', 'FLUSH',
'SHOW', 'DESCRIBE', 'EXPLAIN', 'WITH',
'IF', 'EXISTS', 'NOT', 'FULL', 'OUTER', 'CROSS', 'NATURAL', 'USING',
],
// ── Editable cells ──
editCell: null,
editValue: '',
editSaving: false,
// ── NL2SQL ──
nl2sqlText: '',
nl2sqlLoading: false,
nl2sqlResult: '',
// ── Typo detection ──
typoSuggestions: [],
aiLoading: false,
aiModalOpen: false,
aiModalSql: '',
aiSuggestion: '',
aiAction: 'correct',
aiActions: [
{ value: 'correct', label: 'Corregir errores' },
{ value: 'complete', label: 'Completar consulta' },
{ value: 'optimize', label: 'Optimizar SQL' },
],
// 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' },
get sqlHelpers() {
if (this.isPostgres) return [
{ label: 'SHOW DATABASES', cmd: 'SELECT datname FROM pg_database ORDER BY datname;' },
{ label: 'SHOW TABLES', cmd: "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name;" },
{ label: 'SHOW USERS', cmd: "SELECT usename, usesuper, usecreatedb, usecreaterole FROM pg_catalog.pg_user ORDER BY usename;" },
{ label: 'SHOW GRANTS', cmd: "SELECT grantee, table_name, privilege_type FROM information_schema.role_table_grants WHERE table_schema = 'public' ORDER BY grantee, table_name;" },
{ label: 'SHOW CONNECTIONS', cmd: "SELECT pid, usename, application_name, state, query_start, LEFT(query,80) AS query FROM pg_stat_activity WHERE state IS NOT NULL;" },
{ label: 'DESC tabla', cmd: "SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = 'nombre_tabla' ORDER BY ordinal_position;" },
{ label: 'SHOW INDEXES', cmd: "SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'nombre_tabla';" },
{ label: 'SIZE db', cmd: "SELECT pg_size_pretty(pg_database_size(current_database())) AS size;" },
];
return [
{ label: 'SHOW DATABASES', cmd: 'SHOW DATABASES;' },
{ label: 'SHOW TABLES', cmd: 'SHOW TABLES;' },
{ label: 'SHOW USERS', cmd: "SELECT User, Host, plugin FROM mysql.user ORDER BY User;" },
{ label: 'SHOW GRANTS', cmd: 'SHOW GRANTS FOR CURRENT_USER;' },
{ label: 'SHOW PROCESSLIST', cmd: 'SHOW FULL PROCESSLIST;' },
{ label: 'SHOW CREATE TABLE', cmd: 'SHOW CREATE TABLE nombre_tabla;' },
{ label: 'DESC tabla', cmd: 'DESCRIBE nombre_tabla;' },
{ label: 'ENGINE/CHARSET', cmd: "SELECT table_name, engine, table_collation FROM information_schema.tables WHERE table_schema = DATABASE();" },
];
},
mongoExamples: [
{ label: 'find()', cmd: 'db.coleccion.find({})' },
{ label: 'findOne()', cmd: 'db.coleccion.findOne({})' },
{ label: 'count()', cmd: 'db.coleccion.countDocuments({})' },
{ label: 'insertOne()', cmd: 'db.coleccion.insertOne({"campo": "valor"})' },
{ label: 'updateOne()', cmd: 'db.coleccion.updateOne(\n {"_id": ""},\n {"$set": {"campo": "valor"}}\n)' },
{ label: 'updateMany()', cmd: 'db.coleccion.updateMany(\n {},\n {"$set": {"campo": "valor"}}\n)' },
{ label: '$unset', cmd: 'db.coleccion.updateOne(\n {"_id": ""},\n {"$unset": {"campo": ""}}\n)' },
{ label: '$inc', cmd: 'db.coleccion.updateOne(\n {"_id": ""},\n {"$inc": {"numero": 1}}\n)' },
{ label: '$push', cmd: 'db.coleccion.updateOne(\n {"_id": ""},\n {"$push": {"array": "nuevo_elemento"}}\n)' },
{ label: '$pull', cmd: 'db.coleccion.updateOne(\n {"_id": ""},\n {"$pull": {"array": "elemento"}}\n)' },
{ label: 'deleteOne()', cmd: 'db.coleccion.deleteOne({"_id": ""})' },
{ label: 'aggregate()', cmd: 'db.coleccion.aggregate([{"$group": {"_id": "$campo", "total": {"$sum": 1}}}])' },
],
// ══════════════════════════════════════════════════════════════
// INIT
// ══════════════════════════════════════════════════════════════
async init() {
const params = new URLSearchParams(window.location.search);
const preId = params.get('conx_db_id');
const res = await axios.get('/app/query-runner/connections');
this.conexiones = res.data.data || [];
if (preId) {
this.selectedConxId = parseInt(preId);
await this.onConxChange();
}
},
// ══════════════════════════════════════════════════════════════
// AUTOCOMPLETE — con contexto WHERE/SET/ON/HAVING
// ══════════════════════════════════════════════════════════════
async onSqlInput() {
if (!this.sqlText || this.isMongo || this.isRedis) {
this.closeAutocomplete();
this.typoSuggestions = [];
return;
}
const ta = document.getElementById('sql-editor');
if (!ta) return;
const cursorPos = ta.selectionStart;
const textBefore = this.sqlText.substring(0, cursorPos);
const wordMatch = textBefore.match(/[\w.]+$/);
const currentWord = wordMatch ? wordMatch[0] : '';
// Prefetch columnas de TODAS las tablas de la query en paralelo
this.prefetchTableColumns(this.sqlText);
if (currentWord.length < 1) {
this.closeAutocomplete();
this.typoSuggestions = [];
return;
}
const upper = currentWord.toUpperCase();
// ── Detectar contexto del cursor ──
// Contexto columna: cursor después de WHERE, AND, OR, ON, SET, HAVING, SELECT campo
const contextUpper = textBefore.replace(/\s+/g, ' ').toUpperCase();
const columnContextRe = /(?:WHERE|AND|OR|HAVING|ON|SET|,)\s+[\w.]*$/;
const selectContextRe = /SELECT\s+(?:[\w.,\s]+,\s*)?[\w.]*$/;
const isColumnContext = columnContextRe.test(contextUpper) || selectContextRe.test(contextUpper);
const suggestions = [];
if (isColumnContext) {
// Prioridad 1: columnas de TODAS las tablas en el query
const allTables = this.extractAllTablesFromQuery(this.sqlText);
for (const tableName of allTables) {
const cols = this.columnCache[tableName] || [];
for (const col of cols) {
const colLower = col.name.toLowerCase();
if (colLower.startsWith(currentWord.toLowerCase()) && col.name !== currentWord) {
suggestions.push({
label: col.name,
type: 'col·' + (col.type || '').substring(0, 10),
priority: 0
});
}
}
}
// Prioridad 2: también keywords que aplican en este contexto
const whereKws = ['IS', 'NOT', 'NULL', 'IN', 'LIKE', 'ILIKE', 'BETWEEN', 'AND', 'OR', 'EXISTS', 'TRUE', 'FALSE'];
for (const kw of whereKws) {
if (kw.startsWith(upper) && kw !== upper) {
suggestions.push({ label: kw, type: 'keyword', priority: 1 });
}
}
} else {
// Contexto general: keywords primero
for (const kw of this.sqlKeywords) {
if (kw.startsWith(upper) && kw !== upper) {
suggestions.push({ label: kw, type: 'keyword', priority: 0 });
}
}
// Luego tablas (contexto FROM / JOIN / UPDATE / INTO)
const tableContextRe = /(?:FROM|JOIN|UPDATE|INTO|TABLE)\s+[\w.]*$/i;
if (tableContextRe.test(textBefore)) {
for (const t of this.tables) {
if (t.toLowerCase().startsWith(currentWord.toLowerCase()) && t !== currentWord) {
suggestions.push({ label: t, type: 'table', priority: 0 });
}
}
} else {
// Fuera de contexto claro: tablas de menor prioridad
for (const t of this.tables) {
if (t.toLowerCase().startsWith(currentWord.toLowerCase()) && t !== currentWord) {
suggestions.push({ label: t, type: 'table', priority: 1 });
}
}
}
// Columnas de tabla detectada en contexto
const tableCtx = this.detectTableContext(this.sqlText, cursorPos);
if (tableCtx && this.columnCache[tableCtx]) {
for (const col of this.columnCache[tableCtx]) {
if (col.name.toLowerCase().startsWith(currentWord.toLowerCase()) && col.name !== currentWord) {
suggestions.push({ label: col.name, type: 'col·' + (col.type || '').substring(0, 10), priority: 1 });
}
}
}
}
// Deduplicar y limitar
const seen = new Set();
const unique = suggestions.filter(s => {
if (seen.has(s.label)) return false;
seen.add(s.label);
return true;
}).slice(0, 14);
if (unique.length > 0) {
this.autocompleteItems = unique;
this.autocompleteIdx = 0;
this.autocompleteVisible = true;
// Calcular posición debajo del cursor
const lineHeight = 24;
const charWidth = 8.4;
const lines = textBefore.split('\n');
const lineNum = lines.length;
const colNum = lines[lines.length - 1].length;
this.autocompletePos = {
left: Math.min(colNum * charWidth, 560),
top: lineNum * lineHeight + 4
};
} else {
this.closeAutocomplete();
}
// Typo detection
this.detectTypos(currentWord);
},
// Extrae TODAS las tablas mencionadas en FROM, JOIN, UPDATE, INTO del query
extractAllTablesFromQuery(sql) {
const found = [];
const re = /(?:FROM|JOIN|UPDATE|INTO|TABLE)\s+`?(\w[\w\d_]*)`?/gi;
let m;
while ((m = re.exec(sql)) !== null) {
const t = m[1];
if (!found.includes(t) && this.tables.includes(t)) found.push(t);
}
return found;
},
onSqlKeydown(e) {
if (!this.autocompleteVisible) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
this.autocompleteIdx = Math.min(this.autocompleteIdx + 1, this.autocompleteItems.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.autocompleteIdx = Math.max(this.autocompleteIdx - 1, 0);
} else if (e.key === 'Enter' || e.key === 'Tab') {
if (this.autocompleteItems[this.autocompleteIdx]) {
e.preventDefault();
this.selectAutocomplete(this.autocompleteIdx);
}
} else if (e.key === 'Escape') {
this.closeAutocomplete();
}
},
selectAutocomplete(idx) {
const item = this.autocompleteItems[idx];
if (!item) return;
const ta = document.getElementById('sql-editor');
if (!ta) return;
const cursorPos = ta.selectionStart;
const text = this.sqlText.substring(0, cursorPos);
const words = text.split(/[\s,;()\n]+/);
const currentWord = words[words.length - 1] || '';
const before = this.sqlText.substring(0, cursorPos - currentWord.length);
const after = this.sqlText.substring(cursorPos);
this.sqlText = before + item.label.toLowerCase() + after;
this.closeAutocomplete();
// Move cursor after inserted word
const newPos = before.length + item.label.length;
requestAnimationFrame(() => {
ta.focus();
ta.setSelectionRange(newPos, newPos);
});
},
closeAutocomplete() {
this.autocompleteVisible = false;
this.autocompleteItems = [];
this.autocompleteIdx = -1;
},
// Detecta qué tabla se está consultando basado en el contexto del cursor
detectTableContext(sql, cursorPos) {
const textBefore = sql.substring(0, cursorPos);
const upper = textBefore.toUpperCase();
// Buscar patrones: FROM tabla, JOIN tabla, UPDATE tabla, INSERT INTO tabla
const fromMatch = upper.match(/(?:FROM|JOIN|UPDATE|INTO)\s+([A-Z_][A-Z0-9_]*)\s*$/);
if (fromMatch) {
const table = fromMatch[1].toLowerCase();
// Verificar que existe en tables
for (const t of this.tables) {
if (t.toLowerCase() === table) return t;
}
}
// También buscar cualquier tabla mencionada antes del cursor
for (const t of this.tables) {
const idx = textBefore.toLowerCase().lastIndexOf(t.toLowerCase());
if (idx >= 0 && idx < cursorPos) return t;
}
return null;
},
// Precarga columnas de tablas mencionadas en la query
async prefetchTableColumns(sql) {
if (!this.selectedConxId || !this.selectedDb) return;
const tableRegex = /(?:FROM|JOIN|UPDATE|INTO|TABLE)\s+`?(\w[\w\d_]*)`?/gi;
let match;
const tablesToFetch = new Set();
while ((match = tableRegex.exec(sql)) !== null) {
tablesToFetch.add(match[1]);
}
for (const table of tablesToFetch) {
if (!this.columnCache[table] && this.tables.includes(table)) {
try {
const res = await axios.get('/app/query-runner/columns', {
params: { conx_db_id: this.selectedConxId, db: this.selectedDb, table }
});
this.columnCache[table] = res.data.data || [];
} catch (_) {}
}
}
},
// ══════════════════════════════════════════════════════════════
// TYPO DETECTION
// ══════════════════════════════════════════════════════════════
detectTypos(word) {
if (!word || word.length < 2) {
this.typoSuggestions = [];
return;
}
const upper = word.toUpperCase();
const suggestions = [];
// Levenshtein distance check against keywords
for (const kw of this.sqlKeywords) {
if (kw === upper) continue;
const dist = this.levenshtein(upper, kw);
if (dist === 1 || (dist === 2 && kw.length > 4)) {
suggestions.push(kw);
}
if (suggestions.length >= 3) break;
}
// Check against table names
for (const t of this.tables) {
const tUpper = t.toUpperCase();
if (tUpper === upper) continue;
const dist = this.levenshtein(upper, tUpper);
if (dist <= 2) {
suggestions.push(t);
}
if (suggestions.length >= 5) break;
}
this.typoSuggestions = suggestions;
},
levenshtein(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
}
}
return dp[m][n];
},
applyTypoFix(correctWord) {
const ta = document.getElementById('sql-editor');
if (!ta) return;
const cursorPos = ta.selectionStart;
const text = this.sqlText.substring(0, cursorPos);
const words = text.split(/[\s,;()\n]+/);
const currentWord = words[words.length - 1] || '';
const before = this.sqlText.substring(0, cursorPos - currentWord.length);
const after = this.sqlText.substring(cursorPos);
this.sqlText = before + correctWord.toLowerCase() + after;
this.typoSuggestions = [];
const newPos = before.length + correctWord.length;
requestAnimationFrame(() => {
ta.focus();
ta.setSelectionRange(newPos, newPos);
});
},
// ══════════════════════════════════════════════════════════════
// EDITABLE CELLS
// ══════════════════════════════════════════════════════════════
startEdit(rowIdx, col, row, event) {
if (this.isMongo || this.isRedis) return;
// Extract table name from the query
const table = this.extractTableFromQuery();
if (!table) {
this.showToast('No se pudo detectar la tabla para editar. La consulta debe incluir FROM.', 'error');
return;
}
// Find primary key column (try 'id', 'ID', or first column)
const pkCol = this.detectPKColumn();
if (!pkCol || row[pkCol] === undefined || row[pkCol] === null) {
this.showToast('No se pudo detectar la llave primaria para editar.', 'error');
return;
}
this.editCell = { rowIdx, col, table, pkCol, pkValue: String(row[pkCol]) };
this.editValue = row[col] === null || row[col] === undefined ? '' : String(row[col]);
requestAnimationFrame(() => {
const input = this.$refs?.editInput;
if (input) input.focus();
else {
// Fallback: find the input element
const inputs = document.querySelectorAll('[x-ref="editInput"]');
if (inputs.length > 0) inputs[inputs.length - 1].focus();
}
});
},
async saveEdit(rowIdx, col) {
if (!this.editCell || this.editSaving) return;
this.editSaving = true;
try {
const res = await axios.post('/app/query-runner/update-cell', {
conx_db_id: parseInt(this.selectedConxId),
database: this.selectedDb,
table: this.editCell.table,
column: col,
pk_column: this.editCell.pkCol,
pk_value: this.editCell.pkValue,
value: this.editValue
});
if (res.data.error) {
this.showToast('Error: ' + res.data.error, 'error');
} else {
this.showToast('✓ Celda actualizada (' + res.data.affected + ' fila(s))');
// Update local result
if (this.results[rowIdx]) {
this.results[rowIdx][col] = this.editValue;
}
}
} catch (e) {
this.showToast('Error al actualizar: ' + (e.response?.data?.error || e.message), 'error');
}
this.editSaving = false;
this.editCell = null;
this.editValue = '';
},
cancelEdit() {
this.editCell = null;
this.editValue = '';
},
extractTableFromQuery() {
const upper = this.sqlText.toUpperCase();
// FROM tabla, JOIN tabla, UPDATE tabla
const match = upper.match(/(?:FROM|JOIN|UPDATE|INTO)\s+`?(\w[\w\d_]*)`?/);
if (match) {
const tableName = match[1].toLowerCase();
// Check against known tables
for (const t of this.tables) {
if (t.toLowerCase() === tableName) return t;
}
return match[1];
}
return null;
},
detectPKColumn() {
// Common primary key names
const pkCandidates = ['id', 'ID', 'Id', '_id', 'codigo', 'code', 'uuid', 'ID_'];
for (const col of this.columns) {
for (const pk of pkCandidates) {
if (col === pk) return col;
}
}
// Fallback: first column
return this.columns[0] || null;
},
// ══════════════════════════════════════════════════════════════
// AI SUGGESTION
// ══════════════════════════════════════════════════════════════
openAI() {
this.aiModalOpen = true;
this.aiModalSql = this.sqlText;
this.aiSuggestion = '';
},
async askAI() {
if (!this.sqlText.trim()) return;
if (!this.aiModalOpen) {
this.aiModalOpen = true;
this.aiModalSql = this.sqlText;
this.aiSuggestion = '';
return;
}
this.aiLoading = true;
this.aiSuggestion = '';
try {
const res = await axios.post('/app/query-runner/suggest', {
conx_db_id: parseInt(this.selectedConxId) || 0,
database: this.selectedDb,
sql: this.aiModalSql || this.sqlText,
action: this.aiAction
});
if (res.data.suggestion) {
this.aiSuggestion = res.data.suggestion;
} else {
this.showToast('No se pudo generar sugerencia', 'error');
}
} catch (e) {
this.showToast('Error IA: ' + (e.response?.data?.error || e.message), 'error');
}
this.aiLoading = false;
},
applyAISuggestion() {
if (this.aiSuggestion) {
this.sqlText = this.aiSuggestion;
this.aiModalOpen = false;
this.showToast('✓ Sugerencia aplicada al editor');
}
},
// ══════════════════════════════════════════════════════════════
// NL2SQL — Lenguaje natural a SQL
// ══════════════════════════════════════════════════════════════
async generateNL2SQL() {
if (!this.nl2sqlText.trim() || this.nl2sqlLoading) return;
this.nl2sqlLoading = true;
this.nl2sqlResult = '';
// Construir schema de tablas disponibles para dar contexto a la IA
const schemaPayload = [];
for (const tableName of this.tables) {
const cols = this.columnCache[tableName] || [];
schemaPayload.push({
table: tableName,
columns: cols.map(c => ({ name: c.name, type: c.type || '' }))
});
// Si no tenemos columnas cargadas aún, intentar cargarlas
if (cols.length === 0 && this.selectedConxId && this.selectedDb) {
try {
const res = await axios.get('/app/query-runner/columns', {
params: { conx_db_id: this.selectedConxId, db: this.selectedDb, table: tableName }
});
const loaded = res.data.data || [];
this.columnCache[tableName] = loaded;
schemaPayload[schemaPayload.length - 1].columns = loaded.map(c => ({ name: c.name, type: c.type || '' }));
} catch (_) {}
}
}
try {
const res = await axios.post('/app/query-runner/nl2sql', {
conx_db_id: parseInt(this.selectedConxId) || 0,
database: this.selectedDb,
text: this.nl2sqlText,
schema: schemaPayload
});
if (res.data.suggestion) {
this.nl2sqlResult = res.data.suggestion;
} else if (res.data.error) {
this.showToast(res.data.error, 'error');
}
} catch (e) {
const msg = e.response?.data?.error || e.message;
this.showToast('Error NL2SQL: ' + msg, 'error');
}
this.nl2sqlLoading = false;
},
applyNL2SQL() {
if (this.nl2sqlResult) {
this.sqlText = this.nl2sqlResult;
this.nl2sqlResult = '';
this.nl2sqlText = '';
document.getElementById('sql-editor')?.focus();
this.showToast('SQL aplicado al editor');
}
},
// ══════════════════════════════════════════════════════════════
// CORE QUERY RUNNER
// ══════════════════════════════════════════════════════════════
async onConxChange() {
this.databases = [];
this.tables = [];
this.selectedDb = '';
this.results = [];
this.columns = [];
this.statusMsg = '';
this.history = [];
this.isMongo = false;
this.isRedis = false;
this.selectedCollection = '';
this.collectionFields = [];
this.columnCache = {};
if (!this.selectedConxId) return;
const conx = this.conexiones.find(c => c.ID == this.selectedConxId);
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 || [];
} catch (e) {
this.showToast(e.response?.data?.error || 'Error al cargar DBs', 'error');
}
await this.loadHistory();
},
async loadTables() {
this.tables = [];
this.selectedCollection = '';
this.collectionFields = [];
if (!this.selectedDb) return;
try {
const res = await axios.get('/app/query-runner/tables?conx_db_id=' + this.selectedConxId + '&db=' + encodeURIComponent(this.selectedDb));
this.tables = res.data.data || [];
} catch (e) {
this.showToast(e.response?.data?.error || 'Error al cargar tablas', 'error');
}
},
async verRelaciones(tabla) {
this.relacionesFiltro = tabla || '';
this.relacionesModalOpen = true;
this.relacionesError = '';
if (this.relacionesData.length === 0 || this._relacionesDbCargada !== this.selectedDb) {
this.relacionesLoading = true;
try {
const res = await axios.get('/app/query-runner/relations?conx_db_id=' + this.selectedConxId + '&db=' + encodeURIComponent(this.selectedDb));
this.relacionesData = res.data.data || [];
this._relacionesDbCargada = this.selectedDb;
} catch (e) {
this.relacionesError = e.response?.data?.error || 'Error al cargar las relaciones';
this.relacionesLoading = false;
return;
}
this.relacionesLoading = false;
}
await this.renderRelacionesDiagrama();
},
async renderRelacionesDiagrama() {
const contenedor = document.getElementById('relaciones-diagrama');
if (!contenedor) return;
contenedor.innerHTML = '';
this.relacionesError = '';
let relaciones = this.relacionesData;
if (this.relacionesFiltro) {
relaciones = relaciones.filter(r => r.from_table === this.relacionesFiltro || r.to_table === this.relacionesFiltro);
}
if (relaciones.length === 0) return;
const idSeguro = (n) => n.replace(/[^a-zA-Z0-9_]/g, '_');
const lineas = ['erDiagram'];
relaciones.forEach(r => {
lineas.push(` ${idSeguro(r.to_table)} ||--o{ ${idSeguro(r.from_table)} : "${r.from_column}"`);
});
const definicion = lineas.join('\n');
if (!window.mermaid) {
this.relacionesError = 'No se pudo cargar el motor de diagramas (mermaid.min.js)';
return;
}
try {
this.relacionesSeq++;
const { svg } = await mermaid.render('relaciones-svg-' + this.relacionesSeq, definicion);
contenedor.innerHTML = svg;
} catch (e) {
this.relacionesError = 'No se pudo dibujar el diagrama (revisa que los nombres de tabla no tengan caracteres especiales)';
}
},
async testConn() {
this.testLoading = true;
this.testMsg = '';
try {
await axios.get('/app/query-runner/test?conx_db_id=' + this.selectedConxId);
this.testOk = true;
this.testMsg = '✓ Conexión exitosa';
} catch (e) {
this.testOk = false;
this.testMsg = '✗ ' + (e.response?.data?.error || 'Error de conexión');
}
this.testLoading = false;
setTimeout(() => this.testMsg = '', 4000);
},
async runQuery() {
if (!this.selectedConxId || !this.sqlText.trim()) return;
if (this.batchMode) {
await this.runBatch();
return;
}
this.loadingQuery = true;
this.statusMsg = '';
this.results = [];
this.columns = [];
this.editCell = null;
this.closeAutocomplete();
try {
const res = await axios.post('/app/query-runner/run', {
conx_db_id: parseInt(this.selectedConxId),
database: this.selectedDb,
sql: this.sqlText
});
const data = res.data;
if (data.error) {
this.statusOk = false;
this.statusMsg = '✗ ' + data.error;
} else if (data.is_select) {
this.columns = data.columns || [];
this.results = data.rows || [];
this.statusOk = true;
this.statusMsg = `✓ ${this.results.length} fila(s) — ${data.duration_ms}ms`;
} else {
this.statusOk = true;
this.statusMsg = `✓ ${data.affected_rows} fila(s) afectadas — ${data.duration_ms}ms`;
}
} catch (e) {
this.statusOk = false;
this.statusMsg = '✗ ' + (e.response?.data?.error || 'Error al ejecutar');
}
this.loadingQuery = false;
await this.loadHistory();
},
// ── Batch mode ──────────────────────────────────────────────
extractSQLStatements(text) {
// Split by semicolons, trim whitespace, remove empty
return text
.split(';')
.map(s => s.trim())
.filter(s => s.length > 0 && !s.toUpperCase().startsWith('--') && !s.startsWith('#'));
},
uploadSQLFile(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result;
this.sqlText = content;
this.batchMode = true;
this.showToast(`📁 Archivo "${file.name}" cargado (${(content.length / 1024).toFixed(1)} KB)`);
};
reader.readAsText(file);
// Reset input so re-selecting same file triggers change
event.target.value = '';
},
async runBatch() {
if (!this.selectedConxId) return;
const sqls = this.extractSQLStatements(this.sqlText);
if (sqls.length === 0) {
this.showToast('No se encontraron sentencias SQL válidas', 'error');
return;
}
this.loadingQuery = true;
this.statusMsg = '';
this.batchResults = [];
this.batchActiveIdx = 0;
this.activeTab = 'results';
try {
const formData = new FormData();
formData.append('conx_db_id', this.selectedConxId);
formData.append('database', this.selectedDb || '');
sqls.forEach((sql, i) => formData.append('sqls[]', sql));
const res = await axios.post('/app/query-runner/run-batch', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
this.batchResults = res.data.results || [];
const okCount = this.batchResults.filter(r => r.status === 'ok').length;
const errCount = this.batchResults.filter(r => r.status === 'error').length;
this.statusOk = errCount === 0;
this.statusMsg = `✓ ${okCount} consultas ejecutadas, ${errCount} con error — ${res.data.duration || ''}`;
} catch (e) {
this.statusOk = false;
this.statusMsg = '✗ ' + (e.response?.data?.error || 'Error al ejecutar batch');
if (e.response?.data?.results) {
this.batchResults = e.response.data.results;
}
}
this.loadingQuery = false;
},
async loadHistory() {
if (!this.selectedConxId) return;
try {
const res = await axios.get('/app/query-runner/history?conx_db_id=' + this.selectedConxId);
this.history = res.data.data || [];
} catch (_) {}
},
async clearHistory() {
if (!confirm('¿Borrar todo el historial de esta conexión?')) return;
try {
await axios.delete('/app/query-runner/history?conx_db_id=' + this.selectedConxId);
this.history = [];
this.showToast('Historial borrado');
} catch (e) {
this.showToast('Error al borrar historial', 'error');
}
},
async exportData(fmt) {
if (!this.sqlText.trim()) return;
try {
const res = await axios.post('/app/query-runner/export/' + fmt, {
conx_db_id: parseInt(this.selectedConxId),
database: this.selectedDb,
sql: this.sqlText
}, { responseType: 'blob' });
const mime = fmt === 'csv' ? 'text/csv' : 'application/json';
const blob = new Blob([res.data], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'query_result.' + fmt;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
this.showToast('Error al exportar', 'error');
}
},
insertTable(name) {
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();
},
async loadCollectionFields(name) {
this.selectedCollection = name;
this.collectionFields = [];
if (!this.selectedConxId || !name) return;
this.loadingFields = true;
try {
const res = await axios.post('/app/query-runner/run', {
conx_db_id: parseInt(this.selectedConxId),
database: this.selectedDb,
sql: `db.${name}.findOne({})`
});
const rows = res.data?.rows;
if (rows && rows.length > 0) {
this.collectionFields = Object.entries(rows[0]).map(([k, v]) => ({
name: k,
type: v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v,
preview: v === null ? 'null' : Array.isArray(v) ? `[${v.length} elem]` : String(v).substring(0, 30),
sample: v
}));
}
} catch (_) {}
this.loadingFields = false;
},
buildUpdateField(fieldName) {
const col = this.selectedCollection || 'coleccion';
const f = this.collectionFields.find(f => f.name === fieldName);
let sampleVal = '"nuevo_valor"';
if (f && f.sample !== null && f.sample !== undefined) {
if (typeof f.sample === 'number') sampleVal = String(f.sample);
else if (typeof f.sample === 'boolean') sampleVal = String(f.sample);
else if (Array.isArray(f.sample)) sampleVal = '[]';
else if (typeof f.sample === 'object') sampleVal = '{}';
else {
const safe = String(f.sample).replace(/\\/g, '\\\\').replace(/"/g, '\\"').substring(0, 50);
sampleVal = `"${safe}"`;
}
}
this.sqlText = `db.${col}.updateOne(\n { "_id": "" },\n { "$set": { "${fieldName}": ${sampleVal} } }\n)`;
document.getElementById('sql-editor')?.focus();
},
toJsonValue(raw) {
const s = (raw || '').trim();
if (s === '') return '""';
if (/^".*"$/.test(s)) return s;
if (s === 'true' || s === 'false' || s === 'null') return s;
if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(s)) return s;
if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('[') && s.endsWith(']'))) return s;
const escaped = s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return `"${escaped}"`;
},
generateUpdate() {
const col = this.updateBuilder.collection || this.selectedCollection || 'coleccion';
const field = this.updateBuilder.field || 'campo';
const rawValue = this.updateBuilder.value || '"nuevo_valor"';
const filterField = this.updateBuilder.filterField || '_id';
const rawFilter = this.updateBuilder.filterValue || '""';
const op = this.updateBuilder.op || '$set';
const method = this.updateBuilder.multi === 'many' ? 'updateMany' : 'updateOne';
const value = this.toJsonValue(rawValue);
const filterValue = this.toJsonValue(rawFilter);
let updateDoc;
if (op === '$unset') updateDoc = `{ "${field}": "" }`;
else updateDoc = `{ "${field}": ${value} }`;
this.sqlText = `db.${col}.${method}(\n { "${filterField}": ${filterValue} },\n { "${op}": ${updateDoc} }\n)`;
},
generateDbHelper() {
const db = this.dbHelper.db || 'nombre_bd';
const user = this.dbHelper.user || 'usuario_app';
const host = this.dbHelper.host || '%';
const pass = this.dbHelper.pass || 'Contraseña123!';
let map;
if (this.isPostgres) {
const createDb = `CREATE DATABASE "${db}"\n WITH ENCODING 'UTF8'\n LC_COLLATE = 'en_US.UTF-8'\n LC_CTYPE = 'en_US.UTF-8'\n TEMPLATE = template0;`;
const createUser = `DO $$\nBEGIN\n IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '${user}') THEN\n CREATE USER "${user}" WITH PASSWORD '${pass}';\n END IF;\nEND\n$$;`;
const grant = `GRANT ALL PRIVILEGES ON DATABASE "${db}" TO "${user}";\nGRANT ALL ON SCHEMA public TO "${user}";\nALTER DEFAULT PRIVILEGES IN SCHEMA public\n GRANT ALL ON TABLES TO "${user}";\nALTER DEFAULT PRIVILEGES IN SCHEMA public\n GRANT ALL ON SEQUENCES TO "${user}";`;
const revoke = `REVOKE ALL PRIVILEGES ON DATABASE "${db}" FROM "${user}";\nREVOKE ALL ON SCHEMA public FROM "${user}";`;
const showGrants = `SELECT grantee, privilege_type, table_name\nFROM information_schema.role_table_grants\nWHERE grantee = '${user}'\nORDER BY table_name;`;
const dropUser = `DROP USER IF EXISTS "${user}";`;
const dropDb = `DROP DATABASE IF EXISTS "${db}";`;
map = {
full_setup: `-- 1. Crear base de datos\n${createDb}\n\n-- 2. Crear usuario (si no existe)\n${createUser}\n\n-- 3. Dar permisos totales\n${grant}`,
create_db: createDb,
create_user: createUser,
grant: grant,
revoke: revoke,
show_grants: showGrants,
drop_user: dropUser,
drop_db: dropDb,
};
} else {
const createDb = `CREATE DATABASE IF NOT EXISTS \`${db}\`\n CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`;
const createUser = `CREATE USER IF NOT EXISTS '${user}'@'${host}' IDENTIFIED BY '${pass}';`;
const grant = `GRANT ALL PRIVILEGES ON \`${db}\`.* TO '${user}'@'${host}';\nFLUSH PRIVILEGES;`;
const revoke = `REVOKE ALL PRIVILEGES ON \`${db}\`.* FROM '${user}'@'${host}';\nFLUSH PRIVILEGES;`;
const showGrants = `SHOW GRANTS FOR '${user}'@'${host}';`;
const dropUser = `DROP USER IF EXISTS '${user}'@'${host}';\nFLUSH PRIVILEGES;`;
const dropDb = `DROP DATABASE IF EXISTS \`${db}\`;`;
map = {
full_setup: `-- 1. Crear base de datos\n${createDb}\n\n-- 2. Crear usuario\n${createUser}\n\n-- 3. Dar permisos totales\n${grant}`,
create_db: createDb,
create_user: createUser,
grant: grant,
revoke: revoke,
show_grants: showGrants,
drop_user: dropUser,
drop_db: dropDb,
};
}
this.sqlText = map[this.dbHelper.op] || '';
document.getElementById('sql-editor')?.focus();
},
clearEditor() {
this.sqlText = '';
this.results = [];
this.columns = [];
this.statusMsg = '';
this.closeAutocomplete();
this.typoSuggestions = [];
this.editCell = null;
},
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) {
if (!d) return '';
return new Date(d).toLocaleString();
},
showToast(msg, type = 'ok') {
this.toast = { show: true, msg, type };
setTimeout(() => this.toast.show = false, 3500);
}
}));
});
</script>