Feature: Sistema de tracking de sesiones RADIUS en tiempo real
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 30s
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 30s
- Nueva tabla `sesiones` para historial persistente de conexiones - Job de detección de stale cada 2 min (10 min idle = desconectado) - Inicialización resiliente desde BD al arrancar el servidor - Nuevos endpoints: /api/sessions, /api/sessions/history, /api/sessions.csv - Nueva vista "Sesiones" en el dashboard con estadísticas - Historial integrado en UserCard y DispositivoCard - Estadísticas de bytes in/out y duración por sesión - Retención configurable de historial (90 días por defecto)
This commit is contained in:
@@ -19,8 +19,10 @@ const { toast } = useToast();
|
|||||||
|
|
||||||
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, sessions: false });
|
||||||
const devices = ref([]);
|
const devices = ref([]);
|
||||||
|
const sessions = ref([]);
|
||||||
|
const sessionStats = ref({ active: 0, stopped: 0, stale: 0, total: 0 });
|
||||||
const userExpanded = reactive({});
|
const userExpanded = reactive({});
|
||||||
const deviceExpanded = 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) {
|
async function toggleDisable(u) {
|
||||||
await fetch(`/api/users/${encodeURIComponent(u.username)}`, {
|
await fetch(`/api/users/${encodeURIComponent(u.username)}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -135,7 +153,7 @@ function setupSse() {
|
|||||||
function scheduleRefresh() {
|
function scheduleRefresh() {
|
||||||
if (refreshTimer) clearTimeout(refreshTimer);
|
if (refreshTimer) clearTimeout(refreshTimer);
|
||||||
refreshTimer = setTimeout(async () => {
|
refreshTimer = setTimeout(async () => {
|
||||||
await Promise.all([fetchUsers(), fetchDevices()]);
|
await Promise.all([fetchUsers(), fetchDevices(), fetchSessions()]);
|
||||||
refreshTimer = null;
|
refreshTimer = null;
|
||||||
}, 3000); // Debounce de 3 segundos para evitar parpadeos
|
}, 3000); // Debounce de 3 segundos para evitar parpadeos
|
||||||
}
|
}
|
||||||
@@ -164,6 +182,7 @@ onMounted(async () => {
|
|||||||
await fetchUsers();
|
await fetchUsers();
|
||||||
await fetchDevices();
|
await fetchDevices();
|
||||||
await fetchRequests();
|
await fetchRequests();
|
||||||
|
await fetchSessions();
|
||||||
setupSse();
|
setupSse();
|
||||||
applyTheme();
|
applyTheme();
|
||||||
checkPWAStatus();
|
checkPWAStatus();
|
||||||
@@ -241,6 +260,10 @@ const devicePage = ref(0);
|
|||||||
const pagedDevices = computed(() => devicesAll.value.slice(devicePage.value*pageSize, devicePage.value*pageSize + pageSize));
|
const pagedDevices = computed(() => devicesAll.value.slice(devicePage.value*pageSize, devicePage.value*pageSize + pageSize));
|
||||||
watch([devicesAll, () => layoutMode.value], () => { devicePage.value = 0; });
|
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 filteredRequestsAll = computed(() => filteredRequests.value);
|
||||||
const reqPage = ref(0);
|
const reqPage = ref(0);
|
||||||
const pagedRequests = computed(() => filteredRequestsAll.value.slice(reqPage.value*pageSize, reqPage.value*pageSize + pageSize));
|
const pagedRequests = computed(() => filteredRequestsAll.value.slice(reqPage.value*pageSize, reqPage.value*pageSize + pageSize));
|
||||||
@@ -462,15 +485,25 @@ async function handleUserFormSubmit(data) {
|
|||||||
<CardTitle>Usuarios y Dispositivos</CardTitle>
|
<CardTitle>Usuarios y Dispositivos</CardTitle>
|
||||||
<span class="flex-1"></span>
|
<span class="flex-1"></span>
|
||||||
<Badge v-if="layoutMode==='user'">Página {{ userPage+1 }} / {{ Math.max(1, Math.ceil(filteredUsersAll.length / pageSize)) }}</Badge>
|
<Badge v-if="layoutMode==='user'">Página {{ userPage+1 }} / {{ Math.max(1, Math.ceil(filteredUsersAll.length / pageSize)) }}</Badge>
|
||||||
<Badge v-else>Página {{ devicePage+1 }} / {{ Math.max(1, Math.ceil(devicesAll.length / pageSize)) }}</Badge>
|
<Badge v-else-if="layoutMode==='device'">Página {{ devicePage+1 }} / {{ Math.max(1, Math.ceil(devicesAll.length / pageSize)) }}</Badge>
|
||||||
<Button size="sm" @click="layoutMode==='user' ? (userPage=Math.max(0,userPage-1)) : (devicePage=Math.max(0,devicePage-1))">Anterior</Button>
|
<Badge v-else>Página {{ sessionPage+1 }} / {{ Math.max(1, Math.ceil(sessions.length / pageSize)) }}</Badge>
|
||||||
<Button size="sm" @click="layoutMode==='user' ? (userPage=Math.min(Math.ceil(filteredUsersAll.length/pageSize)-1,userPage+1)) : (devicePage=Math.min(Math.ceil(devicesAll.length/pageSize)-1,devicePage+1))">Siguiente</Button>
|
<Button size="sm" @click="layoutMode==='user' ? (userPage=Math.max(0,userPage-1)) : layoutMode==='device' ? (devicePage=Math.max(0,devicePage-1)) : (sessionPage=Math.max(0,sessionPage-1))">Anterior</Button>
|
||||||
|
<Button size="sm" @click="layoutMode==='user' ? (userPage=Math.min(Math.ceil(filteredUsersAll.length/pageSize)-1,userPage+1)) : layoutMode==='device' ? (devicePage=Math.min(Math.ceil(devicesAll.length/pageSize)-1,devicePage+1)) : (sessionPage=Math.min(Math.ceil(sessions.length/pageSize)-1,sessionPage+1))">Siguiente</Button>
|
||||||
<Button size="sm" :variant="layoutMode==='user' ? 'default' : 'ghost'" title="Vista usuarios" @click="layoutMode='user'">
|
<Button size="sm" :variant="layoutMode==='user' ? 'default' : 'ghost'" title="Vista usuarios" @click="layoutMode='user'">
|
||||||
<img class="size-4 opacity-90" src="/icons/layout-users.svg" alt="usuarios"/> Usuarios
|
<img class="size-4 opacity-90" src="/icons/layout-users.svg" alt="usuarios"/> Usuarios
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" :variant="layoutMode==='device' ? 'default' : 'ghost'" title="Vista dispositivos" @click="layoutMode='device'">
|
<Button size="sm" :variant="layoutMode==='device' ? 'default' : 'ghost'" title="Vista dispositivos" @click="layoutMode='device'">
|
||||||
<img class="size-4 opacity-90" src="/icons/layout-devices.svg" alt="dispositivos"/> Dispositivos
|
<img class="size-4 opacity-90" src="/icons/layout-devices.svg" alt="dispositivos"/> Dispositivos
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" :variant="layoutMode==='session' ? 'default' : 'ghost'" title="Vista sesiones activas" @click="layoutMode='session'; fetchSessions()">
|
||||||
|
<svg class="size-4 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||||
|
</svg>
|
||||||
|
Sesiones <Badge v-if="sessionStats.active > 0" variant="pink" class="ml-1 text-[10px] py-0">{{ sessionStats.active }}</Badge>
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="ghost" title="Filtrar" @click="showUserFilters = true">
|
<Button size="sm" variant="ghost" title="Filtrar" @click="showUserFilters = true">
|
||||||
<img class="size-4 opacity-90" src="/icons/filter.svg" alt="filtro"/>
|
<img class="size-4 opacity-90" src="/icons/filter.svg" alt="filtro"/>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -489,12 +522,80 @@ async function handleUserFormSubmit(data) {
|
|||||||
@toggleDisable="toggleDisable" @remove="removeUser" @edit="openEditUser"
|
@toggleDisable="toggleDisable" @remove="removeUser" @edit="openEditUser"
|
||||||
@disconnect="disconnectUser" @edit-device="openDeviceForm" />
|
@disconnect="disconnectUser" @edit-device="openDeviceForm" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-2.5">
|
<div v-else-if="layoutMode==='device'" class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-2.5">
|
||||||
<DispositivoCard v-for="d in pagedDevices" :key="d.id" :device="d" :users="usersForDevice(d.id)" :devicesById="devicesById"
|
<DispositivoCard v-for="d in pagedDevices" :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" @disconnect="disconnectDevice" />
|
@edit="openDeviceForm" @disconnect="disconnectDevice" />
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Session View -->
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="flex flex-wrap gap-3 mb-4">
|
||||||
|
<Badge variant="pink" class="text-sm py-1.5 px-3">
|
||||||
|
<svg class="size-4 mr-1.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<polyline points="12 6 12 12 16 14"></polyline>
|
||||||
|
</svg>
|
||||||
|
Activas: {{ sessionStats.active }}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" class="text-sm py-1.5 px-3">Finalizadas: {{ sessionStats.stopped }}</Badge>
|
||||||
|
<Badge variant="warning" class="text-sm py-1.5 px-3">Stale: {{ sessionStats.stale }}</Badge>
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
<Button size="sm" @click="fetchSessions">
|
||||||
|
<svg class="size-4 mr-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path>
|
||||||
|
<path d="M21 3v5h-5"></path>
|
||||||
|
</svg>
|
||||||
|
Actualizar
|
||||||
|
</Button>
|
||||||
|
<Button as="a" size="sm" variant="ghost" href="/api/sessions.csv" target="_blank">
|
||||||
|
<svg class="size-4 mr-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
Exportar CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<!-- Sessions Grid -->
|
||||||
|
<div v-if="loading.sessions" class="text-muted">Cargando sesiones...</div>
|
||||||
|
<div v-else-if="!sessions.length" class="text-muted text-center py-8">No hay sesiones activas</div>
|
||||||
|
<div v-else class="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-3">
|
||||||
|
<Card v-for="s in pagedSessions" :key="s.id" :class="['p-4', s.status === 'active' && 'border-pink-400/30 bg-pink-400/5']">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||||||
|
<Badge :variant="s.status === 'active' ? 'pink' : s.status === 'stale' ? 'warning' : 'secondary'">
|
||||||
|
{{ s.status }}
|
||||||
|
</Badge>
|
||||||
|
<span class="font-semibold">{{ s.username }}</span>
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
<span class="text-xs text-muted">{{ new Date(s.started_at).toLocaleString('es-HN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-muted">MAC:</span>
|
||||||
|
<span class="font-mono text-xs">{{ s.mac || s.calling_station_id || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-muted">Dispositivo:</span>
|
||||||
|
<span>{{ s.device_name || 'Sin nombre' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-muted">NAS:</span>
|
||||||
|
<span class="text-xs">{{ s.nas_id || s.nas_ip || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-muted">Duración:</span>
|
||||||
|
<span>{{ s.session_time ? (Math.floor(s.session_time/3600) + 'h ' + Math.floor((s.session_time%3600)/60) + 'm') : '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-muted">Datos:</span>
|
||||||
|
<span>↓ {{ ((s.bytes_in || 0) / 1024 / 1024).toFixed(1) }} MB / ↑ {{ ((s.bytes_out || 0) / 1024 / 1024).toFixed(1) }} MB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { Card, Badge, Button } from '@/components/ui';
|
import { Card, Badge, Button } from '@/components/ui';
|
||||||
import UserCard from './UserCard.vue';
|
import UserCard from './UserCard.vue';
|
||||||
|
import SessionHistory from './SessionHistory.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
device: { type: Object, required: true },
|
device: { type: Object, required: true },
|
||||||
@@ -88,5 +89,10 @@ const disconnectedUsers = computed(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Session History -->
|
||||||
|
<div v-if="expanded" class="mt-3 pt-3 border-t border-border">
|
||||||
|
<SessionHistory :deviceId="device.id" :limit="5" />
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
152
frontend/src/components/SessionHistory.vue
Normal file
152
frontend/src/components/SessionHistory.vue
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from 'vue';
|
||||||
|
import { Card, Badge, Button } from '@/components/ui';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
username: String,
|
||||||
|
deviceId: Number,
|
||||||
|
limit: { type: Number, default: 10 },
|
||||||
|
showHeader: { type: Boolean, default: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['refresh']);
|
||||||
|
|
||||||
|
const sessions = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
async function fetchSessions() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
let url = '/api/sessions/history?limit=' + props.limit;
|
||||||
|
if (props.username) url += '&username=' + encodeURIComponent(props.username);
|
||||||
|
if (props.deviceId) url += '&dispositivo_id=' + props.deviceId;
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
sessions.value = data.items || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch sessions:', e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchSessions);
|
||||||
|
|
||||||
|
watch(() => [props.username, props.deviceId], fetchSessions);
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (!seconds) return '-';
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
if (h > 0) return `${h}h ${m}m`;
|
||||||
|
if (m > 0) return `${m}m ${s}s`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let i = 0;
|
||||||
|
let b = bytes;
|
||||||
|
while (b >= 1024 && i < units.length - 1) {
|
||||||
|
b /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return b.toFixed(1) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusVariant(status) {
|
||||||
|
return {
|
||||||
|
active: 'pink',
|
||||||
|
stopped: 'secondary',
|
||||||
|
stale: 'warning'
|
||||||
|
}[status] || 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts) {
|
||||||
|
if (!ts) return '-';
|
||||||
|
return new Date(ts).toLocaleString('es-HN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeShort(ts) {
|
||||||
|
if (!ts) return '-';
|
||||||
|
return new Date(ts).toLocaleString('es-HN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ refresh: fetchSessions });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div v-if="showHeader" class="flex items-center justify-between mb-3">
|
||||||
|
<span class="text-sm font-medium text-muted lowercase tracking-wide">historial de sesiones</span>
|
||||||
|
<Button size="sm" variant="ghost" @click="fetchSessions" :disabled="loading">
|
||||||
|
<svg :class="cn('size-3.5', loading && 'animate-spin')" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path>
|
||||||
|
<path d="M21 3v5h-5"></path>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-muted text-sm py-4 text-center">Cargando historial...</div>
|
||||||
|
<div v-else-if="!sessions.length" class="text-muted text-sm py-4 text-center">Sin sesiones registradas</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="s in sessions"
|
||||||
|
:key="s.id"
|
||||||
|
:class="cn(
|
||||||
|
'p-3 rounded-lg border border-border bg-card/50',
|
||||||
|
s.status === 'active' && 'border-pink-400/30 bg-pink-400/5'
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge :variant="statusVariant(s.status)" class="text-[10px]">{{ s.status }}</Badge>
|
||||||
|
<span v-if="!username" class="font-medium text-sm">{{ s.username }}</span>
|
||||||
|
<span class="text-muted text-xs font-mono">{{ s.mac || s.calling_station_id || '-' }}</span>
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
<span class="text-xs text-muted">{{ formatTime(s.started_at) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="s.status !== 'active'" class="text-xs text-muted mt-1.5">
|
||||||
|
Fin: {{ formatTimeShort(s.ended_at) }}
|
||||||
|
<span v-if="s.stop_reason" class="opacity-70 ml-1">({{ s.stop_reason }})</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3 mt-2 text-xs text-muted">
|
||||||
|
<span class="flex items-center gap-1" title="Duración">
|
||||||
|
<svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<polyline points="12 6 12 12 16 14"></polyline>
|
||||||
|
</svg>
|
||||||
|
{{ formatDuration(s.session_time) }}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1" title="Datos recibidos">
|
||||||
|
<svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="7 13 12 18 17 13"></polyline>
|
||||||
|
<polyline points="7 6 12 11 17 6"></polyline>
|
||||||
|
</svg>
|
||||||
|
{{ formatBytes(s.bytes_in) }}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1" title="Datos enviados">
|
||||||
|
<svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="17 11 12 6 7 11"></polyline>
|
||||||
|
<polyline points="17 18 12 13 7 18"></polyline>
|
||||||
|
</svg>
|
||||||
|
{{ formatBytes(s.bytes_out) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
|||||||
import { Card, Badge, Button } from '@/components/ui';
|
import { Card, Badge, Button } from '@/components/ui';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import DispositivoCard from './DispositivoCard.vue';
|
import DispositivoCard from './DispositivoCard.vue';
|
||||||
|
import SessionHistory from './SessionHistory.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
item: { type: Object, required: true },
|
item: { type: Object, required: true },
|
||||||
@@ -125,6 +126,11 @@ const userInitial = computed(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Session History -->
|
||||||
|
<div v-if="expanded" class="mt-4 pt-4 border-t border-border">
|
||||||
|
<SessionHistory :username="item.username" :limit="5" />
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,17 +1,113 @@
|
|||||||
import { createApp } from './src/app.js';
|
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 app = createApp();
|
||||||
const port = process.env.PORT || 3000;
|
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 {
|
try {
|
||||||
await ensureSchema();
|
await ensureSchema();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Database schema ensure failed:', e?.message || 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, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Node RADIUS REST API listening on :${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)
|
// Schedule daily guest disable at 4:00 AM America/Tegucigalpa (UTC-6 -> 10:00 UTC)
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { VLAN_ID } from '../config/env.js';
|
import { VLAN_ID } from '../config/env.js';
|
||||||
import { clearRequests, getRecentRequests, registerSse } from '../sse.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';
|
import { disconnectUserSessions } from '../services/radius.js';
|
||||||
|
|
||||||
const router = Router();
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { VLAN_ID } from '../config/env.js';
|
|||||||
import { buildAcceptPayload, normalizeAttributes } from '../utils/attrs.js';
|
import { buildAcceptPayload, normalizeAttributes } from '../utils/attrs.js';
|
||||||
import { pushRequest } from '../sse.js';
|
import { pushRequest } from '../sse.js';
|
||||||
import { activeSessions, sendRadiusSelfTest } from '../services/radius.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();
|
const router = Router();
|
||||||
|
|
||||||
@@ -29,25 +29,49 @@ router.post('/authorize', (req, res) => {
|
|||||||
return res.status(200).json(reply);
|
return res.status(200).json(reply);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/accounting', (req, res) => {
|
router.post('/accounting', async (req, res) => {
|
||||||
const attrs = normalizeAttributes(req.body);
|
const attrs = normalizeAttributes(req.body);
|
||||||
try {
|
try {
|
||||||
const st = String(attrs['Acct-Status-Type'] || attrs['Acct-Status-Type*0'] || '').toUpperCase();
|
const st = String(attrs['Acct-Status-Type'] || attrs['Acct-Status-Type*0'] || '').toUpperCase();
|
||||||
const sessionId = String(attrs['Acct-Session-Id'] || '');
|
const sessionId = String(attrs['Acct-Session-Id'] || '');
|
||||||
const username = String(attrs['User-Name'] || '');
|
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 (sessionId && username) {
|
||||||
if (st === 'START' || st === 'ALIVE' || st === 'INTERIM-UPDATE' || st === 'INTERIM') {
|
if (st === 'START' || st === 'ALIVE' || st === 'INTERIM-UPDATE' || st === 'INTERIM') {
|
||||||
|
// Keep Map in memory for CoA compatibility
|
||||||
activeSessions.set(sessionId, {
|
activeSessions.set(sessionId, {
|
||||||
username,
|
username,
|
||||||
sessionId,
|
sessionId,
|
||||||
nasIp: attrs['NAS-IP-Address'] || '',
|
nasIp: attrs['NAS-IP-Address'] || '',
|
||||||
nasId: attrs['NAS-Identifier'] || '',
|
nasId: attrs['NAS-Identifier'] || '',
|
||||||
callingStationId: attrs['Calling-Station-Id'] || '',
|
callingStationId: mac,
|
||||||
calledStationId: attrs['Called-Station-Id'] || '',
|
calledStationId: attrs['Called-Station-Id'] || '',
|
||||||
updatedAt: Date.now(),
|
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) {
|
if (mac) {
|
||||||
getOrCreateDevice({ mac: String(mac) }).then(async (id) => {
|
getOrCreateDevice({ mac: String(mac) }).then(async (id) => {
|
||||||
await addDeviceToUser(String(username), id);
|
await addDeviceToUser(String(username), id);
|
||||||
@@ -56,7 +80,13 @@ router.post('/accounting', (req, res) => {
|
|||||||
}
|
}
|
||||||
} else if (st === 'STOP') {
|
} else if (st === 'STOP') {
|
||||||
activeSessions.delete(sessionId);
|
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) {
|
if (mac) {
|
||||||
getOrCreateDevice({ mac: String(mac) }).then(async (id) => {
|
getOrCreateDevice({ mac: String(mac) }).then(async (id) => {
|
||||||
await disconnectDeviceForUser(String(username), 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({
|
pushRequest({
|
||||||
id: Date.now() + ':' + Math.random().toString(16).slice(2),
|
id: Date.now() + ':' + Math.random().toString(16).slice(2),
|
||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
|
|||||||
@@ -93,6 +93,36 @@ export async function ensureSchema() {
|
|||||||
END IF;
|
END IF;
|
||||||
END$$;
|
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');
|
await client.query('COMMIT');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
@@ -318,3 +348,245 @@ export async function deleteUserFromDb(username) {
|
|||||||
client.release();
|
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];
|
||||||
|
}
|
||||||
|
|||||||
49
postgres/init/03-sesiones.sql
Normal file
49
postgres/init/03-sesiones.sql
Normal file
@@ -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';
|
||||||
Reference in New Issue
Block a user