app V1 completada
This commit is contained in:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user