tablas y frontend visualizador de DB listo
This commit is contained in:
@@ -12,9 +12,14 @@
|
||||
<button class="icon-btn" @click="openAddGuest">
|
||||
<img class="icon" src="/icons/guest.svg" alt="invitado"> Invitado
|
||||
</button>
|
||||
<button class="icon-btn" @click="openSettings">
|
||||
<div class="dropdown">
|
||||
<button class="icon-btn" @click="toggleSettingsMenu">
|
||||
<img class="icon" src="/icons/settings.svg" alt="config"> Configuración
|
||||
</button>
|
||||
<div v-if="showSettingsMenu" class="menu">
|
||||
<button @click="openRawDb">ver rawDB</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -55,24 +60,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<<<<<<< HEAD
|
||||
<div v-if="loading.users" class="muted">Cargando usuarios…</div>
|
||||
<div v-else class="grid">
|
||||
<UserCard v-for="u in filteredUsers" :key="u.username" :item="u" :mode="layoutMode"
|
||||
@toggleDisable="toggleDisable" @remove="removeUser" @edit="openEditUser" />
|
||||
=======
|
||||
<form @submit.prevent="createUser" class="row" style="margin-bottom:10px;">
|
||||
<input v-model="form.username" placeholder="usuario" required class="toggle"/>
|
||||
<input v-model="form.password" placeholder="contraseña" required class="toggle"/>
|
||||
<input v-model="form.vlan" placeholder="VLAN" class="toggle"/>
|
||||
<label class="row toggle" style="gap:6px;"><input type="checkbox" v-model="form.disabled"/> deshabilitado</label>
|
||||
<button type="submit" class="icon-btn">Crear / Actualizar</button>
|
||||
</form>
|
||||
<div v-if="loading.users" class="muted">Cargando usuarios…</div>
|
||||
<div v-else class="grid">
|
||||
<UserCard v-for="u in filteredUsers" :key="u.username" :item="u" :mode="layoutMode"
|
||||
@toggleDisable="toggleDisable" @remove="removeUser" />
|
||||
>>>>>>> c92df7bb9a1b22aee21d2c083ea163d21ab1a722
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -103,14 +94,15 @@
|
||||
</select>
|
||||
</div>
|
||||
</Modal>
|
||||
<<<<<<< HEAD
|
||||
|
||||
<Modal :open="showUserForm" :title="userFormMode==='edit' ? 'Editar usuario' : (userFormMode==='guest' ? 'Agregar invitado' : 'Agregar usuario')"
|
||||
@close="showUserForm=false">
|
||||
<UserForm :model-value="userFormModel" :mode="userFormMode" @submit="handleUserFormSubmit" @cancel="showUserForm=false" />
|
||||
</Modal>
|
||||
=======
|
||||
>>>>>>> c92df7bb9a1b22aee21d2c083ea163d21ab1a722
|
||||
|
||||
<Modal :open="showRawDb" :fullscreen="rawDbFullscreen" title="Raw DB Viewer" @close="closeRawDb">
|
||||
<RawDbViewer @toggle-fullscreen="rawDbFullscreen = !rawDbFullscreen" />
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -118,19 +110,13 @@ import { onMounted, reactive, ref, computed } from 'vue';
|
||||
import EventCard from './components/EventCard.js';
|
||||
import UserCard from './components/UserCard.js';
|
||||
import Modal from './components/Modal.vue';
|
||||
<<<<<<< HEAD
|
||||
import UserForm from './components/UserForm.vue';
|
||||
=======
|
||||
>>>>>>> c92df7bb9a1b22aee21d2c083ea163d21ab1a722
|
||||
import RawDbViewer from './components/RawDbViewer.vue';
|
||||
|
||||
const users = ref([]);
|
||||
const requests = ref([]);
|
||||
const loading = reactive({ users: false, requests: false });
|
||||
<<<<<<< HEAD
|
||||
// formulario inline removido: se usa modal con UserForm
|
||||
=======
|
||||
const form = reactive({ username: '', password: '', vlan: '', disabled: false });
|
||||
>>>>>>> c92df7bb9a1b22aee21d2c083ea163d21ab1a722
|
||||
|
||||
const showEventFilters = ref(false);
|
||||
const showUserFilters = ref(false);
|
||||
@@ -141,6 +127,7 @@ const mainCollapsed = ref(false);
|
||||
const layoutMode = ref('user');
|
||||
const theme = ref(localStorage.getItem('theme') || 'dark');
|
||||
const statusText = ref('OK');
|
||||
const showSettingsMenu = ref(false);
|
||||
|
||||
async function fetchUsers() {
|
||||
loading.users = true;
|
||||
@@ -160,20 +147,6 @@ async function fetchRequests() {
|
||||
} finally { loading.requests = false; }
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
async function createUser() {
|
||||
const payload = { ...form };
|
||||
if (!payload.vlan) delete payload.vlan;
|
||||
await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||
form.username = '';
|
||||
form.password = '';
|
||||
form.vlan = '';
|
||||
form.disabled = false;
|
||||
await fetchUsers();
|
||||
}
|
||||
|
||||
>>>>>>> c92df7bb9a1b22aee21d2c083ea163d21ab1a722
|
||||
async function toggleDisable(u) {
|
||||
await fetch(`/api/users/${encodeURIComponent(u.username)}`, {
|
||||
method: 'PATCH',
|
||||
@@ -253,7 +226,6 @@ function applyTheme() {
|
||||
document.documentElement.classList.toggle('light', theme.value === 'light');
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
const showUserForm = ref(false);
|
||||
const userFormMode = ref('create'); // 'create' | 'edit' | 'guest'
|
||||
const userFormModel = ref({ username:'', password:'', vlan:'', disabled:false });
|
||||
@@ -268,7 +240,12 @@ function openAddGuest() {
|
||||
userFormModel.value = { username:'', password:'', vlan:'5', disabled:false };
|
||||
showUserForm.value = true;
|
||||
}
|
||||
function openSettings() { /* modal de configuración futura */ }
|
||||
function toggleSettingsMenu() { showSettingsMenu.value = !showSettingsMenu.value; }
|
||||
const showRawDb = ref(false);
|
||||
const rawDbFullscreen = ref(false);
|
||||
function openRawDb() { showSettingsMenu.value = false; showRawDb.value = true; }
|
||||
function closeRawDb() { showRawDb.value = false; }
|
||||
function openSettings() { showSettingsMenu.value = !showSettingsMenu.value; }
|
||||
function openEditUser(u) {
|
||||
userFormMode.value = 'edit';
|
||||
userFormModel.value = { username: u.username, password: u.password || '', vlan: u.vlan || '', disabled: !!u.disabled };
|
||||
@@ -286,9 +263,4 @@ async function handleUserFormSubmit(data) {
|
||||
await fetchUsers();
|
||||
showUserForm.value = false;
|
||||
}
|
||||
=======
|
||||
function openAddUser() { /* placeholder for advanced modal */ }
|
||||
function openAddGuest() { /* placeholder for advanced modal */ }
|
||||
function openSettings() { /* placeholder for advanced modal */ }
|
||||
>>>>>>> c92df7bb9a1b22aee21d2c083ea163d21ab1a722
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
168
frontend/src/components/RawDbViewer.vue
Normal file
168
frontend/src/components/RawDbViewer.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -80,6 +80,10 @@ a { color: inherit; }
|
||||
backdrop-filter: blur(var(--glass-blur)); }
|
||||
.icon-btn:hover { transform: translateY(-1px); background: rgba(var(--card)); }
|
||||
.icon { width: 16px; height: 16px; opacity: .9; }
|
||||
.dropdown { position: relative; }
|
||||
.menu { position: absolute; right: 0; top: calc(100% + 6px); background: rgba(var(--card)); border: 1px solid #ffcfe4; border-radius: 10px; padding: 6px; backdrop-filter: blur(var(--glass-blur)); min-width: 180px; box-shadow: 0 10px 24px rgba(0,0,0,.18); }
|
||||
.menu button { display: block; width: 100%; text-align: left; padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(var(--border)); background: rgba(var(--card)); }
|
||||
.menu button:hover { transform: none; background: rgba(255,255,255,0.06); }
|
||||
|
||||
/* Layout */
|
||||
.shell { height: calc(100vh - 54px); display: grid; grid-template-columns: 360px 1fr; gap: 12px; padding: 12px; }
|
||||
@@ -117,6 +121,10 @@ a { color: inherit; }
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.35); backdrop-filter: blur(4px); display: grid; place-items: center; z-index: 20; animation: fadeIn .15s ease;
|
||||
}
|
||||
.modal { width: min(680px, 92vw); border-radius: 14px; border: 1px solid rgba(var(--border)); background: rgba(var(--card)); padding: 14px; box-shadow: 0 10px 32px rgba(0,0,0,.2); }
|
||||
.modal.fullscreen { width: 96vw; max-width: 96vw; height: 92vh; max-height: 92vh; display: flex; flex-direction: column; }
|
||||
.modal.fullscreen > .modal-header { position: sticky; top: 0; background: rgba(var(--card)); z-index: 1; }
|
||||
.modal.fullscreen > .modal-footer { position: sticky; bottom: 0; background: rgba(var(--card)); z-index: 1; }
|
||||
.modal.fullscreen > div:nth-child(2) { flex: 1 1 auto; overflow: auto; }
|
||||
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { createApp } from './src/app.js';
|
||||
import { ensureSchema } from './src/services/db.js';
|
||||
|
||||
const app = createApp();
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
try {
|
||||
await ensureSchema();
|
||||
} catch (e) {
|
||||
console.error('Database schema ensure failed:', e?.message || e);
|
||||
}
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Node RADIUS REST API listening on :${port}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { Router } from 'express';
|
||||
import { VLAN_ID } from '../config/env.js';
|
||||
import { clearRequests, getRecentRequests, registerSse } from '../sse.js';
|
||||
import { deleteUserFromDb, readUsersFromDb, upsertUserToDb } from '../services/db.js';
|
||||
import { deleteUserFromDb, readUsersFromDb, upsertUserToDb, pool } from '../services/db.js';
|
||||
import { disconnectUserSessions } from '../services/radius.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Users
|
||||
router.get('/users', async (_req, res) => {
|
||||
try {
|
||||
const items = await readUsersFromDb();
|
||||
res.json({ items });
|
||||
} catch (e) {
|
||||
console.error('GET /api/users error:', e?.message || e);
|
||||
res.status(500).json({ items: [], ok: false, error: 'db_error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/users', async (req, res) => {
|
||||
@@ -89,4 +94,36 @@ router.get('/events', (req, res) => {
|
||||
registerSse(req, res, {});
|
||||
});
|
||||
|
||||
// Raw DB views (read-only)
|
||||
router.get('/db/tables', async (_req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE' ORDER BY table_name ASC"
|
||||
);
|
||||
res.json({ items: rows.map(r => r.table_name) });
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: 'db_error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/db/table/:name', async (req, res) => {
|
||||
const t = String(req.params.name);
|
||||
try {
|
||||
// validate table name exists and is public
|
||||
const { rows } = await pool.query(
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE' AND table_name = $1",
|
||||
[t]
|
||||
);
|
||||
if (rows.length === 0) return res.status(404).json({ ok: false, error: 'not_found' });
|
||||
const limit = Math.max(1, Math.min(1000, parseInt(String(req.query.limit || '100'), 10) || 100));
|
||||
const offset = Math.max(0, parseInt(String(req.query.offset || '0'), 10) || 0);
|
||||
const totalRes = await pool.query(`SELECT COUNT(*)::int AS n FROM "${t}"`);
|
||||
const total = totalRes.rows[0]?.n || 0;
|
||||
const result = await pool.query(`SELECT * FROM "${t}" OFFSET $1 LIMIT $2`, [offset, limit]);
|
||||
res.json({ ok: true, columns: result.fields.map(f => f.name), rows: result.rows, total, limit, offset });
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: 'db_error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -4,6 +4,70 @@ import { PGDATABASE, PGHOST, PGPASSWORD, PGPORT, PGUSER, SESSION_TIMEOUT, VLAN_I
|
||||
const { Pool } = pkgPg;
|
||||
export const pool = new Pool({ host: PGHOST, port: PGPORT, database: PGDATABASE, user: PGUSER, password: PGPASSWORD });
|
||||
|
||||
export async function ensureSchema() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS vlans (
|
||||
id INTEGER PRIMARY KEY,
|
||||
nombre TEXT NOT NULL,
|
||||
descripcion TEXT
|
||||
);
|
||||
`);
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS dispositivos (
|
||||
id SERIAL PRIMARY KEY,
|
||||
mac VARCHAR(32) UNIQUE NOT NULL,
|
||||
nombre TEXT,
|
||||
descripcion TEXT,
|
||||
vendor TEXT,
|
||||
first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen TIMESTAMPTZ,
|
||||
notas TEXT
|
||||
);
|
||||
`);
|
||||
await client.query(`CREATE INDEX IF NOT EXISTS dispositivos_mac_idx ON dispositivos (mac);`);
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
username VARCHAR(64) PRIMARY KEY,
|
||||
etiquetas TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
habilitado_since TIMESTAMPTZ,
|
||||
dispositivos_utilizados INTEGER[] NOT NULL DEFAULT '{}',
|
||||
dispositivos_conectados INTEGER[] NOT NULL DEFAULT '{}'
|
||||
);
|
||||
`);
|
||||
await client.query(`
|
||||
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END; $$ LANGUAGE plpgsql;
|
||||
`);
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_users_updated'
|
||||
) THEN
|
||||
CREATE TRIGGER trg_users_updated
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
END IF;
|
||||
END$$;
|
||||
`);
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('ensureSchema error:', e?.message || e);
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function readUsersFromDb() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
@@ -18,12 +82,30 @@ export async function readUsersFromDb() {
|
||||
SELECT rr.value FROM radreply rr
|
||||
WHERE rr.username = rc.username AND rr.attribute = 'Tunnel-Private-Group-Id'
|
||||
ORDER BY rr.id DESC LIMIT 1
|
||||
), $1) AS vlan
|
||||
), $1) AS vlan,
|
||||
u.etiquetas,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.habilitado_since,
|
||||
u.dispositivos_utilizados,
|
||||
u.dispositivos_conectados
|
||||
FROM radcheck rc
|
||||
LEFT JOIN users u ON u.username = rc.username
|
||||
WHERE rc.attribute = 'Cleartext-Password'
|
||||
ORDER BY rc.username ASC`;
|
||||
const { rows } = await client.query(q, [String(VLAN_ID)]);
|
||||
return rows.map(r => ({ username: r.username, password: r.password, vlan: String(r.vlan), disabled: !!r.disabled }));
|
||||
return rows.map(r => ({
|
||||
username: r.username,
|
||||
password: r.password,
|
||||
vlan: String(r.vlan),
|
||||
disabled: !!r.disabled,
|
||||
etiquetas: r.etiquetas || [],
|
||||
created_at: r.created_at || null,
|
||||
updated_at: r.updated_at || null,
|
||||
habilitado_since: r.habilitado_since || null,
|
||||
dispositivos_utilizados: r.dispositivos_utilizados || [],
|
||||
dispositivos_conectados: r.dispositivos_conectados || [],
|
||||
}));
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
@@ -34,6 +116,11 @@ export async function upsertUserToDb(user) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
// Ensure metadata row exists
|
||||
await client.query(
|
||||
'INSERT INTO users (username) VALUES ($1) ON CONFLICT (username) DO NOTHING',
|
||||
[username]
|
||||
);
|
||||
await client.query("DELETE FROM radcheck WHERE username = $1 AND attribute = 'Cleartext-Password'", [username]);
|
||||
await client.query(
|
||||
"INSERT INTO radcheck (username, attribute, op, value) VALUES ($1,'Cleartext-Password',':=',$2)",
|
||||
@@ -66,6 +153,14 @@ export async function upsertUserToDb(user) {
|
||||
[username, attr, String(val)]
|
||||
);
|
||||
}
|
||||
// Update metadata timestamps; set habilitado_since when enabled
|
||||
await client.query(
|
||||
`UPDATE users
|
||||
SET updated_at = NOW(),
|
||||
habilitado_since = CASE WHEN $2::boolean = FALSE THEN NOW() ELSE habilitado_since END
|
||||
WHERE username = $1`,
|
||||
[username, !!disabled]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
@@ -81,6 +176,7 @@ export async function deleteUserFromDb(username) {
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM radcheck WHERE username = $1', [username]);
|
||||
await client.query('DELETE FROM radreply WHERE username = $1', [username]);
|
||||
await client.query('DELETE FROM users WHERE username = $1', [username]);
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
@@ -89,4 +185,3 @@ export async function deleteUserFromDb(username) {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
59
postgres/init/02-users-devices.sql
Normal file
59
postgres/init/02-users-devices.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- Additional domain tables for VLANs, devices, and user metadata
|
||||
|
||||
-- VLAN catalog (acts as enum of available VLANs)
|
||||
CREATE TABLE IF NOT EXISTS vlans (
|
||||
id INTEGER PRIMARY KEY,
|
||||
nombre TEXT NOT NULL,
|
||||
descripcion TEXT
|
||||
);
|
||||
|
||||
-- Devices table: store individual device information
|
||||
CREATE TABLE IF NOT EXISTS dispositivos (
|
||||
id SERIAL PRIMARY KEY,
|
||||
mac VARCHAR(32) UNIQUE NOT NULL,
|
||||
nombre TEXT,
|
||||
descripcion TEXT,
|
||||
vendor TEXT,
|
||||
first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen TIMESTAMPTZ,
|
||||
notas TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS dispositivos_mac_idx ON dispositivos (mac);
|
||||
|
||||
-- Users metadata table (separate from radcheck/radreply for FreeRADIUS)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
username VARCHAR(64) PRIMARY KEY,
|
||||
etiquetas TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
habilitado_since TIMESTAMPTZ,
|
||||
dispositivos_utilizados INTEGER[] NOT NULL DEFAULT '{}', -- references dispositivos.id
|
||||
dispositivos_conectados INTEGER[] NOT NULL DEFAULT '{}' -- references dispositivos.id
|
||||
);
|
||||
|
||||
-- updated_at trigger helper
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_proc WHERE proname = 'set_updated_at'
|
||||
) THEN
|
||||
CREATE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_users_updated'
|
||||
) THEN
|
||||
CREATE TRIGGER trg_users_updated
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
Reference in New Issue
Block a user