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 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) {
|
||||
<CardTitle>Usuarios y Dispositivos</CardTitle>
|
||||
<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-else>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>
|
||||
<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>
|
||||
<Badge v-else-if="layoutMode==='device'">Página {{ devicePage+1 }} / {{ Math.max(1, Math.ceil(devicesAll.length / pageSize)) }}</Badge>
|
||||
<Badge v-else>Página {{ sessionPage+1 }} / {{ Math.max(1, Math.ceil(sessions.length / pageSize)) }}</Badge>
|
||||
<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'">
|
||||
<img class="size-4 opacity-90" src="/icons/layout-users.svg" alt="usuarios"/> Usuarios
|
||||
</Button>
|
||||
<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
|
||||
</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">
|
||||
<img class="size-4 opacity-90" src="/icons/filter.svg" alt="filtro"/>
|
||||
</Button>
|
||||
@@ -489,12 +522,80 @@ async function handleUserFormSubmit(data) {
|
||||
@toggleDisable="toggleDisable" @remove="removeUser" @edit="openEditUser"
|
||||
@disconnect="disconnectUser" @edit-device="openDeviceForm" />
|
||||
</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"
|
||||
:expanded="!!deviceExpanded[d.id]"
|
||||
@toggle-expand="deviceExpanded[d.id] = !deviceExpanded[d.id]"
|
||||
@edit="openDeviceForm" @disconnect="disconnectDevice" />
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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(() => {
|
||||
/>
|
||||
</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>
|
||||
</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 { 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(() => {
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user