UI/UX mejorados 5
This commit is contained in:
@@ -67,13 +67,14 @@
|
|||||||
<UserCard v-for="u in filteredUsers" :key="u.username" :item="u" :devicesById="devicesById"
|
<UserCard v-for="u in filteredUsers" :key="u.username" :item="u" :devicesById="devicesById"
|
||||||
:expanded="!!userExpanded[u.username]"
|
:expanded="!!userExpanded[u.username]"
|
||||||
@toggle-expand="userExpanded[u.username] = !userExpanded[u.username]"
|
@toggle-expand="userExpanded[u.username] = !userExpanded[u.username]"
|
||||||
@toggleDisable="toggleDisable" @remove="removeUser" @edit="openEditUser" />
|
@toggleDisable="toggleDisable" @remove="removeUser" @edit="openEditUser"
|
||||||
|
@disconnect="disconnectUser" @edit-device="openDeviceForm" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="grid">
|
<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 devices" :key="d.id" :device="d" :users="usersForDevice(d.id)" :devicesById="devicesById"
|
||||||
:expanded="!!deviceExpanded[d.id]"
|
:expanded="!!deviceExpanded[d.id]"
|
||||||
@toggle-expand="deviceExpanded[d.id] = !deviceExpanded[d.id]"
|
@toggle-expand="deviceExpanded[d.id] = !deviceExpanded[d.id]"
|
||||||
@edit="openDeviceForm" />
|
@edit="openDeviceForm" @disconnect="disconnectDevice" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,6 +207,20 @@ async function selfTest() {
|
|||||||
await fetch('/test/radius', { method: 'POST' });
|
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() {
|
function setupSse() {
|
||||||
const ev = new EventSource('/api/events');
|
const ev = new EventSource('/api/events');
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<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 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>
|
<span class="spacer"></span>
|
||||||
<button class="icon-btn" @click="$emit('edit', device)">Editar</button>
|
<button class="icon-btn" @click="$emit('edit', device)">Editar</button>
|
||||||
|
<button class="icon-btn" @click="$emit('disconnect', device)">Desconectar</button>
|
||||||
<button v-if="!simple" class="icon-btn" @click="$emit('toggleExpand')">{{ expanded ? 'Contraer' : 'Expandir' }}</button>
|
<button v-if="!simple" class="icon-btn" @click="$emit('toggleExpand')">{{ expanded ? 'Contraer' : 'Expandir' }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="muted" style="font-size:12px; margin-top:6px;" v-if="device.nombre || device.descripcion">
|
<div class="muted" style="font-size:12px; margin-top:6px;" v-if="device.nombre || device.descripcion">
|
||||||
|
|||||||
@@ -4,15 +4,17 @@
|
|||||||
<b>{{ item.username }}</b>
|
<b>{{ item.username }}</b>
|
||||||
<span class="chip">VLAN {{ item.vlan }}</span>
|
<span class="chip">VLAN {{ item.vlan }}</span>
|
||||||
<span class="chip" :style="item.disabled ? 'color:#b33' : ''">{{ item.disabled ? 'deshabilitado' : 'activo' }}</span>
|
<span class="chip" :style="item.disabled ? 'color:#b33' : ''">{{ item.disabled ? 'deshabilitado' : 'activo' }}</span>
|
||||||
|
<span v-if="hasConnected" class="chip" style="background: rgba(255,127,187,.2); border-color: rgba(255,127,187,.5);">Conectado</span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button class="icon-btn" @click="$emit('edit', item)">Editar</button>
|
<button class="icon-btn" @click="$emit('edit', item)">Editar</button>
|
||||||
|
<button class="icon-btn" @click="$emit('disconnect', item)">Desconectar</button>
|
||||||
<button class="icon-btn" @click="$emit('toggleDisable', item)">{{ item.disabled ? 'Habilitar' : 'Deshabilitar' }}</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 class="icon-btn" @click="$emit('remove', item)">Eliminar</button>
|
||||||
<button v-if="expandable" class="icon-btn" @click="$emit('toggleExpand')">{{ expanded ? 'Contraer' : 'Expandir' }}</button>
|
<button v-if="expandable" class="icon-btn" @click="$emit('toggleExpand')">{{ expanded ? 'Contraer' : 'Expandir' }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="expanded && deviceList.length" style="margin-top:8px;">
|
<div v-if="expanded && deviceList.length" style="margin-top:8px;">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<DispositivoCard v-for="d in deviceList" :key="d.id" :device="d" :connected="isConnected(d.id)" simple />
|
<DispositivoCard v-for="d in deviceList" :key="d.id" :device="d" :connected="isConnected(d.id)" simple @edit="$emit('editDevice', d)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,4 +39,6 @@ const deviceList = computed(() => {
|
|||||||
function isConnected(id) {
|
function isConnected(id) {
|
||||||
return Array.isArray(props.item.dispositivos_conectados) && props.item.dispositivos_conectados.includes(id);
|
return Array.isArray(props.item.dispositivos_conectados) && props.item.dispositivos_conectados.includes(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasConnected = computed(() => Array.isArray(props.item.dispositivos_conectados) && props.item.dispositivos_conectados.length > 0);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -86,7 +86,9 @@ a { color: inherit; }
|
|||||||
.menu button:hover { transform: none; background: rgba(255,255,255,0.06); }
|
.menu button:hover { transform: none; background: rgba(255,255,255,0.06); }
|
||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
.shell { height: calc(100vh - 54px); display: grid; grid-template-columns: 360px 1fr; gap: 12px; padding: 12px; }
|
.shell { height: calc(100vh - 54px); display: grid; grid-template-columns: 360px 1fr; grid-template-areas: "sidebar main"; gap: 12px; padding: 12px; }
|
||||||
|
.shell > aside { grid-area: sidebar; }
|
||||||
|
.shell > main { grid-area: main; }
|
||||||
<<<<<<< HEAD
|
<<<<<<< HEAD
|
||||||
.panel {
|
.panel {
|
||||||
border: 1px solid #ffcfe4; /* light más claro */
|
border: 1px solid #ffcfe4; /* light más claro */
|
||||||
@@ -111,9 +113,18 @@ a { color: inherit; }
|
|||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.shell { grid-template-columns: 1fr; }
|
.shell { grid-template-columns: 1fr; grid-template-areas:
|
||||||
|
"main"
|
||||||
|
"sidebar"; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* When a panel is collapsed, hide its scroll and shrink its grid track to show only header */
|
||||||
|
.panel.collapsed .scroll { display: none; }
|
||||||
|
|
||||||
|
/* Using :has to adapt the grid columns when a side is collapsed (modern browsers) */
|
||||||
|
.shell:has(> aside.collapsed) { grid-template-columns: 52px 1fr; }
|
||||||
|
.shell:has(> main.collapsed) { grid-template-columns: 1fr 52px; }
|
||||||
|
|
||||||
/* Collapse helpers: keep headers visible when collapsed */
|
/* Collapse helpers: keep headers visible when collapsed */
|
||||||
.panel.collapsed .scroll { display: none; }
|
.panel.collapsed .scroll { display: none; }
|
||||||
|
|
||||||
|
|||||||
@@ -176,4 +176,30 @@ router.patch('/devices/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/users/:username/disconnect', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const uname = String(req.params.username);
|
||||||
|
await disconnectUserSessions(uname);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('POST /api/users/:username/disconnect error:', e?.message || e);
|
||||||
|
res.status(500).json({ ok: false, error: 'disconnect_error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
import { disconnectByMac } from '../services/radius.js';
|
||||||
|
router.post('/devices/:id/disconnect', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(String(req.params.id), 10);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ ok: false, error: 'invalid_id' });
|
||||||
|
const r = await pool.query('SELECT mac FROM dispositivos WHERE id = $1', [id]);
|
||||||
|
if (r.rows.length === 0) return res.status(404).json({ ok: false, error: 'not_found' });
|
||||||
|
await disconnectByMac(r.rows[0].mac);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('POST /api/devices/:id/disconnect error:', e?.message || e);
|
||||||
|
res.status(500).json({ ok: false, error: 'disconnect_error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -125,3 +125,49 @@ export async function disconnectUserSessions(username) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function disconnectByMac(mac) {
|
||||||
|
if (!mac) return;
|
||||||
|
const targets = [];
|
||||||
|
for (const sess of activeSessions.values()) {
|
||||||
|
if (String(sess.callingStationId || '').toLowerCase() === String(mac).toLowerCase() && sess.nasIp) {
|
||||||
|
targets.push(sess);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
for (const sess of targets) {
|
||||||
|
try {
|
||||||
|
const result = await sendDisconnectRequest({
|
||||||
|
nasIp: sess.nasIp,
|
||||||
|
username: sess.username,
|
||||||
|
sessionId: sess.sessionId,
|
||||||
|
callingStationId: sess.callingStationId,
|
||||||
|
nasId: sess.nasId,
|
||||||
|
});
|
||||||
|
pushRequest({
|
||||||
|
id: Date.now() + ':' + Math.random().toString(16).slice(2),
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: 'coa-disconnect',
|
||||||
|
attrs: {
|
||||||
|
'User-Name': sess.username,
|
||||||
|
'NAS-IP-Address': sess.nasIp,
|
||||||
|
'Acct-Session-Id': sess.sessionId,
|
||||||
|
'Calling-Station-Id': sess.callingStationId,
|
||||||
|
'result': result.code,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
pushRequest({
|
||||||
|
id: Date.now() + ':' + Math.random().toString(16).slice(2),
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: 'coa-disconnect',
|
||||||
|
attrs: {
|
||||||
|
'User-Name': sess.username,
|
||||||
|
'NAS-IP-Address': sess.nasIp,
|
||||||
|
'Acct-Session-Id': sess.sessionId,
|
||||||
|
'Calling-Station-Id': sess.callingStationId,
|
||||||
|
'error': String(e?.message || e),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user