All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 58s
- Reemplazar CSS nativo con Tailwind CSS v4 y utilidades custom - Crear librería de componentes UI basada en shadcn-vue (Radix Vue) - Componentes UI: Button, Card, Input, Textarea, Badge, Dialog, Avatar, DropdownMenu - Migrar todos los componentes existentes a Tailwind utilities - Convertir EventCard.js (htm) a EventCard.vue (SFC) - Implementar sistema de temas dark/light con clase .dark - Mantener efectos glassmorphism via @utility custom - Eliminar styles.css legacy
570 lines
24 KiB
Vue
570 lines
24 KiB
Vue
<script setup>
|
|
import { onMounted, reactive, ref, computed, watch } from 'vue';
|
|
import { Button, Badge, Input, Card, CardHeader, CardTitle, CardActions, CardContent, Textarea } from '@/components/ui';
|
|
import EventCard from '@/components/EventCard.vue';
|
|
import UserCard from '@/components/UserCard.vue';
|
|
import DispositivoCard from '@/components/DispositivoCard.vue';
|
|
import Modal from '@/components/Modal.vue';
|
|
import UserForm from '@/components/UserForm.vue';
|
|
import RawDbViewer from '@/components/RawDbViewer.vue';
|
|
import VlanForm from '@/components/VlanForm.vue';
|
|
import DeviceForm from '@/components/DeviceForm.vue';
|
|
import Toast from '@/components/Toast.vue';
|
|
import UserDropdown from '@/components/auth/UserDropdown.vue';
|
|
import { createToastSystem, useToast } from '@/composables/useToast.js';
|
|
|
|
// Initialize toast system
|
|
createToastSystem();
|
|
const { toast } = useToast();
|
|
|
|
const users = ref([]);
|
|
const requests = ref([]);
|
|
const loading = reactive({ users: false, requests: false });
|
|
const devices = ref([]);
|
|
const userExpanded = reactive({});
|
|
const deviceExpanded = reactive({});
|
|
|
|
// Helper para detectar errores de autenticación
|
|
function isAuthError(error) {
|
|
return error instanceof TypeError && error.message.includes('fetch');
|
|
}
|
|
|
|
function handleAuthError() {
|
|
console.warn('Sesión expirada o error de autenticación, redirigiendo...');
|
|
window.location.reload();
|
|
}
|
|
|
|
const showEventFilters = ref(false);
|
|
const showUserFilters = ref(false);
|
|
const eventFilters = reactive({ text: '', type: '' });
|
|
const userFilters = reactive({ text: '', status: '' });
|
|
const sidebarCollapsed = ref(false);
|
|
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;
|
|
try {
|
|
const res = await fetch('/api/users');
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
const data = await res.json();
|
|
users.value = data.items || [];
|
|
} catch (error) {
|
|
if (isAuthError(error)) handleAuthError();
|
|
else console.error('Error fetching users:', error);
|
|
} finally { loading.users = false; }
|
|
}
|
|
|
|
async function fetchRequests() {
|
|
loading.requests = true;
|
|
try {
|
|
const res = await fetch('/api/requests');
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
const data = await res.json();
|
|
requests.value = data.items || [];
|
|
} catch (error) {
|
|
if (isAuthError(error)) handleAuthError();
|
|
else console.error('Error fetching requests:', error);
|
|
} finally { loading.requests = false; }
|
|
}
|
|
|
|
async function fetchDevices() {
|
|
try {
|
|
const res = await fetch('/api/devices');
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
const data = await res.json();
|
|
devices.value = data.items || [];
|
|
} catch (error) {
|
|
if (isAuthError(error)) handleAuthError();
|
|
else console.error('Error fetching devices:', error);
|
|
}
|
|
}
|
|
|
|
async function toggleDisable(u) {
|
|
await fetch(`/api/users/${encodeURIComponent(u.username)}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ disabled: !u.disabled })
|
|
});
|
|
await fetchUsers();
|
|
}
|
|
|
|
async function removeUser(u) {
|
|
if (!confirm(`Eliminar ${u.username}?`)) return;
|
|
await fetch(`/api/users/${encodeURIComponent(u.username)}`, { method: 'DELETE' });
|
|
await fetchUsers();
|
|
}
|
|
|
|
async function clearRequests() {
|
|
await fetch('/api/requests', { method: 'DELETE' });
|
|
await fetchRequests();
|
|
}
|
|
|
|
async function selfTest() {
|
|
await fetch('/test/radius', { method: 'POST' });
|
|
}
|
|
|
|
async function disconnectUser(u) {
|
|
try {
|
|
await fetch(`/api/users/${encodeURIComponent(u.username)}/disconnect`, { method: 'POST' });
|
|
await Promise.all([fetchUsers(), fetchDevices()]);
|
|
} catch {}
|
|
}
|
|
|
|
async function disconnectDevice(d) {
|
|
try {
|
|
await fetch(`/api/devices/${encodeURIComponent(d.id)}/disconnect`, { method: 'POST' });
|
|
await Promise.all([fetchUsers(), fetchDevices()]);
|
|
} catch {}
|
|
}
|
|
|
|
function setupSse() {
|
|
const ev = new EventSource('/api/events');
|
|
let refreshTimer = null;
|
|
function scheduleRefresh() {
|
|
if (refreshTimer) clearTimeout(refreshTimer);
|
|
refreshTimer = setTimeout(async () => {
|
|
await Promise.all([fetchUsers(), fetchDevices()]);
|
|
refreshTimer = null;
|
|
}, 1000);
|
|
}
|
|
ev.addEventListener('message', (e) => {
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
if (data && data.ts) requests.value.push(data);
|
|
const t = data && data.type;
|
|
if (t === 'authorize' || t === 'post-auth' || t === 'accounting' || t === 'coa-disconnect') {
|
|
scheduleRefresh();
|
|
}
|
|
} catch {}
|
|
});
|
|
ev.addEventListener('clear', () => { requests.value = []; });
|
|
}
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const ue = JSON.parse(localStorage.getItem('ui_userExpanded') || '{}');
|
|
Object.assign(userExpanded, ue && typeof ue === 'object' ? ue : {});
|
|
} catch {}
|
|
try {
|
|
const de = JSON.parse(localStorage.getItem('ui_deviceExpanded') || '{}');
|
|
Object.assign(deviceExpanded, de && typeof de === 'object' ? de : {});
|
|
} catch {}
|
|
await fetchUsers();
|
|
await fetchDevices();
|
|
await fetchRequests();
|
|
setupSse();
|
|
applyTheme();
|
|
checkPWAStatus();
|
|
});
|
|
|
|
function checkPWAStatus() {
|
|
if (localStorage.getItem('pwa_toast_dismissed')) return;
|
|
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
|
|
if (!isStandalone) {
|
|
let deferredPrompt = null;
|
|
window.addEventListener('beforeinstallprompt', (e) => {
|
|
e.preventDefault();
|
|
deferredPrompt = e;
|
|
setTimeout(() => {
|
|
toast.pwa('Instala RADIUS Nucleo como aplicación para una mejor experiencia', {
|
|
title: '📱 Instalar Aplicación',
|
|
position: 'top-center',
|
|
duration: 0,
|
|
persistent: true,
|
|
action: {
|
|
label: 'Instalar',
|
|
handler: async () => {
|
|
if (deferredPrompt) {
|
|
deferredPrompt.prompt();
|
|
const { outcome } = await deferredPrompt.userChoice;
|
|
if (outcome === 'accepted') localStorage.setItem('pwa_toast_dismissed', 'true');
|
|
deferredPrompt = null;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}, 2000);
|
|
});
|
|
}
|
|
}
|
|
|
|
const filteredRequests = computed(() => {
|
|
return requests.value.filter(ev => {
|
|
if (eventFilters.type && ev.type !== eventFilters.type) return false;
|
|
if (eventFilters.text) {
|
|
const t = eventFilters.text.toLowerCase();
|
|
const blob = JSON.stringify(ev).toLowerCase();
|
|
if (!blob.includes(t)) return false;
|
|
}
|
|
return true;
|
|
});
|
|
});
|
|
|
|
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;
|
|
if (userFilters.status === 'disabled' && !u.disabled) return false;
|
|
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 = {};
|
|
for (const d of devices.value) m[d.id] = d;
|
|
return m;
|
|
});
|
|
|
|
function usersForDevice(id) {
|
|
return users.value.filter(u => Array.isArray(u.dispositivos_utilizados) && u.dispositivos_utilizados.includes(id));
|
|
}
|
|
|
|
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; });
|
|
|
|
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; });
|
|
|
|
watch(userExpanded, (v) => {
|
|
try { localStorage.setItem('ui_userExpanded', JSON.stringify(v)); } catch {}
|
|
}, { deep: true });
|
|
watch(deviceExpanded, (v) => {
|
|
try { localStorage.setItem('ui_deviceExpanded', JSON.stringify(v)); } catch {}
|
|
}, { deep: true });
|
|
|
|
function copyRequests() {
|
|
const txt = JSON.stringify(requests.value, null, 2);
|
|
navigator.clipboard?.writeText(txt);
|
|
}
|
|
|
|
function toggleTheme() {
|
|
theme.value = theme.value === 'light' ? 'dark' : 'light';
|
|
localStorage.setItem('theme', theme.value);
|
|
applyTheme();
|
|
}
|
|
function applyTheme() {
|
|
document.documentElement.classList.toggle('dark', theme.value === 'dark');
|
|
}
|
|
|
|
const showUserForm = ref(false);
|
|
const userFormMode = ref('create');
|
|
const userFormModel = ref({ username:'', password:'', vlan:'', disabled:false });
|
|
|
|
function openAddUser() {
|
|
userFormMode.value = 'create';
|
|
userFormModel.value = { username:'', password:'', vlan:'', disabled:false };
|
|
showUserForm.value = true;
|
|
}
|
|
function openAddGuest() {
|
|
userFormMode.value = 'guest';
|
|
userFormModel.value = { username:'', password:'', vlan:'5', disabled:false, etiquetas: ['invitado'] };
|
|
showUserForm.value = true;
|
|
}
|
|
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; }
|
|
const showVlan = ref(false);
|
|
function openVlanForm() { showSettingsMenu.value = false; showVlan.value = true; }
|
|
function closeVlan() { showVlan.value = false; }
|
|
function onVlanCreated() { showVlan.value = false; }
|
|
const showDevice = ref(false);
|
|
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(); }
|
|
|
|
const showImport = ref(false);
|
|
const importKind = ref('users');
|
|
const importText = ref('');
|
|
const importFilename = ref('');
|
|
const importError = ref('');
|
|
const importFile = ref(null);
|
|
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; }
|
|
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 openEditUser(u) {
|
|
userFormMode.value = 'edit';
|
|
userFormModel.value = { username: u.username, password: u.password || '', vlan: u.vlan || '', disabled: !!u.disabled };
|
|
showUserForm.value = true;
|
|
}
|
|
|
|
async function handleUserFormSubmit(data) {
|
|
if (userFormMode.value === 'edit') {
|
|
await fetch(`/api/users/${encodeURIComponent(userFormModel.value.username)}`, {
|
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data)
|
|
});
|
|
} else {
|
|
await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
|
}
|
|
await fetchUsers();
|
|
showUserForm.value = false;
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<!-- Top Bar -->
|
|
<header class="sticky top-0 z-10 flex flex-wrap items-center gap-2.5 p-3 glass backdrop-blur-[14px] border-b border-pink-600/50 dark:border-pink-600/50 bg-gradient-to-r from-pink-600/20 via-transparent to-transparent">
|
|
<div class="text-base font-bold tracking-wide flex-1">RADIUS Nucleo</div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<Button as="a" href="https://inicio.nucleoriofrio.com" variant="ghost">
|
|
🏠 Inicio
|
|
</Button>
|
|
<Button variant="ghost" size="icon" @click="toggleTheme">
|
|
<img class="size-4 opacity-90" :src="theme === 'light' ? '/icons/moon.svg' : '/icons/sun.svg'" alt="theme">
|
|
</Button>
|
|
<Badge><span class="text-muted">Estado:</span> {{ statusText }}</Badge>
|
|
<Button @click="openAddUser">
|
|
<img class="size-4 opacity-90" src="/icons/user-plus.svg" alt="usuario"> Usuario
|
|
</Button>
|
|
<Button @click="openAddGuest">
|
|
<img class="size-4 opacity-90" src="/icons/guest.svg" alt="invitado"> Invitado
|
|
</Button>
|
|
<UserDropdown />
|
|
<div class="relative">
|
|
<Button @click="toggleSettingsMenu">
|
|
<img class="size-4 opacity-90" src="/icons/settings.svg" alt="config"> Configuración
|
|
</Button>
|
|
<div v-if="showSettingsMenu" class="absolute right-0 top-full mt-1.5 glass-card p-1.5 min-w-[200px] shadow-lg border border-pink-200 dark:border-pink-600/50 z-50 space-y-1">
|
|
<Button variant="ghost" class="w-full justify-start" @click="openRawDb">ver rawDB</Button>
|
|
<Button variant="ghost" class="w-full justify-start" @click="openVlanForm">crear VLAN</Button>
|
|
<hr class="border-border my-1" />
|
|
<Button as="a" variant="ghost" class="w-full justify-start" href="/api/users.csv">Exportar usuarios CSV</Button>
|
|
<Button as="a" variant="ghost" class="w-full justify-start" href="/api/devices.csv">Exportar dispositivos CSV</Button>
|
|
<Button as="a" variant="ghost" class="w-full justify-start" href="/api/vlans.csv">Exportar VLANs CSV</Button>
|
|
<Button variant="ghost" class="w-full justify-start" @click="openImport('users')">Importar usuarios CSV</Button>
|
|
<Button variant="ghost" class="w-full justify-start" @click="openImport('devices')">Importar dispositivos CSV</Button>
|
|
<Button variant="ghost" class="w-full justify-start" @click="openImport('vlans')">Importar VLANs CSV</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Main Layout -->
|
|
<section class="h-[calc(100vh-54px)] grid grid-cols-[360px_1fr] gap-3 p-3 max-md:grid-cols-1 max-md:grid-rows-[auto_1fr]"
|
|
:class="{ 'grid-cols-[52px_1fr]': sidebarCollapsed && !mainCollapsed, 'grid-cols-[1fr_52px]': mainCollapsed && !sidebarCollapsed }">
|
|
|
|
<!-- Sidebar: Eventos -->
|
|
<aside>
|
|
<Card variant="panel" class="h-full border-pink-200 dark:border-pink-600/50" :class="{ 'panel-collapsed': sidebarCollapsed }">
|
|
<CardHeader>
|
|
<CardTitle>Eventos FreeRADIUS</CardTitle>
|
|
<CardActions>
|
|
<Badge>Página {{ reqPage+1 }} / {{ Math.max(1, Math.ceil(filteredRequestsAll.length / pageSize)) }}</Badge>
|
|
<Button size="sm" @click="reqPage=Math.max(0, reqPage-1)">Anterior</Button>
|
|
<Button size="sm" @click="reqPage=Math.min(Math.ceil(filteredRequestsAll.length/pageSize)-1, reqPage+1)">Siguiente</Button>
|
|
<Button size="sm" variant="ghost" title="Filtrar" @click="showEventFilters = true">
|
|
<img class="size-4 opacity-90" src="/icons/filter.svg" alt="filtrar">
|
|
</Button>
|
|
<Button size="sm" variant="ghost" title="Limpiar" @click="clearRequests">
|
|
<img class="size-4 opacity-90" src="/icons/clear.svg" alt="limpiar">
|
|
</Button>
|
|
<Button size="sm" variant="ghost" title="Test" @click="selfTest">
|
|
<img class="size-4 opacity-90" src="/icons/test.svg" alt="test">
|
|
</Button>
|
|
<Button as="a" size="sm" variant="ghost" title="Descargar" href="/api/requests.csv" target="_blank">
|
|
<img class="size-4 opacity-90" src="/icons/download.svg" alt="descargar">
|
|
</Button>
|
|
<Button size="sm" variant="ghost" title="Copiar" @click="copyRequests">
|
|
<img class="size-4 opacity-90" src="/icons/copy.svg" alt="copiar">
|
|
</Button>
|
|
<Button size="sm" variant="ghost" class="hidden max-md:inline-flex" title="Colapsar" @click="sidebarCollapsed = !sidebarCollapsed">
|
|
{{ sidebarCollapsed ? 'Expandir' : 'Colapsar' }}
|
|
</Button>
|
|
</CardActions>
|
|
</CardHeader>
|
|
<CardContent v-if="!sidebarCollapsed" class="flex-1 overflow-auto scroll-custom space-y-2">
|
|
<div v-if="loading.requests" class="text-muted">Cargando eventos…</div>
|
|
<EventCard v-for="ev in pagedRequests" :key="ev.id" :ev="ev" />
|
|
</CardContent>
|
|
</Card>
|
|
</aside>
|
|
|
|
<!-- Main: Usuarios -->
|
|
<main>
|
|
<Card variant="panel" class="h-full border-pink-200 dark:border-pink-600/50" :class="{ 'panel-collapsed': mainCollapsed }">
|
|
<CardHeader>
|
|
<div class="flex flex-wrap items-center gap-2 w-full">
|
|
<CardTitle>Usuarios y Dispositivos</CardTitle>
|
|
<span class="flex-1"></span>
|
|
<Badge v-if="layoutMode==='user'">Página {{ userPage+1 }} / {{ Math.max(1, Math.ceil(filteredUsersAll.length / pageSize)) }}</Badge>
|
|
<Badge v-else>Página {{ devicePage+1 }} / {{ Math.max(1, Math.ceil(devicesAll.length / pageSize)) }}</Badge>
|
|
<Button size="sm" @click="layoutMode==='user' ? (userPage=Math.max(0,userPage-1)) : (devicePage=Math.max(0,devicePage-1))">Anterior</Button>
|
|
<Button size="sm" @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 size="sm" :variant="layoutMode==='user' ? 'default' : 'ghost'" title="Vista usuarios" @click="layoutMode='user'">
|
|
<img class="size-4 opacity-90" src="/icons/layout-users.svg" alt="usuarios"/> Usuarios
|
|
</Button>
|
|
<Button size="sm" :variant="layoutMode==='device' ? 'default' : 'ghost'" title="Vista dispositivos" @click="layoutMode='device'">
|
|
<img class="size-4 opacity-90" src="/icons/layout-devices.svg" alt="dispositivos"/> Dispositivos
|
|
</Button>
|
|
<Button size="sm" variant="ghost" title="Filtrar" @click="showUserFilters = true">
|
|
<img class="size-4 opacity-90" src="/icons/filter.svg" alt="filtro"/>
|
|
</Button>
|
|
<Button size="sm" variant="ghost" class="hidden max-md:inline-flex" title="Colapsar" @click="mainCollapsed = !mainCollapsed">
|
|
{{ mainCollapsed ? 'Expandir' : 'Colapsar' }}
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent v-if="!mainCollapsed" class="flex-1 overflow-auto scroll-custom">
|
|
<div v-if="loading.users" class="text-muted">Cargando usuarios…</div>
|
|
<template v-else>
|
|
<div v-if="layoutMode==='user'" class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-2.5">
|
|
<UserCard v-for="u in filteredUsers" :key="u.username" :item="u" :devicesById="devicesById"
|
|
:expanded="!!userExpanded[u.username]"
|
|
@toggle-expand="userExpanded[u.username] = !userExpanded[u.username]"
|
|
@toggleDisable="toggleDisable" @remove="removeUser" @edit="openEditUser"
|
|
@disconnect="disconnectUser" @edit-device="openDeviceForm" />
|
|
</div>
|
|
<div v-else class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-2.5">
|
|
<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" />
|
|
</div>
|
|
</template>
|
|
</CardContent>
|
|
</Card>
|
|
</main>
|
|
</section>
|
|
|
|
<!-- Modals -->
|
|
<Modal :open="showEventFilters" title="Filtros de eventos" @close="showEventFilters=false">
|
|
<div class="flex flex-wrap gap-2 my-2">
|
|
<Input v-model="eventFilters.text" placeholder="Buscar texto" class="flex-1" />
|
|
<select v-model="eventFilters.type" class="glass glass-border rounded-md px-3 py-2">
|
|
<option value="">Todos</option>
|
|
<option value="authorize">authorize</option>
|
|
<option value="accounting">accounting</option>
|
|
<option value="post-auth">post-auth</option>
|
|
<option value="selftest">selftest</option>
|
|
<option value="coa-disconnect">coa-disconnect</option>
|
|
</select>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal :open="showUserFilters" title="Filtros de usuarios" @close="showUserFilters=false">
|
|
<div class="flex flex-wrap gap-2 my-2">
|
|
<Input v-model="userFilters.text" placeholder="Buscar usuario" class="flex-1" />
|
|
<select v-model="userFilters.status" class="glass glass-border rounded-md px-3 py-2">
|
|
<option value="">Todos</option>
|
|
<option value="active">Activos</option>
|
|
<option value="disabled">Deshabilitados</option>
|
|
</select>
|
|
</div>
|
|
</Modal>
|
|
|
|
<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>
|
|
|
|
<Modal :open="showRawDb" :fullscreen="rawDbFullscreen" title="Raw DB Viewer" @close="closeRawDb">
|
|
<RawDbViewer @toggle-fullscreen="rawDbFullscreen = !rawDbFullscreen" />
|
|
</Modal>
|
|
|
|
<Modal :open="showVlan" title="Crear VLAN" @close="closeVlan">
|
|
<VlanForm @success="onVlanCreated" @cancel="closeVlan" />
|
|
</Modal>
|
|
|
|
<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="flex flex-col gap-2">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<input ref="importFile" type="file" accept=".csv,text/csv" @change="onImportFile" class="hidden" />
|
|
<Button @click="importFile?.click()">Seleccionar archivo CSV</Button>
|
|
<span v-if="importFilename" class="text-muted text-sm">{{ importFilename }}</span>
|
|
</div>
|
|
<Textarea
|
|
v-model="importText"
|
|
:rows="10"
|
|
placeholder="Pega CSV aquí o selecciona un archivo"
|
|
/>
|
|
<div v-if="importError" class="text-sm text-red-400">{{ importError }}</div>
|
|
<div class="flex justify-end gap-2 mt-2">
|
|
<Button variant="ghost" @click="closeImport">Cancelar</Button>
|
|
<Button @click="submitImport">Importar</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<!-- Toast System -->
|
|
<Toast position="top-center" />
|
|
</template>
|
|
|
|
<style>
|
|
/* Panel collapsed state */
|
|
.panel-collapsed .scroll-custom,
|
|
.panel-collapsed [class*="CardContent"] {
|
|
display: none;
|
|
}
|
|
</style>
|