tablas y frontend visualizador de DB listo

This commit is contained in:
2025-09-26 17:48:45 -06:00
parent 3fc25719f3
commit 44916b642b
9 changed files with 403 additions and 72 deletions

View File

@@ -1,6 +1,6 @@
<template>
<div v-if="open" class="modal-backdrop" @click.self="$emit('close')">
<div class="modal">
<div class="modal" :class="{ fullscreen }">
<div class="modal-header">
<strong>{{ title }}</strong>
<button class="icon-btn" @click="$emit('close')">Cerrar</button>
@@ -18,7 +18,6 @@
</template>
<script setup>
defineProps({ open: Boolean, title: String });
defineProps({ open: Boolean, title: String, fullscreen: { type: Boolean, default: false } });
defineEmits(['close']);
</script>

View File

@@ -0,0 +1,168 @@
<template>
<div>
<div class="row" style="gap:6px; margin-bottom:10px; flex-wrap:wrap;">
<button v-for="t in tables" :key="t" class="icon-btn" :class="{ active: t===active }" @click="select(t)">{{ t }}</button>
</div>
<div class="row" style="gap:8px; margin-bottom:8px; flex-wrap:wrap;">
<input v-model="q" class="toggle" placeholder="Buscar en página (texto)" style="flex:1; min-width:240px;" />
<label class="row toggle" style="gap:6px;">
Tamaño de página
<select v-model.number="limit" @change="reload()" style="background:transparent; border:none; color:inherit;">
<option :value="50">50</option>
<option :value="100">100</option>
<option :value="250">250</option>
<option :value="500">500</option>
</select>
</label>
<span class="chip">{{ offset + 1 }}{{ Math.min(offset + limit, total) }} / {{ total }}</span>
<div class="row" style="gap:6px;">
<button class="icon-btn" :disabled="offset<=0" @click="prev">Anterior</button>
<button class="icon-btn" :disabled="offset+limit>=total" @click="next">Siguiente</button>
</div>
<button class="icon-btn" @click="exportCsv">Exportar CSV</button>
<button class="icon-btn" @click="$emit('toggle-fullscreen')">Fullscreen</button>
</div>
<div class="panel" style="max-height: 60vh;">
<div class="scroll">
<div v-if="loading" class="muted">Cargando</div>
<div v-else>
<table v-if="columns.length" style="width:100%; border-collapse: collapse;">
<thead>
<tr>
<th v-for="c in columns" :key="c" @click="toggleSort(c)" style="user-select:none; cursor:pointer; text-align:left; padding:6px; border-bottom: 1px solid rgba(255,255,255,.08);">
{{ c }}
<span v-if="sortBy===c">{{ sortDir==='asc' ? '' : '' }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in sortedRows" :key="idx">
<td v-for="c in columns" :key="c" style="padding:6px; border-bottom: 1px solid rgba(255,255,255,.06); font-size:12px;">
<pre style="margin:0; white-space: pre-wrap;">{{ fmt(row[c]) }}</pre>
</td>
</tr>
</tbody>
</table>
<div v-else class="muted">Sin columnas</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref, computed } from 'vue';
const tables = ref([]);
const active = ref('');
const columns = ref([]);
const rows = ref([]);
const loading = ref(false);
const total = ref(0);
const limit = ref(100);
const offset = ref(0);
const q = ref('');
const sortBy = ref('');
const sortDir = ref('asc');
function fmt(v) {
if (v === null || v === undefined) return '';
if (typeof v === 'object') return JSON.stringify(v);
return String(v);
}
async function loadTables() {
const res = await fetch('/api/db/tables');
const data = await res.json();
tables.value = data.items || [];
if (tables.value.length && !active.value) {
await select(tables.value[0]);
}
}
async function select(t) {
active.value = t;
offset.value = 0;
await reload();
}
async function reload() {
if (!active.value) return;
loading.value = true;
try {
const params = new URLSearchParams({ limit: String(limit.value), offset: String(offset.value) });
const res = await fetch(`/api/db/table/${encodeURIComponent(active.value)}?` + params.toString());
const data = await res.json();
columns.value = data.columns || [];
rows.value = data.rows || [];
total.value = data.total || 0;
} finally {
loading.value = false;
}
}
function next() { if (offset.value + limit.value < total.value) { offset.value += limit.value; reload(); } }
function prev() { if (offset.value - limit.value >= 0) { offset.value -= limit.value; reload(); } else { offset.value = 0; reload(); } }
const filteredRows = computed(() => {
if (!q.value) return rows.value;
const t = q.value.toLowerCase();
return rows.value.filter(r => JSON.stringify(r).toLowerCase().includes(t));
});
const sortedRows = computed(() => {
if (!sortBy.value) return filteredRows.value;
const dir = sortDir.value === 'asc' ? 1 : -1;
const key = sortBy.value;
const arr = filteredRows.value.slice();
arr.sort((a, b) => {
const va = a[key];
const vb = b[key];
// numeric compare if both look numeric
const na = typeof va === 'number' || (/^-?\d+(\.\d+)?$/.test(String(va)) ? Number(va) : NaN);
const nb = typeof vb === 'number' || (/^-?\d+(\.\d+)?$/.test(String(vb)) ? Number(vb) : NaN);
if (!Number.isNaN(na) && !Number.isNaN(nb)) return (na - nb) * dir;
const sa = String(va ?? '').toLowerCase();
const sb = String(vb ?? '').toLowerCase();
return sa.localeCompare(sb) * dir;
});
return arr;
});
onMounted(() => { loadTables(); });
function toggleSort(c) {
if (sortBy.value !== c) { sortBy.value = c; sortDir.value = 'asc'; return; }
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
}
function toCsv(cols, rowsData) {
const esc = v => {
const s = v === null || v === undefined ? '' : (typeof v === 'object' ? JSON.stringify(v) : String(v));
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
const lines = [cols.join(',')];
for (const r of rowsData) lines.push(cols.map(c => esc(r[c])).join(','));
return lines.join('\n');
}
function exportCsv() {
const csv = toCsv(columns.value, sortedRows.value);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const ts = new Date().toISOString().replace(/[:T]/g, '-').split('.')[0];
a.href = url;
a.download = `${active.value || 'table'}-${ts}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>
<style scoped>
.icon-btn.active { outline: 2px solid rgba(255,127,187,.6); }
</style>

View File

@@ -5,28 +5,18 @@ const html = htm.bind(h);
export default defineComponent({
name: 'UserCard',
props: { item: { type: Object, required: true }, mode: { type: String, default: 'user' } },
<<<<<<< HEAD
emits: ['toggleDisable', 'remove', 'edit'],
setup(props, { emit }) {
function toggle() { emit('toggleDisable', props.item); }
function remove() { emit('remove', props.item); }
function edit() { emit('edit', props.item); }
=======
emits: ['toggleDisable', 'remove'],
setup(props, { emit }) {
function toggle() { emit('toggleDisable', props.item); }
function remove() { emit('remove', props.item); }
>>>>>>> c92df7bb9a1b22aee21d2c083ea163d21ab1a722
return () => html`<div class="card">
<div class="row">
<b>${props.mode === 'user' ? props.item.username : (props.item.device || props.item.username)}</b>
<span class="chip">VLAN ${props.item.vlan}</span>
${props.item.disabled ? html`<span class="chip" style="color:#b33">deshabilitado</span>` : html`<span class="chip">activo</span>`}
<span class="spacer"></span>
<<<<<<< HEAD
<button class="icon-btn" onClick=${edit}>Editar</button>
=======
>>>>>>> c92df7bb9a1b22aee21d2c083ea163d21ab1a722
<button class="icon-btn" onClick=${toggle}>${props.item.disabled ? 'Habilitar' : 'Deshabilitar'}</button>
<button class="icon-btn" onClick=${remove}>Eliminar</button>
</div>
@@ -34,7 +24,3 @@ export default defineComponent({
</div>`;
}
});
<<<<<<< HEAD
=======
>>>>>>> c92df7bb9a1b22aee21d2c083ea163d21ab1a722