- Detecta isPostgres desde tipo_db.nombre al cambiar conexión
- sqlHelpers: getter dinámico — chips distintos para PostgreSQL vs MySQL
PostgreSQL: pg_database, information_schema, pg_stat_activity, pg_indexes
MySQL: SHOW DATABASES/TABLES/USERS/PROCESSLIST/CREATE TABLE
- generateDbHelper: genera SQL correcto por dialecto
PostgreSQL: sin backticks, sin charset, sin @host, DO$$ para CREATE USER IF NOT EXISTS,
GRANT ALL ON DATABASE + SCHEMA + DEFAULT PRIVILEGES
MySQL: backticks, utf8mb4, 'user'@'host', FLUSH PRIVILEGES
- Badge PostgreSQL/MySQL en header del generador DBA
- Campo Host oculto en conexiones PostgreSQL
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
782 lines
49 KiB
HTML
782 lines
49 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' : '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>
|
|
</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>
|
|
|
|
<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' : 'SQL Editor'"></span>
|
|
<div class="flex-1"></div>
|
|
<span x-show="selectedDb" class="text-xs text-[#4ade80] font-mono" x-text="selectedDb"></span>
|
|
</div>
|
|
<textarea id="sql-editor" x-model="sqlText"
|
|
@keydown.ctrl.enter.prevent="runQuery()"
|
|
@keydown.meta.enter.prevent="runQuery()"
|
|
:placeholder="isMongo ? 'db.coleccion.find({})\n// Ctrl+Enter para ejecutar\n// db.coleccion.insertOne({campo: \'valor\'})' : '-- Escribe tu consulta SQL aquí\n-- Ctrl+Enter para ejecutar\nSELECT * FROM tabla LIMIT 100;'"
|
|
class="w-full bg-gray-900 text-gray-100 font-mono text-sm p-4 resize-none outline-none leading-relaxed"
|
|
style="height:180px; tab-size:2;"></textarea>
|
|
</div>
|
|
<!-- ─── Helpers SQL (solo MySQL/PostgreSQL) ─── -->
|
|
<div x-show="!isMongo && selectedConxId" class="mt-1.5">
|
|
<!-- 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" 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>
|
|
</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' },
|
|
isMongo: false,
|
|
isPostgres: false,
|
|
selectedCollection: '',
|
|
collectionFields: [],
|
|
loadingFields: false,
|
|
showUpdateBuilder: false,
|
|
updateBuilder: { collection: '', field: '', value: '', filterField: '_id', filterValue: '""', op: '$set', multi: 'one' },
|
|
|
|
// 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;" },
|
|
];
|
|
// MySQL / MariaDB
|
|
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}}}])' },
|
|
],
|
|
|
|
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 = [];
|
|
this.isMongo = false;
|
|
this.selectedCollection = '';
|
|
this.collectionFields = [];
|
|
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');
|
|
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 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 = this.isMongo ? `db.${name}.find({})` : `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();
|
|
},
|
|
|
|
// Convierte un valor crudo ingresado por el usuario a JSON válido.
|
|
// Si ya es un literal JSON (num, bool, null, string entre comillas, objeto/array)
|
|
// lo deja intacto; de lo contrario lo trata como string y lo envuelve en comillas.
|
|
toJsonValue(raw) {
|
|
const s = (raw || '').trim();
|
|
if (s === '') return '""';
|
|
// Ya es string JSON, número, bool, null, objeto o array
|
|
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;
|
|
// Bare string → escapar y envolver en comillas
|
|
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) {
|
|
// ── PostgreSQL ──────────────────────────────────────────────
|
|
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 {
|
|
// ── MySQL / MariaDB ─────────────────────────────────────────
|
|
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 = ''; },
|
|
|
|
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>
|