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

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';