app V1 completada
This commit is contained in:
47
AGENTS.md
Normal file
47
AGENTS.md
Normal 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.
|
||||||
|
|
||||||
@@ -19,6 +19,13 @@
|
|||||||
<div v-if="showSettingsMenu" class="menu">
|
<div v-if="showSettingsMenu" class="menu">
|
||||||
<button @click="openRawDb">ver rawDB</button>
|
<button @click="openRawDb">ver rawDB</button>
|
||||||
<button @click="openVlanForm">crear VLAN</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,6 +37,9 @@
|
|||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<div class="panel-title">Eventos FreeRADIUS</div>
|
<div class="panel-title">Eventos FreeRADIUS</div>
|
||||||
<div class="panel-actions">
|
<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="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="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>
|
<button class="icon-btn" title="Test" @click="selfTest"><img class="icon" src="/icons/test.svg" alt="test"></button>
|
||||||
@@ -40,7 +50,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="scroll">
|
<div class="scroll">
|
||||||
<div v-if="loading.requests" class="muted">Cargando eventos…</div>
|
<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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -50,6 +60,10 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="panel-title">Usuarios y Dispositivos</div>
|
<div class="panel-title">Usuarios y Dispositivos</div>
|
||||||
<span class="spacer"></span>
|
<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'">
|
<button class="icon-btn" title="Vista usuarios" @click="layoutMode='user'">
|
||||||
<img class="icon" src="/icons/layout-users.svg" alt="usuarios"/> Usuarios
|
<img class="icon" src="/icons/layout-users.svg" alt="usuarios"/> Usuarios
|
||||||
</button>
|
</button>
|
||||||
@@ -71,7 +85,7 @@
|
|||||||
@disconnect="disconnectUser" @edit-device="openDeviceForm" />
|
@disconnect="disconnectUser" @edit-device="openDeviceForm" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="grid">
|
<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]"
|
:expanded="!!deviceExpanded[d.id]"
|
||||||
@toggle-expand="deviceExpanded[d.id] = !deviceExpanded[d.id]"
|
@toggle-expand="deviceExpanded[d.id] = !deviceExpanded[d.id]"
|
||||||
@edit="openDeviceForm" @disconnect="disconnectDevice" />
|
@edit="openDeviceForm" @disconnect="disconnectDevice" />
|
||||||
@@ -123,6 +137,22 @@
|
|||||||
<Modal :open="showDevice" title="Editar dispositivo" @close="closeDevice">
|
<Modal :open="showDevice" title="Editar dispositivo" @close="closeDevice">
|
||||||
<DeviceForm :model="deviceFormModel" @success="onDeviceSaved" @cancel="closeDevice" />
|
<DeviceForm :model="deviceFormModel" @success="onDeviceSaved" @cancel="closeDevice" />
|
||||||
</Modal>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -273,7 +303,7 @@ const filteredRequests = computed(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredUsers = computed(() => {
|
const filteredUsersAll = computed(() => {
|
||||||
return users.value.filter(u => {
|
return users.value.filter(u => {
|
||||||
if (userFilters.text && !u.username.toLowerCase().includes(userFilters.text.toLowerCase())) return false;
|
if (userFilters.text && !u.username.toLowerCase().includes(userFilters.text.toLowerCase())) return false;
|
||||||
if (userFilters.status === 'active' && u.disabled) return false;
|
if (userFilters.status === 'active' && u.disabled) return false;
|
||||||
@@ -281,6 +311,10 @@ const filteredUsers = computed(() => {
|
|||||||
return true;
|
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 devicesById = computed(() => {
|
||||||
const m = {};
|
const m = {};
|
||||||
@@ -292,6 +326,18 @@ function usersForDevice(id) {
|
|||||||
return users.value.filter(u => Array.isArray(u.dispositivos_utilizados) && u.dispositivos_utilizados.includes(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
|
// Persist expansion state
|
||||||
watch(userExpanded, (v) => {
|
watch(userExpanded, (v) => {
|
||||||
try { localStorage.setItem('ui_userExpanded', JSON.stringify(v)); } catch {}
|
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 openDeviceForm(d) { deviceFormModel.value = d; showDevice.value = true; }
|
||||||
function closeDevice() { showDevice.value = false; }
|
function closeDevice() { showDevice.value = false; }
|
||||||
async function onDeviceSaved() { showDevice.value = false; await fetchDevices(); }
|
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 openSettings() { showSettingsMenu.value = !showSettingsMenu.value; }
|
||||||
function openEditUser(u) {
|
function openEditUser(u) {
|
||||||
userFormMode.value = 'edit';
|
userFormMode.value = 'edit';
|
||||||
|
|||||||
@@ -104,6 +104,136 @@ router.get('/requests.csv', (_req, res) => {
|
|||||||
res.send(csv);
|
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
|
// SSE events
|
||||||
router.get('/events', (req, res) => {
|
router.get('/events', (req, res) => {
|
||||||
registerSse(req, res, {});
|
registerSse(req, res, {});
|
||||||
|
|||||||
Reference in New Issue
Block a user