frontend actualizado y mejorado extremadamente
7
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "radius-frontend",
|
"name": "radius-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"htm": "^3.1.1",
|
||||||
"vue": "^3.4.38"
|
"vue": "^3.4.38"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -965,6 +966,12 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/htm": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.19",
|
"version": "0.30.19",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.4.38"
|
"vue": "^3.4.38",
|
||||||
|
"htm": "^3.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.5",
|
"@vitejs/plugin-vue": "^5.0.5",
|
||||||
"vite": "^5.4.8"
|
"vite": "^5.4.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
frontend/public/icons/clear.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19a2 2 0 002 2h8a2 2 0 002-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||||
|
After Width: | Height: | Size: 171 B |
1
frontend/public/icons/copy.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
||||||
|
After Width: | Height: | Size: 226 B |
1
frontend/public/icons/download.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5 20h14v-2H5v2zM11 3h2v8h3l-4 4-4-4h3V3z"/></svg>
|
||||||
|
After Width: | Height: | Size: 140 B |
1
frontend/public/icons/filter.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M3 5h18l-7 8v6l-4-2v-4L3 5z"/></svg>
|
||||||
|
After Width: | Height: | Size: 126 B |
1
frontend/public/icons/guest.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.76 0 5-2.24 5-5S14.76 2 12 2 7 4.24 7 7s2.24 5 5 5zm0 2c-4 0-10 2-10 6v2h20v-2c0-4-6-6-10-6z"/></svg>
|
||||||
|
After Width: | Height: | Size: 200 B |
1
frontend/public/icons/layout-devices.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M4 6h18v10H4V6zm-2 12h22v2H2v-2zm4-10v6h14V8H6z"/></svg>
|
||||||
|
After Width: | Height: | Size: 146 B |
1
frontend/public/icons/layout-users.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5C18 14.17 13.33 13 11 13zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.95 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
|
||||||
|
After Width: | Height: | Size: 376 B |
1
frontend/public/icons/moon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M21 12.79A9 9 0 1111.21 3a7 7 0 109.79 9.79z"/></svg>
|
||||||
|
After Width: | Height: | Size: 143 B |
1
frontend/public/icons/settings.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94a7.43 7.43 0 000-1.88l2.03-1.58a.5.5 0 00.12-.65l-1.92-3.32a.5.5 0 00-.6-.22l-2.39.96a7.36 7.36 0 00-1.63-.95L14.5 1.5a.5.5 0 00-.5-.5h-4a.5.5 0 00-.5.5l-.35 2.8a7.36 7.36 0 00-1.63.95l-2.39-.96a.5.5 0 00-.6.22L2.61 8.83a.5.5 0 00.12.65l2.03 1.58a7.43 7.43 0 000 1.88l-2.03 1.58a.5.5 0 00-.12.65l1.92 3.32a.5.5 0 00.6.22l2.39-.96c.5.39 1.05.71 1.63.95l.35 2.8a.5.5 0 00.5.5h4a.5.5 0 00.5-.5l.35-2.8c.58-.24 1.13-.56 1.63-.95l2.39.96a.5.5 0 00.6-.22l1.92-3.32a.5.5 0 00-.12-.65l-2.03-1.58zM12 15.5A3.5 3.5 0 1112 8a3.5 3.5 0 010 7.5z"/></svg>
|
||||||
|
After Width: | Height: | Size: 643 B |
1
frontend/public/icons/sun.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M6.76 4.84l-1.8-1.79-1.41 1.41 1.79 1.8 1.42-1.42zm10.45 14.32l1.79 1.8 1.41-1.41-1.8-1.79-1.4 1.4zM12 4V1h-1v3h1zm0 19v-3h-1v3h1zm8-8h3v-1h-3v1zM1 12H4v-1H1v1zm15.24-7.16l1.42 1.42 1.79-1.8-1.41-1.41-1.8 1.79zM4.22 18.36l1.42-1.42-1.8-1.79-1.41 1.41 1.79 1.8zM12 7a5 5 0 100 10 5 5 0 000-10z"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
frontend/public/icons/test.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h8v-2H3v2zm0-6h14V5H3v2zm0 12h18v-2H3v2z"/></svg>
|
||||||
|
After Width: | Height: | Size: 144 B |
1
frontend/public/icons/user-plus.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M15 12c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zM1 21v-2c0-2.76 5.82-4 9-4s9 1.24 9 4v2H1zM23 8h-2V6h-2V4h2V2h2v2h2v2h-2v2z"/></svg>
|
||||||
|
After Width: | Height: | Size: 233 B |
@@ -1,73 +1,124 @@
|
|||||||
<template>
|
<template>
|
||||||
<main style="font-family: system-ui, sans-serif; padding: 16px; max-width: 980px; margin: 0 auto;">
|
<header class="topbar">
|
||||||
<h1>RADIUS Dashboard</h1>
|
<div class="title">RADIUS Nucleo</div>
|
||||||
<section style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start;">
|
<div class="actions">
|
||||||
<div>
|
<button class="icon-btn" @click="toggleTheme">
|
||||||
<h2>Usuarios</h2>
|
<img class="icon" :src="theme === 'light' ? '/icons/moon.svg' : '/icons/sun.svg'" alt="theme">
|
||||||
<form @submit.prevent="createUser" style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px;">
|
</button>
|
||||||
<input v-model="form.username" placeholder="usuario" required />
|
<span class="chip"><span class="muted">Estado:</span> {{ statusText }}</span>
|
||||||
<input v-model="form.password" placeholder="contraseña" required />
|
<button class="icon-btn" @click="openAddUser">
|
||||||
<input v-model="form.vlan" placeholder="VLAN" />
|
<img class="icon" src="/icons/user-plus.svg" alt="usuario"> Usuario
|
||||||
<label><input type="checkbox" v-model="form.disabled" /> deshabilitado</label>
|
</button>
|
||||||
<button type="submit">Crear / Actualizar</button>
|
<button class="icon-btn" @click="openAddGuest">
|
||||||
|
<img class="icon" src="/icons/guest.svg" alt="invitado"> Invitado
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" @click="openSettings">
|
||||||
|
<img class="icon" src="/icons/settings.svg" alt="config"> Configuración
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="shell">
|
||||||
|
<!-- Sidebar: Eventos -->
|
||||||
|
<aside class="panel" :class="{ collapsed: sidebarCollapsed }">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-title">Eventos FreeRADIUS</div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<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>
|
||||||
|
<a class="icon-btn" title="Descargar" href="/api/requests.csv" target="_blank"><img class="icon" src="/icons/download.svg" alt="descargar"></a>
|
||||||
|
<button class="icon-btn" title="Copiar" @click="copyRequests"><img class="icon" src="/icons/copy.svg" alt="copiar"></button>
|
||||||
|
<button class="icon-btn" title="Colapsar" @click="sidebarCollapsed = !sidebarCollapsed">{{ sidebarCollapsed ? 'Expandir' : 'Colapsar' }}</button>
|
||||||
|
</div>
|
||||||
|
</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" />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main: Usuarios -->
|
||||||
|
<main class="panel" :class="{ collapsed: mainCollapsed }">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="row">
|
||||||
|
<div class="panel-title">Usuarios y Dispositivos</div>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="icon-btn" title="Vista usuarios" @click="layoutMode='user'">
|
||||||
|
<img class="icon" src="/icons/layout-users.svg" alt="usuarios"/> Usuarios
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" title="Vista dispositivos" @click="layoutMode='device'">
|
||||||
|
<img class="icon" src="/icons/layout-devices.svg" alt="dispositivos"/> Dispositivos
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" title="Filtrar" @click="showUserFilters = true"><img class="icon" src="/icons/filter.svg" alt="filtro"/></button>
|
||||||
|
<button class="icon-btn" title="Colapsar" @click="mainCollapsed = !mainCollapsed">{{ mainCollapsed ? 'Expandir' : 'Colapsar' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scroll">
|
||||||
|
<form @submit.prevent="createUser" class="row" style="margin-bottom:10px;">
|
||||||
|
<input v-model="form.username" placeholder="usuario" required class="toggle"/>
|
||||||
|
<input v-model="form.password" placeholder="contraseña" required class="toggle"/>
|
||||||
|
<input v-model="form.vlan" placeholder="VLAN" class="toggle"/>
|
||||||
|
<label class="row toggle" style="gap:6px;"><input type="checkbox" v-model="form.disabled"/> deshabilitado</label>
|
||||||
|
<button type="submit" class="icon-btn">Crear / Actualizar</button>
|
||||||
</form>
|
</form>
|
||||||
<div v-if="loading.users">Cargando usuarios…</div>
|
<div v-if="loading.users" class="muted">Cargando usuarios…</div>
|
||||||
<table v-else style="width: 100%; border-collapse: collapse;">
|
<div v-else class="grid">
|
||||||
<thead>
|
<UserCard v-for="u in filteredUsers" :key="u.username" :item="u" :mode="layoutMode"
|
||||||
<tr>
|
@toggleDisable="toggleDisable" @remove="removeUser" />
|
||||||
<th style="text-align:left">Usuario</th>
|
|
||||||
<th style="text-align:left">VLAN</th>
|
|
||||||
<th style="text-align:left">Estado</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="u in users" :key="u.username">
|
|
||||||
<td>{{ u.username }}</td>
|
|
||||||
<td>{{ u.vlan }}</td>
|
|
||||||
<td>{{ u.disabled ? 'deshabilitado' : 'activo' }}</td>
|
|
||||||
<td>
|
|
||||||
<button @click="toggleDisable(u)">{{ u.disabled ? 'Habilitar' : 'Deshabilitar' }}</button>
|
|
||||||
<button @click="removeUser(u)" style="margin-left: 6px">Eliminar</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2>Eventos</h2>
|
|
||||||
<div style="margin-bottom: 8px; display:flex; gap:8px;">
|
|
||||||
<button @click="refreshRequests">Refrescar</button>
|
|
||||||
<button @click="clearRequests">Limpiar</button>
|
|
||||||
<button @click="selfTest">Self test</button>
|
|
||||||
<a :href="'/api/requests.csv'" target="_blank">Descargar CSV</a>
|
|
||||||
</div>
|
|
||||||
<div v-if="loading.requests">Cargando eventos…</div>
|
|
||||||
<div v-else style="max-height: 420px; overflow: auto; border: 1px solid #ddd; padding: 8px;">
|
|
||||||
<div v-for="ev in requests" :key="ev.id" style="border-bottom: 1px dashed #ddd; padding: 6px 0;">
|
|
||||||
<div><b>{{ ev.ts }}</b> — {{ ev.type }}</div>
|
|
||||||
<div v-if="ev.attrs" style="font-size: 12px; color: #444;">
|
|
||||||
<span>User: {{ ev.attrs['User-Name'] || ev.attrs['User-Name*0'] }}</span>
|
|
||||||
<span v-if="ev.attrs['NAS-IP-Address']"> — NAS: {{ ev.attrs['NAS-IP-Address'] }}</span>
|
|
||||||
<span v-if="ev.attrs['Calling-Station-Id']"> — STA: {{ ev.attrs['Calling-Station-Id'] }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="ev.decision">Decision: {{ ev.decision }}</div>
|
|
||||||
<div v-if="ev.error" style="color: #a00;">Error: {{ ev.error }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</main>
|
||||||
</main>
|
</section>
|
||||||
|
|
||||||
|
<!-- Modals -->
|
||||||
|
<Modal :open="showEventFilters" title="Filtros de eventos" @close="showEventFilters=false">
|
||||||
|
<div class="row" style="margin:8px 0;">
|
||||||
|
<input v-model="eventFilters.text" placeholder="Buscar texto" class="toggle" style="flex:1;"/>
|
||||||
|
<select v-model="eventFilters.type" class="toggle">
|
||||||
|
<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="row" style="margin:8px 0;">
|
||||||
|
<input v-model="userFilters.text" placeholder="Buscar usuario" class="toggle" style="flex:1;"/>
|
||||||
|
<select v-model="userFilters.status" class="toggle">
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="active">Activos</option>
|
||||||
|
<option value="disabled">Deshabilitados</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, reactive, ref } from 'vue';
|
import { onMounted, reactive, ref, computed } from 'vue';
|
||||||
|
import EventCard from './components/EventCard.js';
|
||||||
|
import UserCard from './components/UserCard.js';
|
||||||
|
import Modal from './components/Modal.vue';
|
||||||
|
|
||||||
const users = ref([]);
|
const users = ref([]);
|
||||||
const requests = ref([]);
|
const requests = ref([]);
|
||||||
const loading = reactive({ users: false, requests: false });
|
const loading = reactive({ users: false, requests: false });
|
||||||
const form = reactive({ username: '', password: '', vlan: '', disabled: false });
|
const form = reactive({ username: '', password: '', vlan: '', disabled: false });
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
async function fetchUsers() {
|
async function fetchUsers() {
|
||||||
loading.users = true;
|
loading.users = true;
|
||||||
try {
|
try {
|
||||||
@@ -138,12 +189,45 @@ onMounted(async () => {
|
|||||||
await fetchUsers();
|
await fetchUsers();
|
||||||
await fetchRequests();
|
await fetchRequests();
|
||||||
setupSse();
|
setupSse();
|
||||||
|
applyTheme();
|
||||||
});
|
});
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
const filteredRequests = computed(() => {
|
||||||
html, body { margin: 0; padding: 0; }
|
return requests.value.filter(ev => {
|
||||||
table th, table td { padding: 4px 6px; border-bottom: 1px solid #eee; }
|
if (eventFilters.type && ev.type !== eventFilters.type) return false;
|
||||||
button { padding: 6px 10px; cursor: pointer; }
|
if (eventFilters.text) {
|
||||||
input { padding: 6px 8px; }
|
const t = eventFilters.text.toLowerCase();
|
||||||
</style>
|
const blob = JSON.stringify(ev).toLowerCase();
|
||||||
|
if (!blob.includes(t)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredUsers = 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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('light', theme.value === 'light');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddUser() { /* placeholder for advanced modal */ }
|
||||||
|
function openAddGuest() { /* placeholder for advanced modal */ }
|
||||||
|
function openSettings() { /* placeholder for advanced modal */ }
|
||||||
|
</script>
|
||||||
|
|||||||
27
frontend/src/components/EventCard.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { defineComponent, h } from 'vue';
|
||||||
|
import htm from 'htm';
|
||||||
|
const html = htm.bind(h);
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'EventCard',
|
||||||
|
props: { ev: { type: Object, required: true } },
|
||||||
|
setup(props) {
|
||||||
|
return () => {
|
||||||
|
const a = props.ev.attrs || {};
|
||||||
|
return html`<div class="card">
|
||||||
|
<div class="row">
|
||||||
|
<b>${props.ev.type}</b>
|
||||||
|
<span class="chip muted">${props.ev.ts}</span>
|
||||||
|
${props.ev.decision ? html`<span class="chip">Decision: ${props.ev.decision}</span>` : ''}
|
||||||
|
${props.ev.error ? html`<span class="chip" style="color:#b33">Error</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="muted" style="margin-top:6px; font-size:12px;">
|
||||||
|
${a['User-Name'] || a['User-Name*0'] ? html`<span>User: ${a['User-Name'] || a['User-Name*0']}</span>` : ''}
|
||||||
|
${a['NAS-IP-Address'] ? html`<span> — NAS: ${a['NAS-IP-Address']}</span>` : ''}
|
||||||
|
${a['Calling-Station-Id'] ? html`<span> — STA: ${a['Calling-Station-Id']}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
24
frontend/src/components/Modal.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="open" class="modal-backdrop" @click.self="$emit('close')">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<strong>{{ title }}</strong>
|
||||||
|
<button class="icon-btn" @click="$emit('close')">Cerrar</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<slot name="footer">
|
||||||
|
<button class="icon-btn" @click="$emit('close')">OK</button>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({ open: Boolean, title: String });
|
||||||
|
defineEmits(['close']);
|
||||||
|
</script>
|
||||||
|
|
||||||
25
frontend/src/components/UserCard.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineComponent, h } from 'vue';
|
||||||
|
import htm from 'htm';
|
||||||
|
const html = htm.bind(h);
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'UserCard',
|
||||||
|
props: { item: { type: Object, required: true }, mode: { type: String, default: 'user' } },
|
||||||
|
emits: ['toggleDisable', 'remove'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
function toggle() { emit('toggleDisable', props.item); }
|
||||||
|
function remove() { emit('remove', props.item); }
|
||||||
|
return () => html`<div class="card">
|
||||||
|
<div class="row">
|
||||||
|
<b>${props.mode === 'user' ? props.item.username : (props.item.device || props.item.username)}</b>
|
||||||
|
<span class="chip">VLAN ${props.item.vlan}</span>
|
||||||
|
${props.item.disabled ? html`<span class="chip" style="color:#b33">deshabilitado</span>` : html`<span class="chip">activo</span>`}
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="icon-btn" onClick=${toggle}>${props.item.disabled ? 'Habilitar' : 'Deshabilitar'}</button>
|
||||||
|
<button class="icon-btn" onClick=${remove}>Eliminar</button>
|
||||||
|
</div>
|
||||||
|
<div class="muted" style="margin-top:6px; font-size:12px;">${props.item.devices ? props.item.devices.length : 0} dispositivos</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
createApp(App).mount('#app');
|
const app = createApp(App);
|
||||||
|
app.mount('#app');
|
||||||
|
|||||||
75
frontend/src/styles.css
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
:root {
|
||||||
|
--bg: 15 15 18;
|
||||||
|
--fg: 235 235 240;
|
||||||
|
--muted: 180 180 190;
|
||||||
|
--accent: 80 160 255;
|
||||||
|
--card: 28 28 34 / 0.55;
|
||||||
|
--border: 255 255 255 / 0.12;
|
||||||
|
--glass-blur: 14px;
|
||||||
|
--radius: 14px;
|
||||||
|
}
|
||||||
|
:root.light {
|
||||||
|
--bg: 245 245 248;
|
||||||
|
--fg: 20 20 22;
|
||||||
|
--muted: 110 110 120;
|
||||||
|
--accent: 18 108 242;
|
||||||
|
--card: 255 255 255 / 0.6;
|
||||||
|
--border: 0 0 0 / 0.08;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #app { height: 100%; }
|
||||||
|
html, body { margin: 0; padding: 0; background: rgb(var(--bg)); color: rgb(var(--fg)); }
|
||||||
|
body { font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
a { color: inherit; }
|
||||||
|
|
||||||
|
/* Top bar */
|
||||||
|
.topbar {
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center;
|
||||||
|
gap: 10px; padding: 10px 14px; backdrop-filter: blur(var(--glass-blur));
|
||||||
|
background: linear-gradient(180deg, rgba(var(--card)), rgba(var(--card)) 60%, rgba(0,0,0,0));
|
||||||
|
border-bottom: 1px solid rgba(var(--border));
|
||||||
|
}
|
||||||
|
.title { font-size: 16px; font-weight: 700; letter-spacing: .2px; flex: 1 1 auto; }
|
||||||
|
.actions { display: inline-flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||||
|
.icon-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 10px; border-radius: 10px; border: 1px solid rgba(var(--border)); background: rgba(var(--card)); color: inherit; transition: transform .12s ease, background .2s;
|
||||||
|
backdrop-filter: blur(var(--glass-blur)); }
|
||||||
|
.icon-btn:hover { transform: translateY(-1px); background: rgba(var(--card)); }
|
||||||
|
.icon { width: 16px; height: 16px; opacity: .9; }
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.shell { height: calc(100vh - 54px); display: grid; grid-template-columns: 360px 1fr; gap: 12px; padding: 12px; }
|
||||||
|
.panel { border: 1px solid rgba(var(--border)); background: rgba(var(--card)); border-radius: var(--radius); backdrop-filter: blur(var(--glass-blur)); overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
|
||||||
|
.panel-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid rgba(var(--border)); }
|
||||||
|
.panel-title { font-weight: 600; }
|
||||||
|
.panel-actions { display: inline-flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.scroll { overflow: auto; padding: 10px; }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card { border: 1px solid rgba(var(--border)); background: rgba(var(--card)); border-radius: 12px; padding: 10px; transition: transform .12s ease, box-shadow .2s ease; box-shadow: 0 4px 14px rgba(0,0,0,.08); }
|
||||||
|
.card:hover { transform: translateY(-1px); box-shadow: 0 8px 20px rgba(0,0,0,.12); }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.shell { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapse helpers: keep headers visible when collapsed */
|
||||||
|
.panel.collapsed .scroll { display: none; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.35); backdrop-filter: blur(4px); display: grid; place-items: center; z-index: 20; animation: fadeIn .15s ease;
|
||||||
|
}
|
||||||
|
.modal { width: min(680px, 92vw); border-radius: 14px; border: 1px solid rgba(var(--border)); background: rgba(var(--card)); padding: 14px; box-shadow: 0 10px 32px rgba(0,0,0,.2); }
|
||||||
|
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||||
|
.modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
||||||
|
/* Small bits */
|
||||||
|
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border: 1px solid rgba(var(--border)); border-radius: 999px; background: rgba(var(--card)); font-size: 12px; }
|
||||||
|
.muted { color: rgb(var(--muted)); }
|
||||||
|
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
.toggle { padding: 6px 10px; border-radius: 8px; border: 1px solid rgba(var(--border)); background: rgba(var(--card)); }
|
||||||