diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index a4c6800..2c68194 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -67,13 +67,14 @@
+ @toggleDisable="toggleDisable" @remove="removeUser" @edit="openEditUser"
+ @disconnect="disconnectUser" @edit-device="openDeviceForm" />
+ @edit="openDeviceForm" @disconnect="disconnectDevice" />
@@ -206,6 +207,20 @@ 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;
diff --git a/frontend/src/components/DispositivoCard.vue b/frontend/src/components/DispositivoCard.vue
index 914d2f3..55dc412 100644
--- a/frontend/src/components/DispositivoCard.vue
+++ b/frontend/src/components/DispositivoCard.vue
@@ -7,6 +7,7 @@
Conectado
+
diff --git a/frontend/src/components/UserCard.vue b/frontend/src/components/UserCard.vue
index 4e6ee84..e04d166 100644
--- a/frontend/src/components/UserCard.vue
+++ b/frontend/src/components/UserCard.vue
@@ -4,15 +4,17 @@
{{ item.username }}
VLAN {{ item.vlan }}
{{ item.disabled ? 'deshabilitado' : 'activo' }}
+ Conectado
+
@@ -37,4 +39,6 @@ const deviceList = computed(() => {
function isConnected(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);
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 5de82b8..2c1c5e6 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -86,7 +86,9 @@ a { color: inherit; }
.menu button:hover { transform: none; background: rgba(255,255,255,0.06); }
/* 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
.panel {
border: 1px solid #ffcfe4; /* light más claro */
@@ -111,9 +113,18 @@ a { color: inherit; }
/* Responsive */
@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 */
.panel.collapsed .scroll { display: none; }
diff --git a/node-api/src/routes/api.js b/node-api/src/routes/api.js
index 401b1b6..28c4cff 100644
--- a/node-api/src/routes/api.js
+++ b/node-api/src/routes/api.js
@@ -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;
diff --git a/node-api/src/services/radius.js b/node-api/src/services/radius.js
index 513cfc8..b6ebd18 100644
--- a/node-api/src/services/radius.js
+++ b/node-api/src/services/radius.js
@@ -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),
+ },
+ });
+ }
+ }
+}