diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 56d9de2..2a1a3a5 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -19,8 +19,10 @@ const { toast } = useToast();
const users = ref([]);
const requests = ref([]);
-const loading = reactive({ users: false, requests: false });
+const loading = reactive({ users: false, requests: false, sessions: false });
const devices = ref([]);
+const sessions = ref([]);
+const sessionStats = ref({ active: 0, stopped: 0, stale: 0, total: 0 });
const userExpanded = reactive({});
const deviceExpanded = reactive({});
@@ -91,6 +93,22 @@ async function fetchDevices() {
}
}
+async function fetchSessions() {
+ loading.sessions = true;
+ try {
+ const res = await fetch('/api/sessions');
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
+ const data = await res.json();
+ sessions.value = data.items || [];
+ sessionStats.value = data.stats || { active: 0, stopped: 0, stale: 0, total: 0 };
+ } catch (error) {
+ if (isAuthError(error)) handleAuthError();
+ else console.error('Error fetching sessions:', error);
+ } finally {
+ loading.sessions = false;
+ }
+}
+
async function toggleDisable(u) {
await fetch(`/api/users/${encodeURIComponent(u.username)}`, {
method: 'PATCH',
@@ -135,7 +153,7 @@ function setupSse() {
function scheduleRefresh() {
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = setTimeout(async () => {
- await Promise.all([fetchUsers(), fetchDevices()]);
+ await Promise.all([fetchUsers(), fetchDevices(), fetchSessions()]);
refreshTimer = null;
}, 3000); // Debounce de 3 segundos para evitar parpadeos
}
@@ -164,6 +182,7 @@ onMounted(async () => {
await fetchUsers();
await fetchDevices();
await fetchRequests();
+ await fetchSessions();
setupSse();
applyTheme();
checkPWAStatus();
@@ -241,6 +260,10 @@ 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 sessionPage = ref(0);
+const pagedSessions = computed(() => sessions.value.slice(sessionPage.value*pageSize, sessionPage.value*pageSize + pageSize));
+watch([sessions, () => layoutMode.value], () => { sessionPage.value = 0; });
+
const filteredRequestsAll = computed(() => filteredRequests.value);
const reqPage = ref(0);
const pagedRequests = computed(() => filteredRequestsAll.value.slice(reqPage.value*pageSize, reqPage.value*pageSize + pageSize));
@@ -462,15 +485,25 @@ async function handleUserFormSubmit(data) {
Usuarios y Dispositivos
Página {{ userPage+1 }} / {{ Math.max(1, Math.ceil(filteredUsersAll.length / pageSize)) }}
- Página {{ devicePage+1 }} / {{ Math.max(1, Math.ceil(devicesAll.length / pageSize)) }}
-
-
+ Página {{ devicePage+1 }} / {{ Math.max(1, Math.ceil(devicesAll.length / pageSize)) }}
+ Página {{ sessionPage+1 }} / {{ Math.max(1, Math.ceil(sessions.length / pageSize)) }}
+
+
+
@@ -489,12 +522,80 @@ async function handleUserFormSubmit(data) {
@toggleDisable="toggleDisable" @remove="removeUser" @edit="openEditUser"
@disconnect="disconnectUser" @edit-device="openDeviceForm" />
-
+
+
+
+
+
+
+
+ Activas: {{ sessionStats.active }}
+
+
Finalizadas: {{ sessionStats.stopped }}
+
Stale: {{ sessionStats.stale }}
+
+
+
+
+
+
Cargando sesiones...
+
No hay sesiones activas
+
+
+
+
+ {{ s.status }}
+
+ {{ s.username }}
+
+ {{ new Date(s.started_at).toLocaleString('es-HN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) }}
+
+
+
+ MAC:
+ {{ s.mac || s.calling_station_id || '-' }}
+
+
+ Dispositivo:
+ {{ s.device_name || 'Sin nombre' }}
+
+
+ NAS:
+ {{ s.nas_id || s.nas_ip || '-' }}
+
+
+ Duración:
+ {{ s.session_time ? (Math.floor(s.session_time/3600) + 'h ' + Math.floor((s.session_time%3600)/60) + 'm') : '-' }}
+
+
+ Datos:
+ ↓ {{ ((s.bytes_in || 0) / 1024 / 1024).toFixed(1) }} MB / ↑ {{ ((s.bytes_out || 0) / 1024 / 1024).toFixed(1) }} MB
+
+
+
+
+
diff --git a/frontend/src/components/DispositivoCard.vue b/frontend/src/components/DispositivoCard.vue
index 21a18b5..6fbe124 100644
--- a/frontend/src/components/DispositivoCard.vue
+++ b/frontend/src/components/DispositivoCard.vue
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import { Card, Badge, Button } from '@/components/ui';
import UserCard from './UserCard.vue';
+import SessionHistory from './SessionHistory.vue';
const props = defineProps({
device: { type: Object, required: true },
@@ -88,5 +89,10 @@ const disconnectedUsers = computed(() => {
/>
+
+
+
+
+
diff --git a/frontend/src/components/SessionHistory.vue b/frontend/src/components/SessionHistory.vue
new file mode 100644
index 0000000..a2bcd0b
--- /dev/null
+++ b/frontend/src/components/SessionHistory.vue
@@ -0,0 +1,152 @@
+
+
+
+
+
+
historial de sesiones
+
+
+
+
Cargando historial...
+
Sin sesiones registradas
+
+
+
+
+ {{ s.status }}
+ {{ s.username }}
+ {{ s.mac || s.calling_station_id || '-' }}
+
+ {{ formatTime(s.started_at) }}
+
+
+
+ Fin: {{ formatTimeShort(s.ended_at) }}
+ ({{ s.stop_reason }})
+
+
+
+
+
+ {{ formatDuration(s.session_time) }}
+
+
+
+ {{ formatBytes(s.bytes_in) }}
+
+
+
+ {{ formatBytes(s.bytes_out) }}
+
+
+
+
+
+
diff --git a/frontend/src/components/UserCard.vue b/frontend/src/components/UserCard.vue
index a9ca4c6..11fe18f 100644
--- a/frontend/src/components/UserCard.vue
+++ b/frontend/src/components/UserCard.vue
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { Card, Badge, Button } from '@/components/ui';
import { cn } from '@/lib/utils';
import DispositivoCard from './DispositivoCard.vue';
+import SessionHistory from './SessionHistory.vue';
const props = defineProps({
item: { type: Object, required: true },
@@ -125,6 +126,11 @@ const userInitial = computed(() => {
/>
+
+
+
+
+
diff --git a/node-api/index.js b/node-api/index.js
index e0f5393..1dc470a 100644
--- a/node-api/index.js
+++ b/node-api/index.js
@@ -1,17 +1,113 @@
import { createApp } from './src/app.js';
-import { ensureSchema, disableGuestsFromYesterday } from './src/services/db.js';
+import {
+ ensureSchema,
+ disableGuestsFromYesterday,
+ getActiveSessions,
+ markStaleSessions,
+ syncConnectedDevicesFromSessions,
+ cleanOldSessions
+} from './src/services/db.js';
+import { activeSessions } from './src/services/radius.js';
+import { broadcastStatus } from './src/sse.js';
const app = createApp();
const port = process.env.PORT || 3000;
+// Configuration from environment
+const STALE_CHECK_INTERVAL = parseInt(process.env.STALE_CHECK_INTERVAL || '120000', 10); // 2 min
+const MAX_IDLE_MINUTES = parseInt(process.env.MAX_IDLE_MINUTES || '10', 10);
+const SESSION_HISTORY_RETENTION_DAYS = parseInt(process.env.SESSION_HISTORY_RETENTION_DAYS || '90', 10);
+
try {
await ensureSchema();
} catch (e) {
console.error('Database schema ensure failed:', e?.message || e);
}
+// Initialize sessions from database on startup
+async function initializeSessionsFromDb() {
+ try {
+ // Sync dispositivos_conectados from active sessions
+ await syncConnectedDevicesFromSessions();
+
+ // Load active sessions into memory Map for CoA
+ const active = await getActiveSessions();
+ for (const sess of active) {
+ activeSessions.set(sess.session_id, {
+ username: sess.username,
+ sessionId: sess.session_id,
+ nasIp: sess.nas_ip,
+ nasId: sess.nas_id,
+ callingStationId: sess.calling_station_id,
+ calledStationId: sess.called_station_id,
+ updatedAt: new Date(sess.last_update).getTime(),
+ });
+ }
+ console.log(`[init] Loaded ${active.length} active sessions from database`);
+ } catch (e) {
+ console.error('[init] Failed to load sessions:', e?.message || e);
+ }
+}
+
+// Job to detect and mark stale sessions
+function scheduleStaleSessionsJob() {
+ setInterval(async () => {
+ try {
+ const result = await markStaleSessions(MAX_IDLE_MINUTES);
+ if (result.count > 0) {
+ console.log(`[stale-sessions] Marked ${result.count} sessions as stale`);
+ // Remove stale sessions from memory Map
+ for (const sess of result.sessions) {
+ activeSessions.delete(sess.session_id);
+ }
+ // Notify SSE clients
+ broadcastStatus({ type: 'sessions-updated', staleCount: result.count });
+ }
+ } catch (e) {
+ console.error('[stale-sessions] Error:', e?.message || e);
+ }
+ }, STALE_CHECK_INTERVAL);
+}
+
+// Job to clean old sessions (runs daily at 3:00 AM local)
+function scheduleSessionCleanupJob() {
+ function schedule() {
+ const now = new Date();
+ const next = new Date(now);
+ next.setUTCHours(9, 0, 0, 0); // 9:00 UTC = 3:00 AM Honduras (UTC-6)
+ if (next <= now) next.setUTCDate(next.getUTCDate() + 1);
+ const delay = next - now;
+
+ setTimeout(async () => {
+ try {
+ const deleted = await cleanOldSessions(SESSION_HISTORY_RETENTION_DAYS);
+ if (deleted > 0) {
+ console.log(`[session-cleanup] Deleted ${deleted} old sessions (>${SESSION_HISTORY_RETENTION_DAYS} days)`);
+ }
+ } catch (e) {
+ console.error('[session-cleanup] Error:', e?.message || e);
+ } finally {
+ schedule(); // Re-schedule for tomorrow
+ }
+ }, delay);
+ }
+ schedule();
+}
+
+// Initialize sessions from database
+try {
+ await initializeSessionsFromDb();
+} catch (e) {
+ console.error('Session initialization failed:', e?.message || e);
+}
+
+// Start maintenance jobs
+scheduleStaleSessionsJob();
+scheduleSessionCleanupJob();
+
app.listen(port, () => {
console.log(`Node RADIUS REST API listening on :${port}`);
+ console.log(`[config] Stale check: every ${STALE_CHECK_INTERVAL/1000}s, idle timeout: ${MAX_IDLE_MINUTES} min, retention: ${SESSION_HISTORY_RETENTION_DAYS} days`);
});
// Schedule daily guest disable at 4:00 AM America/Tegucigalpa (UTC-6 -> 10:00 UTC)
diff --git a/node-api/src/routes/api.js b/node-api/src/routes/api.js
index 0cde4a6..a4acde2 100644
--- a/node-api/src/routes/api.js
+++ b/node-api/src/routes/api.js
@@ -1,7 +1,18 @@
import { Router } from 'express';
import { VLAN_ID } from '../config/env.js';
import { clearRequests, getRecentRequests, registerSse } from '../sse.js';
-import { deleteUserFromDb, readUsersFromDb, upsertUserToDb, pool, disableGuestsFromYesterday } from '../services/db.js';
+import {
+ deleteUserFromDb,
+ readUsersFromDb,
+ upsertUserToDb,
+ pool,
+ disableGuestsFromYesterday,
+ getActiveSessions,
+ getSessionHistory,
+ syncConnectedDevicesFromSessions,
+ markStaleSessions,
+ getSessionStats
+} from '../services/db.js';
import { disconnectUserSessions } from '../services/radius.js';
const router = Router();
@@ -358,4 +369,133 @@ router.post('/guests/disable-yesterday', async (_req, res) => {
}
});
+// ==================== SESSION ENDPOINTS ====================
+
+// GET /api/sessions - Active sessions
+router.get('/sessions', async (_req, res) => {
+ try {
+ const sessions = await getActiveSessions();
+ const stats = await getSessionStats();
+ res.json({ items: sessions, count: sessions.length, stats });
+ } catch (e) {
+ console.error('GET /api/sessions error:', e?.message || e);
+ res.status(500).json({ ok: false, error: 'db_error' });
+ }
+});
+
+// GET /api/sessions/history - Session history with filters
+router.get('/sessions/history', async (req, res) => {
+ try {
+ const { username, dispositivo_id, mac, status, limit, offset, desde, hasta } = req.query;
+ const sessions = await getSessionHistory({
+ username: username || undefined,
+ dispositivoId: dispositivo_id ? parseInt(dispositivo_id, 10) : undefined,
+ mac: mac || undefined,
+ status: status || undefined,
+ limit: Math.min(parseInt(limit || '50', 10), 500),
+ offset: parseInt(offset || '0', 10),
+ desde: desde || undefined,
+ hasta: hasta || undefined
+ });
+ res.json({ items: sessions });
+ } catch (e) {
+ console.error('GET /api/sessions/history error:', e?.message || e);
+ res.status(500).json({ ok: false, error: 'db_error' });
+ }
+});
+
+// GET /api/users/:username/sessions - Sessions for a specific user
+router.get('/users/:username/sessions', async (req, res) => {
+ try {
+ const username = String(req.params.username);
+ const sessions = await getSessionHistory({ username, limit: 100 });
+ const active = sessions.filter(s => s.status === 'active');
+ res.json({
+ active,
+ recent: sessions.slice(0, 20),
+ activeCount: active.length,
+ totalCount: sessions.length
+ });
+ } catch (e) {
+ console.error('GET /api/users/:username/sessions error:', e?.message || e);
+ res.status(500).json({ ok: false, error: 'db_error' });
+ }
+});
+
+// GET /api/devices/:id/sessions - Session history for a specific device
+router.get('/devices/:id/sessions', 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 sessions = await getSessionHistory({ dispositivoId: id, limit: 100 });
+ const active = sessions.filter(s => s.status === 'active');
+ res.json({
+ active,
+ history: sessions,
+ activeCount: active.length,
+ totalCount: sessions.length
+ });
+ } catch (e) {
+ console.error('GET /api/devices/:id/sessions error:', e?.message || e);
+ res.status(500).json({ ok: false, error: 'db_error' });
+ }
+});
+
+// POST /api/sessions/sync - Force sync of sessions
+router.post('/sessions/sync', async (_req, res) => {
+ try {
+ await syncConnectedDevicesFromSessions();
+ const staleResult = await markStaleSessions(10);
+ res.json({ ok: true, staleMarked: staleResult.count });
+ } catch (e) {
+ console.error('POST /api/sessions/sync error:', e?.message || e);
+ res.status(500).json({ ok: false, error: 'sync_error' });
+ }
+});
+
+// GET /api/sessions.csv - Export session history as CSV
+router.get('/sessions.csv', async (req, res) => {
+ try {
+ const { desde, hasta, status } = req.query;
+ const sessions = await getSessionHistory({
+ limit: 10000,
+ desde: desde || undefined,
+ hasta: hasta || undefined,
+ status: status || undefined
+ });
+ const cols = ['session_id','username','mac','device_name','nas_ip','nas_id','started_at','ended_at','status','stop_reason','session_time','bytes_in','bytes_out'];
+ const esc = (v) => {
+ const s = v == null ? '' : String(v);
+ return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
+ };
+ const lines = [cols.join(',')];
+ for (const s of sessions) {
+ lines.push([
+ s.session_id,
+ s.username,
+ s.mac || s.calling_station_id,
+ s.device_name || '',
+ s.nas_ip || '',
+ s.nas_id || '',
+ s.started_at || '',
+ s.ended_at || '',
+ s.status,
+ s.stop_reason || '',
+ s.session_time || 0,
+ s.bytes_in || 0,
+ s.bytes_out || 0
+ ].map(esc).join(','));
+ }
+ const ts = new Date().toISOString().replace(/[:T]/g, '-').split('.')[0];
+ res.setHeader('Content-Type', 'text/csv; charset=utf-8');
+ res.setHeader('Content-Disposition', `attachment; filename="sessions-${ts}.csv"`);
+ res.send(lines.join('\n'));
+ } catch (e) {
+ console.error('GET /api/sessions.csv error:', e?.message || e);
+ res.status(500).json({ ok: false, error: 'export_error' });
+ }
+});
+
export default router;
diff --git a/node-api/src/routes/radius.js b/node-api/src/routes/radius.js
index 3b8eb5d..4052f47 100644
--- a/node-api/src/routes/radius.js
+++ b/node-api/src/routes/radius.js
@@ -3,7 +3,7 @@ import { VLAN_ID } from '../config/env.js';
import { buildAcceptPayload, normalizeAttributes } from '../utils/attrs.js';
import { pushRequest } from '../sse.js';
import { activeSessions, sendRadiusSelfTest } from '../services/radius.js';
-import { addDeviceToUser, connectDeviceForUser, disconnectDeviceForUser, getOrCreateDevice } from '../services/db.js';
+import { addDeviceToUser, connectDeviceForUser, disconnectDeviceForUser, getOrCreateDevice, upsertSession, endSession } from '../services/db.js';
const router = Router();
@@ -29,25 +29,49 @@ router.post('/authorize', (req, res) => {
return res.status(200).json(reply);
});
-router.post('/accounting', (req, res) => {
+router.post('/accounting', async (req, res) => {
const attrs = normalizeAttributes(req.body);
try {
const st = String(attrs['Acct-Status-Type'] || attrs['Acct-Status-Type*0'] || '').toUpperCase();
const sessionId = String(attrs['Acct-Session-Id'] || '');
const username = String(attrs['User-Name'] || '');
+ const mac = attrs['Calling-Station-Id'] || '';
+
+ // Extract statistics from RADIUS attributes
+ const stats = {
+ bytesIn: parseInt(attrs['Acct-Input-Octets'] || '0', 10),
+ bytesOut: parseInt(attrs['Acct-Output-Octets'] || '0', 10),
+ packetsIn: parseInt(attrs['Acct-Input-Packets'] || '0', 10),
+ packetsOut: parseInt(attrs['Acct-Output-Packets'] || '0', 10),
+ sessionTime: parseInt(attrs['Acct-Session-Time'] || '0', 10),
+ interimInterval: parseInt(attrs['Acct-Interim-Interval'] || '0', 10) || null,
+ };
+
if (sessionId && username) {
if (st === 'START' || st === 'ALIVE' || st === 'INTERIM-UPDATE' || st === 'INTERIM') {
+ // Keep Map in memory for CoA compatibility
activeSessions.set(sessionId, {
username,
sessionId,
nasIp: attrs['NAS-IP-Address'] || '',
nasId: attrs['NAS-Identifier'] || '',
- callingStationId: attrs['Calling-Station-Id'] || '',
+ callingStationId: mac,
calledStationId: attrs['Called-Station-Id'] || '',
updatedAt: Date.now(),
});
- // upsert device and link as connected
- const mac = attrs['Calling-Station-Id'] || '';
+
+ // Persist session to database
+ await upsertSession({
+ sessionId,
+ username,
+ nasIp: attrs['NAS-IP-Address'] || '',
+ nasId: attrs['NAS-Identifier'] || '',
+ callingStationId: mac,
+ calledStationId: attrs['Called-Station-Id'] || '',
+ ...stats
+ }).catch(e => console.error('[accounting] upsertSession error:', e?.message || e));
+
+ // Upsert device and link as connected
if (mac) {
getOrCreateDevice({ mac: String(mac) }).then(async (id) => {
await addDeviceToUser(String(username), id);
@@ -56,7 +80,13 @@ router.post('/accounting', (req, res) => {
}
} else if (st === 'STOP') {
activeSessions.delete(sessionId);
- const mac = attrs['Calling-Station-Id'] || '';
+
+ // End session in database
+ const stopReason = attrs['Acct-Terminate-Cause'] || 'Unknown';
+ await endSession(sessionId, stopReason, stats)
+ .catch(e => console.error('[accounting] endSession error:', e?.message || e));
+
+ // Disconnect device
if (mac) {
getOrCreateDevice({ mac: String(mac) }).then(async (id) => {
await disconnectDeviceForUser(String(username), id);
@@ -64,7 +94,9 @@ router.post('/accounting', (req, res) => {
}
}
}
- } catch {}
+ } catch (e) {
+ console.error('[accounting] error:', e?.message || e);
+ }
pushRequest({
id: Date.now() + ':' + Math.random().toString(16).slice(2),
ts: new Date().toISOString(),
diff --git a/node-api/src/services/db.js b/node-api/src/services/db.js
index cced7e3..818ec30 100644
--- a/node-api/src/services/db.js
+++ b/node-api/src/services/db.js
@@ -93,6 +93,36 @@ export async function ensureSchema() {
END IF;
END$$;
`);
+ // Sessions table for persistent connection tracking
+ await client.query(`
+ CREATE TABLE IF NOT EXISTS sesiones (
+ id SERIAL PRIMARY KEY,
+ session_id VARCHAR(64) UNIQUE NOT NULL,
+ username VARCHAR(64) NOT NULL,
+ dispositivo_id INTEGER REFERENCES dispositivos(id) ON DELETE SET NULL,
+ nas_ip VARCHAR(45),
+ nas_id VARCHAR(64),
+ calling_station_id VARCHAR(32),
+ called_station_id VARCHAR(64),
+ started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ ended_at TIMESTAMPTZ,
+ last_update TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ status VARCHAR(16) NOT NULL DEFAULT 'active',
+ stop_reason VARCHAR(64),
+ bytes_in BIGINT DEFAULT 0,
+ bytes_out BIGINT DEFAULT 0,
+ packets_in BIGINT DEFAULT 0,
+ packets_out BIGINT DEFAULT 0,
+ session_time INTEGER DEFAULT 0,
+ interim_interval INTEGER
+ );
+ `);
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_sesiones_username ON sesiones(username);`);
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_sesiones_dispositivo ON sesiones(dispositivo_id);`);
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_sesiones_status ON sesiones(status);`);
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_sesiones_started ON sesiones(started_at DESC);`);
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_sesiones_last_update ON sesiones(last_update);`);
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_sesiones_calling_station ON sesiones(calling_station_id);`);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
@@ -318,3 +348,245 @@ export async function deleteUserFromDb(username) {
client.release();
}
}
+
+// ==================== SESSION FUNCTIONS ====================
+
+// Create or update session on START/INTERIM-UPDATE
+export async function upsertSession({
+ sessionId,
+ username,
+ nasIp,
+ nasId,
+ callingStationId,
+ calledStationId,
+ bytesIn,
+ bytesOut,
+ packetsIn,
+ packetsOut,
+ sessionTime,
+ interimInterval
+}) {
+ const client = await pool.connect();
+ try {
+ // Get dispositivo_id if device exists
+ let dispositivoId = null;
+ if (callingStationId) {
+ const devRes = await client.query(
+ 'SELECT id FROM dispositivos WHERE mac = $1',
+ [callingStationId]
+ );
+ if (devRes.rows.length > 0) {
+ dispositivoId = devRes.rows[0].id;
+ }
+ }
+
+ await client.query(`
+ INSERT INTO sesiones (
+ session_id, username, dispositivo_id, nas_ip, nas_id,
+ calling_station_id, called_station_id, status, last_update,
+ bytes_in, bytes_out, packets_in, packets_out, session_time,
+ interim_interval
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', NOW(), $8, $9, $10, $11, $12, $13)
+ ON CONFLICT (session_id) DO UPDATE SET
+ last_update = NOW(),
+ bytes_in = COALESCE($8, sesiones.bytes_in),
+ bytes_out = COALESCE($9, sesiones.bytes_out),
+ packets_in = COALESCE($10, sesiones.packets_in),
+ packets_out = COALESCE($11, sesiones.packets_out),
+ session_time = COALESCE($12, sesiones.session_time),
+ interim_interval = COALESCE($13, sesiones.interim_interval)
+ `, [
+ sessionId, username, dispositivoId, nasIp, nasId,
+ callingStationId, calledStationId,
+ bytesIn || 0, bytesOut || 0, packetsIn || 0, packetsOut || 0,
+ sessionTime || 0, interimInterval
+ ]);
+ } finally {
+ client.release();
+ }
+}
+
+// End session on STOP
+export async function endSession(sessionId, stopReason, stats = {}) {
+ await pool.query(`
+ UPDATE sesiones SET
+ status = 'stopped',
+ ended_at = NOW(),
+ last_update = NOW(),
+ stop_reason = $2,
+ bytes_in = COALESCE($3, bytes_in),
+ bytes_out = COALESCE($4, bytes_out),
+ session_time = COALESCE($5, session_time)
+ WHERE session_id = $1 AND status = 'active'
+ `, [sessionId, stopReason, stats.bytesIn, stats.bytesOut, stats.sessionTime]);
+}
+
+// Get all active sessions
+export async function getActiveSessions() {
+ const { rows } = await pool.query(`
+ SELECT s.*, d.mac, d.nombre as device_name, d.vendor as device_vendor
+ FROM sesiones s
+ LEFT JOIN dispositivos d ON d.id = s.dispositivo_id
+ WHERE s.status = 'active'
+ ORDER BY s.started_at DESC
+ `);
+ return rows;
+}
+
+// Get session history with filters and pagination
+export async function getSessionHistory({
+ username,
+ dispositivoId,
+ mac,
+ status,
+ limit = 50,
+ offset = 0,
+ desde,
+ hasta
+} = {}) {
+ let query = `
+ SELECT s.*, d.mac, d.nombre as device_name, d.vendor as device_vendor
+ FROM sesiones s
+ LEFT JOIN dispositivos d ON d.id = s.dispositivo_id
+ WHERE 1=1
+ `;
+ const params = [];
+ let paramIdx = 1;
+
+ if (username) {
+ query += ` AND s.username = $${paramIdx++}`;
+ params.push(username);
+ }
+ if (dispositivoId) {
+ query += ` AND s.dispositivo_id = $${paramIdx++}`;
+ params.push(dispositivoId);
+ }
+ if (mac) {
+ query += ` AND s.calling_station_id = $${paramIdx++}`;
+ params.push(mac);
+ }
+ if (status) {
+ query += ` AND s.status = $${paramIdx++}`;
+ params.push(status);
+ }
+ if (desde) {
+ query += ` AND s.started_at >= $${paramIdx++}`;
+ params.push(desde);
+ }
+ if (hasta) {
+ query += ` AND s.started_at <= $${paramIdx++}`;
+ params.push(hasta);
+ }
+
+ query += ` ORDER BY s.started_at DESC LIMIT $${paramIdx++} OFFSET $${paramIdx}`;
+ params.push(limit, offset);
+
+ const { rows } = await pool.query(query, params);
+ return rows;
+}
+
+// Mark stale sessions (called by cleanup job)
+export async function markStaleSessions(maxIdleMinutes = 10) {
+ const client = await pool.connect();
+ try {
+ await client.query('BEGIN');
+
+ // Get sessions that will be marked as stale (for disconnect logic)
+ const { rows: staleSessions } = await client.query(`
+ SELECT session_id, username, dispositivo_id
+ FROM sesiones
+ WHERE status = 'active'
+ AND last_update < NOW() - INTERVAL '1 minute' * $1
+ `, [maxIdleMinutes]);
+
+ // Mark them as stale
+ const { rowCount } = await client.query(`
+ UPDATE sesiones SET
+ status = 'stale',
+ ended_at = last_update,
+ stop_reason = 'Stale-Detected'
+ WHERE status = 'active'
+ AND last_update < NOW() - INTERVAL '1 minute' * $1
+ `, [maxIdleMinutes]);
+
+ // Disconnect devices for users with stale sessions
+ for (const sess of staleSessions) {
+ if (sess.dispositivo_id) {
+ await client.query(
+ `UPDATE users SET dispositivos_conectados = array_remove(coalesce(dispositivos_conectados, '{}'::int[]), $2::int)
+ WHERE username = $1`,
+ [sess.username, sess.dispositivo_id]
+ );
+ }
+ }
+
+ await client.query('COMMIT');
+ return { count: rowCount, sessions: staleSessions };
+ } catch (e) {
+ await client.query('ROLLBACK');
+ throw e;
+ } finally {
+ client.release();
+ }
+}
+
+// Rebuild dispositivos_conectados from active sessions
+export async function syncConnectedDevicesFromSessions() {
+ const client = await pool.connect();
+ try {
+ await client.query('BEGIN');
+
+ // Clear all dispositivos_conectados
+ await client.query(`UPDATE users SET dispositivos_conectados = '{}'`);
+
+ // Get active sessions grouped by username
+ const { rows } = await client.query(`
+ SELECT username, array_agg(DISTINCT dispositivo_id) as device_ids
+ FROM sesiones
+ WHERE status = 'active' AND dispositivo_id IS NOT NULL
+ GROUP BY username
+ `);
+
+ // Update each user with their connected devices
+ for (const row of rows) {
+ const deviceIds = row.device_ids.filter(id => id != null);
+ if (deviceIds.length > 0) {
+ await client.query(
+ `UPDATE users SET dispositivos_conectados = $2 WHERE username = $1`,
+ [row.username, deviceIds]
+ );
+ }
+ }
+
+ await client.query('COMMIT');
+ return rows.length;
+ } catch (e) {
+ await client.query('ROLLBACK');
+ throw e;
+ } finally {
+ client.release();
+ }
+}
+
+// Clean old sessions (data retention)
+export async function cleanOldSessions(retentionDays = 90) {
+ const { rowCount } = await pool.query(`
+ DELETE FROM sesiones
+ WHERE status != 'active'
+ AND started_at < NOW() - INTERVAL '1 day' * $1
+ `, [retentionDays]);
+ return rowCount;
+}
+
+// Get session counts by status
+export async function getSessionStats() {
+ const { rows } = await pool.query(`
+ SELECT
+ COUNT(*) FILTER (WHERE status = 'active') as active,
+ COUNT(*) FILTER (WHERE status = 'stopped') as stopped,
+ COUNT(*) FILTER (WHERE status = 'stale') as stale,
+ COUNT(*) as total
+ FROM sesiones
+ `);
+ return rows[0];
+}
diff --git a/postgres/init/03-sesiones.sql b/postgres/init/03-sesiones.sql
new file mode 100644
index 0000000..152543a
--- /dev/null
+++ b/postgres/init/03-sesiones.sql
@@ -0,0 +1,49 @@
+-- Session history table for tracking all RADIUS connections
+-- This table persists session data that was previously only in memory
+
+CREATE TABLE IF NOT EXISTS sesiones (
+ id SERIAL PRIMARY KEY,
+
+ -- RADIUS session identifiers
+ session_id VARCHAR(64) UNIQUE NOT NULL, -- Acct-Session-Id from FreeRADIUS
+ username VARCHAR(64) NOT NULL, -- User-Name
+ dispositivo_id INTEGER REFERENCES dispositivos(id) ON DELETE SET NULL,
+
+ -- NAS (Network Access Server) information
+ nas_ip VARCHAR(45), -- NAS-IP-Address
+ nas_id VARCHAR(64), -- NAS-Identifier
+ calling_station_id VARCHAR(32), -- MAC address of client device
+ called_station_id VARCHAR(64), -- SSID or AP identifier
+
+ -- Session timestamps
+ started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ ended_at TIMESTAMPTZ, -- NULL if session is active
+ last_update TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ -- Session state: active, stopped, stale
+ status VARCHAR(16) NOT NULL DEFAULT 'active',
+ stop_reason VARCHAR(64), -- Acct-Terminate-Cause
+
+ -- Traffic statistics (from Interim-Updates and Stop)
+ bytes_in BIGINT DEFAULT 0, -- Acct-Input-Octets
+ bytes_out BIGINT DEFAULT 0, -- Acct-Output-Octets
+ packets_in BIGINT DEFAULT 0, -- Acct-Input-Packets
+ packets_out BIGINT DEFAULT 0, -- Acct-Output-Packets
+ session_time INTEGER DEFAULT 0, -- Acct-Session-Time (seconds)
+
+ -- For stale detection
+ interim_interval INTEGER -- Expected Acct-Interim-Interval
+);
+
+-- Indexes for efficient queries
+CREATE INDEX IF NOT EXISTS idx_sesiones_username ON sesiones(username);
+CREATE INDEX IF NOT EXISTS idx_sesiones_dispositivo ON sesiones(dispositivo_id);
+CREATE INDEX IF NOT EXISTS idx_sesiones_status ON sesiones(status);
+CREATE INDEX IF NOT EXISTS idx_sesiones_started ON sesiones(started_at DESC);
+CREATE INDEX IF NOT EXISTS idx_sesiones_last_update ON sesiones(last_update);
+CREATE INDEX IF NOT EXISTS idx_sesiones_calling_station ON sesiones(calling_station_id);
+
+-- Partial index for active sessions (frequently queried)
+CREATE INDEX IF NOT EXISTS idx_sesiones_active
+ ON sesiones(username, dispositivo_id)
+ WHERE status = 'active';