mejoras UI 4
This commit is contained in:
@@ -62,10 +62,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="scroll">
|
<div class="scroll">
|
||||||
<div v-if="loading.users" class="muted">Cargando usuarios…</div>
|
<div v-if="loading.users" class="muted">Cargando usuarios…</div>
|
||||||
<div v-else class="grid">
|
<template v-else>
|
||||||
<UserCard v-for="u in filteredUsers" :key="u.username" :item="u" :mode="layoutMode"
|
<div v-if="layoutMode==='user'" class="grid">
|
||||||
|
<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" />
|
@toggleDisable="toggleDisable" @remove="removeUser" @edit="openEditUser" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="grid">
|
||||||
|
<DispositivoCard v-for="d in devices" :key="d.id" :device="d" :users="usersForDevice(d.id)" :devicesById="devicesById"
|
||||||
|
:expanded="!!deviceExpanded[d.id]"
|
||||||
|
@toggle-expand="deviceExpanded[d.id] = !deviceExpanded[d.id]" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</section>
|
</section>
|
||||||
@@ -111,9 +120,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, reactive, ref, computed } from 'vue';
|
import { onMounted, reactive, ref, computed, watch } from 'vue';
|
||||||
import EventCard from './components/EventCard.js';
|
import EventCard from './components/EventCard.js';
|
||||||
import UserCard from './components/UserCard.js';
|
import UserCard from './components/UserCard.vue';
|
||||||
|
import DispositivoCard from './components/DispositivoCard.vue';
|
||||||
import Modal from './components/Modal.vue';
|
import Modal from './components/Modal.vue';
|
||||||
import UserForm from './components/UserForm.vue';
|
import UserForm from './components/UserForm.vue';
|
||||||
import RawDbViewer from './components/RawDbViewer.vue';
|
import RawDbViewer from './components/RawDbViewer.vue';
|
||||||
@@ -122,6 +132,9 @@ import VlanForm from './components/VlanForm.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 devices = ref([]);
|
||||||
|
const userExpanded = reactive({});
|
||||||
|
const deviceExpanded = reactive({});
|
||||||
// formulario inline removido: se usa modal con UserForm
|
// formulario inline removido: se usa modal con UserForm
|
||||||
|
|
||||||
const showEventFilters = ref(false);
|
const showEventFilters = ref(false);
|
||||||
@@ -153,6 +166,14 @@ async function fetchRequests() {
|
|||||||
} finally { loading.requests = false; }
|
} finally { loading.requests = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchDevices() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/devices');
|
||||||
|
const data = await res.json();
|
||||||
|
devices.value = data.items || [];
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleDisable(u) {
|
async function toggleDisable(u) {
|
||||||
await fetch(`/api/users/${encodeURIComponent(u.username)}`, {
|
await fetch(`/api/users/${encodeURIComponent(u.username)}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -181,17 +202,39 @@ async function selfTest() {
|
|||||||
|
|
||||||
function setupSse() {
|
function setupSse() {
|
||||||
const ev = new EventSource('/api/events');
|
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) => {
|
ev.addEventListener('message', (e) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (data && data.ts) requests.value.push(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 {}
|
} catch {}
|
||||||
});
|
});
|
||||||
ev.addEventListener('clear', () => { requests.value = []; });
|
ev.addEventListener('clear', () => { requests.value = []; });
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// Load persisted expand state
|
||||||
|
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 fetchUsers();
|
||||||
|
await fetchDevices();
|
||||||
await fetchRequests();
|
await fetchRequests();
|
||||||
setupSse();
|
setupSse();
|
||||||
applyTheme();
|
applyTheme();
|
||||||
@@ -218,6 +261,24 @@ const filteredUsers = computed(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist expansion state
|
||||||
|
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() {
|
function copyRequests() {
|
||||||
const txt = JSON.stringify(requests.value, null, 2);
|
const txt = JSON.stringify(requests.value, null, 2);
|
||||||
navigator.clipboard?.writeText(txt);
|
navigator.clipboard?.writeText(txt);
|
||||||
|
|||||||
35
frontend/src/components/DispositivoCard.vue
Normal file
35
frontend/src/components/DispositivoCard.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div class="row">
|
||||||
|
<b>{{ device.nombre || device.mac }}</b>
|
||||||
|
<span class="chip">{{ device.mac }}</span>
|
||||||
|
<span v-if="connectedCount>0 || connected" class="chip" style="background: rgba(255,127,187,.2); border-color: rgba(255,127,187,.5);">Conectado</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button v-if="!simple" class="icon-btn" @click="$emit('toggleExpand')">{{ expanded ? 'Contraer' : 'Expandir' }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="expanded && users && users.length" style="margin-top:8px;">
|
||||||
|
<div class="grid">
|
||||||
|
<UserCard v-for="u in users" :key="u.username" :item="u" :devicesById="devicesById" :expandable="false" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import UserCard from './UserCard.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
device: { type: Object, required: true },
|
||||||
|
users: { type: Array, default: () => [] },
|
||||||
|
devicesById: { type: Object, default: () => ({}) },
|
||||||
|
simple: { type: Boolean, default: false },
|
||||||
|
connected: { type: Boolean, default: false },
|
||||||
|
expanded: { type: Boolean, default: false }
|
||||||
|
});
|
||||||
|
const connectedCount = computed(() => {
|
||||||
|
if (!props.users || !props.users.length) return props.connected ? 1 : 0;
|
||||||
|
const id = props.device.id;
|
||||||
|
return props.users.filter(u => Array.isArray(u.dispositivos_conectados) && u.dispositivos_conectados.includes(id)).length;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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', 'edit'],
|
|
||||||
setup(props, { emit }) {
|
|
||||||
function toggle() { emit('toggleDisable', props.item); }
|
|
||||||
function remove() { emit('remove', props.item); }
|
|
||||||
function edit() { emit('edit', 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=${edit}>Editar</button>
|
|
||||||
<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>`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
40
frontend/src/components/UserCard.vue
Normal file
40
frontend/src/components/UserCard.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div class="row">
|
||||||
|
<b>{{ item.username }}</b>
|
||||||
|
<span class="chip">VLAN {{ item.vlan }}</span>
|
||||||
|
<span class="chip" :style="item.disabled ? 'color:#b33' : ''">{{ item.disabled ? 'deshabilitado' : 'activo' }}</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="icon-btn" @click="$emit('edit', item)">Editar</button>
|
||||||
|
<button class="icon-btn" @click="$emit('toggleDisable', item)">{{ item.disabled ? 'Habilitar' : 'Deshabilitar' }}</button>
|
||||||
|
<button class="icon-btn" @click="$emit('remove', item)">Eliminar</button>
|
||||||
|
<button v-if="expandable" class="icon-btn" @click="$emit('toggleExpand')">{{ expanded ? 'Contraer' : 'Expandir' }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="expanded && deviceList.length" style="margin-top:8px;">
|
||||||
|
<div class="grid">
|
||||||
|
<DispositivoCard v-for="d in deviceList" :key="d.id" :device="d" :connected="isConnected(d.id)" simple />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import DispositivoCard from './DispositivoCard.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
item: { type: Object, required: true },
|
||||||
|
devicesById: { type: Object, default: () => ({}) },
|
||||||
|
expandable: { type: Boolean, default: true },
|
||||||
|
expanded: { type: Boolean, default: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
const deviceList = computed(() => {
|
||||||
|
const ids = props.item.dispositivos_utilizados || [];
|
||||||
|
return ids.map(id => props.devicesById[id]).filter(Boolean);
|
||||||
|
});
|
||||||
|
|
||||||
|
function isConnected(id) {
|
||||||
|
return Array.isArray(props.item.dispositivos_conectados) && props.item.dispositivos_conectados.includes(id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -151,4 +151,15 @@ router.post('/vlans', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Devices list (read-only)
|
||||||
|
router.get('/devices', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query('SELECT id, mac, nombre, vendor, descripcion, first_seen, last_seen FROM dispositivos ORDER BY last_seen DESC NULLS LAST, first_seen DESC');
|
||||||
|
res.json({ items: result.rows });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('GET /api/devices error:', e?.message || e);
|
||||||
|
res.status(500).json({ ok: false, error: 'db_error', items: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
Reference in New Issue
Block a user