app V1 completada

This commit is contained in:
2025-09-26 19:55:12 -06:00
parent 7d7a845a75
commit ca605a5759
3 changed files with 287 additions and 3 deletions

47
AGENTS.md Normal file
View File

@@ -0,0 +1,47 @@
# Repository Guidelines
## Project Structure & Module Organization
- `node-api/`: Node.js Express service (ES modules).
- `src/app.js`: Express app bootstrap; static files served from `public/`.
- `src/routes/`: API (`api.js`) and RADIUS hooks (`radius.js`).
- `src/services/`: DB (`db.js`), RADIUS/CoA (`radius.js`).
- `src/utils/attrs.js`, `src/sse.js`.
- `index.js`: entrypoint (schema ensure + server).
- `frontend/`: Vue 3 + Vite SPA.
- `src/`: `App.vue`, components, `styles.css`.
- `index.html` and built assets in `dist/` (ignored).
- `postgres/init/`: SQL schema files.
- `freeradius/`: server configuration used by docker.
- `docker-compose.yml`: local stack (node, postgres, freeradius).
## Build, Test, and Development Commands
- `npm run dev`: build frontend, start full stack with Docker.
- `npm run restart`: rebuild + recreate containers.
- `npm run logs`: follow service logs; `npm run down`: stop stack.
- Frontend dev (hot-reload): `npm --prefix frontend run dev` (proxy to `:3000`).
- Manual guest cleanup: `POST /api/guests/disable-yesterday`.
## Coding Style & Naming Conventions
- JavaScript/Node (ESM), Vue SFCs, 2-space indent, semicolons, single quotes.
- Filenames: kebab-case for files, PascalCase for Vue components, camelCase for vars/functions.
- Keep modules small and colocate helpers in `src/utils/` or `src/services/`.
- No formatter configured; match existing style.
## Testing Guidelines
- No test harness checked in. If adding tests:
- API: Jest + Supertest under `node-api/tests/`.
- Frontend: Vitest under `frontend/src/__tests__/`.
- Prefer small, fast unit tests; name with `*.test.js`.
## Commit & Pull Request Guidelines
- Follow Conventional Commits where practical (e.g., `feat:`, `fix:`, `chore:`, `style:`). Examples in history: `chore(git): …`, `style(ui): …`.
- PRs should include:
- Summary, rationale, and scope.
- Linked issue (if any) and screenshots/GIFs for UI changes.
- Notes on migrations (`postgres/init`) or env changes.
## Security & Configuration Tips
- Do not commit secrets; `.env` is ignored. Configure `RADIUS_SECRET`, PG creds, VLAN/bandwidth via env.
- DB schema is auto-ensured at startup; initial SQL runs from `postgres/init/` when the volume is new.
- SSE endpoints stream events at `/api/events`; be mindful of long-lived connections.

View File

@@ -19,6 +19,13 @@
<div v-if="showSettingsMenu" class="menu">
<button @click="openRawDb">ver rawDB</button>
<button @click="openVlanForm">crear VLAN</button>
<hr/>
<a class="icon-btn" href="/api/users.csv">Exportar usuarios CSV</a>
<a class="icon-btn" href="/api/devices.csv">Exportar dispositivos CSV</a>
<a class="icon-btn" href="/api/vlans.csv">Exportar VLANs CSV</a>
<button class="icon-btn" @click="openImport('users')">Importar usuarios CSV</button>
<button class="icon-btn" @click="openImport('devices')">Importar dispositivos CSV</button>
<button class="icon-btn" @click="openImport('vlans')">Importar VLANs CSV</button>
</div>
</div>
</div>
@@ -30,6 +37,9 @@
<div class="panel-header">
<div class="panel-title">Eventos FreeRADIUS</div>
<div class="panel-actions">
<span class="chip">Página {{ reqPage+1 }} / {{ Math.max(1, Math.ceil(filteredRequestsAll.length / pageSize)) }}</span>
<button class="icon-btn" @click="reqPage=Math.max(0, reqPage-1)">Anterior</button>
<button class="icon-btn" @click="reqPage=Math.min(Math.ceil(filteredRequestsAll.length/pageSize)-1, reqPage+1)">Siguiente</button>
<button class="icon-btn" title="Filtrar" @click="showEventFilters = true"><img class="icon" src="/icons/filter.svg" alt="filtrar"></button>
<button class="icon-btn" title="Limpiar" @click="clearRequests"><img class="icon" src="/icons/clear.svg" alt="limpiar"></button>
<button class="icon-btn" title="Test" @click="selfTest"><img class="icon" src="/icons/test.svg" alt="test"></button>
@@ -40,7 +50,7 @@
</div>
<div class="scroll">
<div v-if="loading.requests" class="muted">Cargando eventos</div>
<EventCard v-for="ev in filteredRequests" :key="ev.id" :ev="ev" />
<EventCard v-for="ev in pagedRequests" :key="ev.id" :ev="ev" />
</div>
</aside>
@@ -50,6 +60,10 @@
<div class="row">
<div class="panel-title">Usuarios y Dispositivos</div>
<span class="spacer"></span>
<span class="chip" v-if="layoutMode==='user'">Página {{ userPage+1 }} / {{ Math.max(1, Math.ceil(filteredUsersAll.length / pageSize)) }}</span>
<span class="chip" v-else>Página {{ devicePage+1 }} / {{ Math.max(1, Math.ceil(devicesAll.length / pageSize)) }}</span>
<button class="icon-btn" @click="layoutMode==='user' ? (userPage=Math.max(0,userPage-1)) : (devicePage=Math.max(0,devicePage-1))">Anterior</button>
<button class="icon-btn" @click="layoutMode==='user' ? (userPage=Math.min(Math.ceil(filteredUsersAll.length/pageSize)-1,userPage+1)) : (devicePage=Math.min(Math.ceil(devicesAll.length/pageSize)-1,devicePage+1))">Siguiente</button>
<button class="icon-btn" title="Vista usuarios" @click="layoutMode='user'">
<img class="icon" src="/icons/layout-users.svg" alt="usuarios"/> Usuarios
</button>
@@ -71,7 +85,7 @@
@disconnect="disconnectUser" @edit-device="openDeviceForm" />
</div>
<div v-else class="grid">
<DispositivoCard v-for="d in devices" :key="d.id" :device="d" :users="usersForDevice(d.id)" :devicesById="devicesById"
<DispositivoCard v-for="d in pagedDevices" :key="d.id" :device="d" :users="usersForDevice(d.id)" :devicesById="devicesById"
:expanded="!!deviceExpanded[d.id]"
@toggle-expand="deviceExpanded[d.id] = !deviceExpanded[d.id]"
@edit="openDeviceForm" @disconnect="disconnectDevice" />
@@ -123,6 +137,22 @@
<Modal :open="showDevice" title="Editar dispositivo" @close="closeDevice">
<DeviceForm :model="deviceFormModel" @success="onDeviceSaved" @cancel="closeDevice" />
</Modal>
<Modal :open="showImport" :title="'Importar ' + importKind + ' CSV'" @close="closeImport">
<div class="column" style="gap:8px;">
<div class="row" style="gap:8px; align-items:center; flex-wrap:wrap;">
<input id="csvFile" ref="importFile" type="file" accept=".csv,text/csv" @change="onImportFile" style="display:none;" />
<button class="icon-btn" @click="$refs.importFile.click()">Seleccionar archivo CSV</button>
<span class="muted" v-if="importFilename">{{ importFilename }}</span>
</div>
<textarea v-model="importText" rows="10" placeholder="Pega CSV aquí o selecciona un archivo" style="width:100%; background:transparent; border: 1px solid rgba(255,255,255,.12); color: inherit; padding:8px;"></textarea>
<div v-if="importError" class="muted" style="color:#ff6b6b;">{{ importError }}</div>
<div class="modal-footer">
<button class="icon-btn" @click="closeImport">Cancelar</button>
<button class="icon-btn" @click="submitImport">Importar</button>
</div>
</div>
</Modal>
</template>
<script setup>
@@ -273,7 +303,7 @@ const filteredRequests = computed(() => {
});
});
const filteredUsers = computed(() => {
const filteredUsersAll = computed(() => {
return users.value.filter(u => {
if (userFilters.text && !u.username.toLowerCase().includes(userFilters.text.toLowerCase())) return false;
if (userFilters.status === 'active' && u.disabled) return false;
@@ -281,6 +311,10 @@ const filteredUsers = computed(() => {
return true;
});
});
const pageSize = 20;
const userPage = ref(0);
const filteredUsers = computed(() => filteredUsersAll.value.slice(userPage.value*pageSize, userPage.value*pageSize + pageSize));
watch([filteredUsersAll, () => layoutMode.value], () => { userPage.value = 0; });
const devicesById = computed(() => {
const m = {};
@@ -292,6 +326,18 @@ function usersForDevice(id) {
return users.value.filter(u => Array.isArray(u.dispositivos_utilizados) && u.dispositivos_utilizados.includes(id));
}
// Devices pagination
const devicesAll = computed(() => devices.value);
const devicePage = ref(0);
const pagedDevices = computed(() => devicesAll.value.slice(devicePage.value*pageSize, devicePage.value*pageSize + pageSize));
watch([devicesAll, () => layoutMode.value], () => { devicePage.value = 0; });
// Requests pagination (sidebar)
const filteredRequestsAll = computed(() => filteredRequests.value);
const reqPage = ref(0);
const pagedRequests = computed(() => filteredRequestsAll.value.slice(reqPage.value*pageSize, reqPage.value*pageSize + pageSize));
watch(filteredRequestsAll, () => { reqPage.value = 0; });
// Persist expansion state
watch(userExpanded, (v) => {
try { localStorage.setItem('ui_userExpanded', JSON.stringify(v)); } catch {}
@@ -342,6 +388,67 @@ const deviceFormModel = ref({});
function openDeviceForm(d) { deviceFormModel.value = d; showDevice.value = true; }
function closeDevice() { showDevice.value = false; }
async function onDeviceSaved() { showDevice.value = false; await fetchDevices(); }
// Import CSV modal
const showImport = ref(false);
const importKind = ref('users');
const importText = ref('');
const importFilename = ref('');
const importError = ref('');
function openImport(kind){ importKind.value = kind; importText.value=''; showImport.value = true; }
function closeImport(){ showImport.value = false; }
async function submitImport(){
importError.value = '';
const txt = (importText.value || '').trim();
if (!txt) { importError.value = 'El CSV está vacío.'; return; }
// Basic header validation
const headers = getCsvHeaders(txt);
if (!headers.length) { importError.value = 'No se encontraron columnas en el CSV.'; return; }
const need = (k)=>headers.includes(k);
if (importKind.value === 'users') {
const missing = ['username','password'].filter(c=>!need(c));
if (missing.length) { importError.value = `CSV de usuarios requiere columnas: username,password. Faltantes: ${missing.join(', ')}`; return; }
} else if (importKind.value === 'devices') {
const missing = ['mac'].filter(c=>!need(c));
if (missing.length) { importError.value = `CSV de dispositivos requiere columna: mac.`; return; }
} else if (importKind.value === 'vlans') {
const missing = ['id'].filter(c=>!need(c));
if (missing.length) { importError.value = `CSV de VLANs requiere columna: id.`; return; }
}
try {
const ep = importKind.value === 'users' ? '/api/users/import' : importKind.value === 'devices' ? '/api/devices/import' : '/api/vlans/import';
await fetch(ep, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ csv: importText.value }) });
showImport.value = false;
await Promise.all([fetchUsers(), fetchDevices()]);
} catch {}
}
function onImportFile(e){
const f = e.target.files && e.target.files[0];
if (!f) return;
importFilename.value = f.name;
const reader = new FileReader();
reader.onload = () => { importText.value = String(reader.result || ''); };
reader.readAsText(f, 'utf-8');
e.target.value = '';
}
function getCsvHeaders(text){
const lines = text.split(/\r?\n/).filter(l=>l.trim().length>0);
if (!lines.length) return [];
const line = lines[0];
const out=[]; let cur=''; let q=false;
for (let i=0;i<line.length;i++){
const ch=line[i];
if (ch==='"'){
if (q && line[i+1]==='"'){ cur+='"'; i++; }
else { q=!q; }
} else if (ch===',' && !q){ out.push(cur.trim().toLowerCase()); cur=''; }
else { cur+=ch; }
}
out.push(cur.trim().toLowerCase());
return out;
}
function openSettings() { showSettingsMenu.value = !showSettingsMenu.value; }
function openEditUser(u) {
userFormMode.value = 'edit';

View File

@@ -104,6 +104,136 @@ router.get('/requests.csv', (_req, res) => {
res.send(csv);
});
// Export CSV: users, devices, vlans
router.get('/users.csv', async (_req, res) => {
const items = await readUsersFromDb();
const cols = ['username','password','vlan','disabled','etiquetas','created_at','updated_at','habilitado_since'];
const esc = (v) => {
const s = v == null ? '' : Array.isArray(v) ? v.join(';') : String(v);
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
const lines = [cols.join(',')];
for (const u of items) {
lines.push([u.username,u.password,u.vlan,u.disabled ? 'true':'false',u.etiquetas||[],u.created_at||'',u.updated_at||'',u.habilitado_since||''].map(esc).join(','));
}
res.setHeader('Content-Type','text/csv; charset=utf-8');
res.setHeader('Content-Disposition','attachment; filename="users.csv"');
res.send(lines.join('\n'));
});
router.get('/devices.csv', async (_req, res) => {
const r = await pool.query('SELECT id,mac,nombre,descripcion,vendor,first_seen,last_seen FROM dispositivos ORDER BY id ASC');
const cols = ['id','mac','nombre','descripcion','vendor','first_seen','last_seen'];
const esc = (v) => {
const s = v == null ? '' : String(v);
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
const lines = [cols.join(',')];
for (const d of r.rows) lines.push(cols.map(c => esc(d[c])).join(','));
res.setHeader('Content-Type','text/csv; charset=utf-8');
res.setHeader('Content-Disposition','attachment; filename="devices.csv"');
res.send(lines.join('\n'));
});
router.get('/vlans.csv', async (_req, res) => {
const r = await pool.query('SELECT id,nombre,descripcion FROM vlans ORDER BY id ASC');
const cols = ['id','nombre','descripcion'];
const esc = (v) => {
const s = v == null ? '' : String(v);
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
const lines = [cols.join(',')];
for (const d of r.rows) lines.push(cols.map(c => esc(d[c])).join(','));
res.setHeader('Content-Type','text/csv; charset=utf-8');
res.setHeader('Content-Disposition','attachment; filename="vlans.csv"');
res.send(lines.join('\n'));
});
// Import CSV: expect JSON { csv: string }
function parseCsv(str){
const rows=[]; let i=0, cur='', row=[], inq=false; for(; i<str.length; i++){ const ch=str[i]; if(ch==='"'){ if(inq && str[i+1]==='"'){ cur+='"'; i++; } else { inq=!inq; } } else if(ch===',' && !inq){ row.push(cur); cur=''; } else if((ch==='\n' || ch==='\r') && !inq){ if(ch==='\r' && str[i+1]==='\n') i++; row.push(cur); rows.push(row); row=[]; cur=''; } else { cur+=ch; } } if(cur.length||row.length) { row.push(cur); rows.push(row); } return rows; }
router.post('/users/import', async (req, res) => {
try {
const csv = String((req.body && req.body.csv) || '');
const rows = parseCsv(csv).filter(r => r.length>1);
if (rows.length === 0) return res.json({ ok: true, count: 0 });
const header = rows[0].map(s=>s.trim().toLowerCase());
const idx = (k) => header.indexOf(k);
let count=0;
for (let r of rows.slice(1)) {
const user = {
username: r[idx('username')]?.trim(),
password: r[idx('password')] ?? '',
vlan: r[idx('vlan')] ?? '',
disabled: /^(true|1|yes)$/i.test(r[idx('disabled')] || ''),
};
const tags = r[idx('etiquetas')] || '';
if (tags) user.etiquetas = String(tags).split(/[;,|]/).map(s=>s.trim()).filter(Boolean).slice(0,100);
if (user.username && user.password) { await upsertUserToDb(user); count++; }
}
res.json({ ok: true, count });
} catch (e) {
console.error('POST /api/users/import error:', e?.message || e);
res.status(500).json({ ok: false, error: 'import_error' });
}
});
router.post('/devices/import', async (req, res) => {
try {
const csv = String((req.body && req.body.csv) || '');
const rows = parseCsv(csv).filter(r => r.length>1);
if (rows.length === 0) return res.json({ ok: true, count: 0 });
const header = rows[0].map(s=>s.trim().toLowerCase());
const idx = (k) => header.indexOf(k);
let count=0;
for (let r of rows.slice(1)) {
const mac = r[idx('mac')]?.trim();
if (!mac) continue;
const nombre = r[idx('nombre')] || null;
const descripcion = r[idx('descripcion')] || null;
const vendor = r[idx('vendor')] || null;
await pool.query(
`INSERT INTO dispositivos (mac,nombre,descripcion,vendor) VALUES ($1,$2,$3,$4)
ON CONFLICT (mac) DO UPDATE SET nombre=EXCLUDED.nombre, descripcion=EXCLUDED.descripcion, vendor=EXCLUDED.vendor, last_seen=NOW()`,
[mac, nombre, descripcion, vendor]
);
count++;
}
res.json({ ok: true, count });
} catch (e) {
console.error('POST /api/devices/import error:', e?.message || e);
res.status(500).json({ ok: false, error: 'import_error' });
}
});
router.post('/vlans/import', async (req, res) => {
try {
const csv = String((req.body && req.body.csv) || '');
const rows = parseCsv(csv).filter(r => r.length>1);
if (rows.length === 0) return res.json({ ok: true, count: 0 });
const header = rows[0].map(s=>s.trim().toLowerCase());
const idx = (k) => header.indexOf(k);
let count=0;
for (let r of rows.slice(1)) {
const id = parseInt(r[idx('id')]||'',10);
if (!Number.isInteger(id)) continue;
const nombre = r[idx('nombre')] || null;
const descripcion = r[idx('descripcion')] || null;
await pool.query(
`INSERT INTO vlans (id,nombre,descripcion) VALUES ($1,$2,$3)
ON CONFLICT (id) DO UPDATE SET nombre=EXCLUDED.nombre, descripcion=EXCLUDED.descripcion`,
[id, nombre, descripcion]
);
count++;
}
res.json({ ok: true, count });
} catch (e) {
console.error('POST /api/vlans/import error:', e?.message || e);
res.status(500).json({ ok: false, error: 'import_error' });
}
});
// SSE events
router.get('/events', (req, res) => {
registerSse(req, res, {});