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).
This commit is contained in:
Lizandro GD
2026-08-08 23:11:48 +00:00
parent b7fba43ea1
commit 21d8cd5260
6 changed files with 3888 additions and 0 deletions
+108
View File
@@ -68,6 +68,14 @@
<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>
@@ -149,6 +157,12 @@
<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"
@@ -749,6 +763,33 @@
</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"
@@ -756,6 +797,13 @@
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', () => ({
@@ -789,6 +837,14 @@ document.addEventListener('alpine:init', () => {
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: [],
@@ -1428,6 +1484,58 @@ document.addEventListener('alpine:init', () => {
}
},
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 = '';