Files
soft_usite/resources/views/query_runner.html
T

383 lines
19 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 -->
<div class="flex-1 overflow-y-auto p-2" x-show="tables.length > 0">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wide px-1 mb-1">Tablas</p>
<template x-for="t in tables" :key="t">
<button @click="insertTable(t)"
class="w-full text-left text-xs px-2 py-1 rounded 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>
</template>
</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>
<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 -->
<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">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="-- Escribe tu consulta SQL aquí&#10;-- Ctrl+Enter para ejecutar&#10;SELECT * 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>
</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">
<div x-show="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>
<table x-show="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"
:title="nullStr(row[col])"
x-text="nullStr(row[col])"></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>
<!-- 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>
<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',
statusMsg: '',
statusOk: true,
testMsg: '',
testOk: true,
toast: { show: false, msg: '', type: 'ok' },
async init() {
// Pre-cargar ID desde URL si viene de conx_db
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();
}
},
async onConxChange() {
this.databases = [];
this.tables = [];
this.selectedDb = '';
this.results = [];
this.columns = [];
this.statusMsg = '';
this.history = [];
if (!this.selectedConxId) return;
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 = [];
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 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;
this.loadingQuery = true;
this.statusMsg = '';
this.results = [];
this.columns = [];
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();
},
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) {
const sel = `SELECT * FROM ${name} LIMIT 100;`;
this.sqlText = this.sqlText ? this.sqlText + '\n' + sel : sel;
document.getElementById('sql-editor')?.focus();
},
clearEditor() { this.sqlText = ''; this.results = []; this.columns = []; this.statusMsg = ''; },
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>