activity
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
@@ -30,6 +30,8 @@ router.get('/sessions', basicAuthMiddleware, adminAuthMiddleware, async (req, re
|
|||||||
const sessionsByUser = {};
|
const sessionsByUser = {};
|
||||||
let activeSessions = 0;
|
let activeSessions = 0;
|
||||||
let expiredSessions = 0;
|
let expiredSessions = 0;
|
||||||
|
let connectedSessions = 0; // Sesiones con actividad reciente
|
||||||
|
let inactiveSessions = 0; // Sesiones sin actividad pero no expiradas
|
||||||
|
|
||||||
sessions.forEach(session => {
|
sessions.forEach(session => {
|
||||||
if (!sessionsByUser[session.username]) {
|
if (!sessionsByUser[session.username]) {
|
||||||
@@ -41,6 +43,13 @@ router.get('/sessions', basicAuthMiddleware, adminAuthMiddleware, async (req, re
|
|||||||
expiredSessions++;
|
expiredSessions++;
|
||||||
} else {
|
} else {
|
||||||
activeSessions++;
|
activeSessions++;
|
||||||
|
|
||||||
|
// Contar sesiones conectadas (con actividad reciente)
|
||||||
|
if (session.isActive) {
|
||||||
|
connectedSessions++;
|
||||||
|
} else {
|
||||||
|
inactiveSessions++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,6 +59,8 @@ router.get('/sessions', basicAuthMiddleware, adminAuthMiddleware, async (req, re
|
|||||||
total: sessions.length,
|
total: sessions.length,
|
||||||
active: activeSessions,
|
active: activeSessions,
|
||||||
expired: expiredSessions,
|
expired: expiredSessions,
|
||||||
|
connected: connectedSessions,
|
||||||
|
inactive: inactiveSessions,
|
||||||
byUser: sessionsByUser,
|
byUser: sessionsByUser,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import { getDB, getUser, createUser, deleteUser as deleteUserFromDB, getAllUsers, updateUserPassword } from '../services/mongodb.js';
|
import { getDB, getUser, createUser, deleteUser as deleteUserFromDB, getAllUsers, updateUserPassword, getActiveSessions } from '../services/mongodb.js';
|
||||||
import { basicAuthMiddleware, createSession, invalidateSession, invalidateUserSessions } from '../middlewares/auth.js';
|
import { basicAuthMiddleware, createSession, invalidateSession, invalidateUserSessions } from '../middlewares/auth.js';
|
||||||
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
|
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
|
||||||
import { combineFingerprint } from '../utils/fingerprint.js';
|
import { combineFingerprint } from '../utils/fingerprint.js';
|
||||||
|
import { getActiveUsers } from '../services/websocket.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -375,5 +376,71 @@ router.delete('/:username', basicAuthMiddleware, adminAuthMiddleware, async (req
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Obtener usuarios activos/conectados (requiere autenticación)
|
||||||
|
router.get('/active', basicAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDB();
|
||||||
|
if (!db) {
|
||||||
|
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener usuarios activos del WebSocket
|
||||||
|
const activeUsersWS = getActiveUsers();
|
||||||
|
|
||||||
|
// Obtener sesiones activas de la base de datos
|
||||||
|
const activeSessions = await getActiveSessions();
|
||||||
|
|
||||||
|
// Combinar información y eliminar duplicados
|
||||||
|
const userMap = new Map();
|
||||||
|
|
||||||
|
// Añadir usuarios del WebSocket
|
||||||
|
for (const user of activeUsersWS) {
|
||||||
|
if (!userMap.has(user.username)) {
|
||||||
|
userMap.set(user.username, {
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
status: user.status,
|
||||||
|
lastActivity: user.lastActivity,
|
||||||
|
connectedViaWebSocket: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Añadir usuarios de sesiones activas (que podrían no estar en WebSocket)
|
||||||
|
for (const session of activeSessions) {
|
||||||
|
if (!userMap.has(session.username)) {
|
||||||
|
const user = await getUser(session.username);
|
||||||
|
userMap.set(session.username, {
|
||||||
|
username: session.username,
|
||||||
|
role: user?.role || 'user',
|
||||||
|
status: 'active',
|
||||||
|
lastActivity: session.lastActivity?.toISOString() || null,
|
||||||
|
connectedViaWebSocket: false,
|
||||||
|
deviceInfo: session.deviceInfo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir a array
|
||||||
|
const activeUsers = Array.from(userMap.values());
|
||||||
|
|
||||||
|
// Ordenar por última actividad (más recientes primero)
|
||||||
|
activeUsers.sort((a, b) => {
|
||||||
|
const dateA = a.lastActivity ? new Date(a.lastActivity).getTime() : 0;
|
||||||
|
const dateB = b.lastActivity ? new Date(b.lastActivity).getTime() : 0;
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
activeUsers,
|
||||||
|
total: activeUsers.length,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error obteniendo usuarios activos:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|||||||
@@ -1101,6 +1101,30 @@ export async function getSession(token) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Actualizar actividad de una sesión
|
||||||
|
export async function updateSessionActivity(token, isActive = true) {
|
||||||
|
if (!db) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionsCollection = db.collection('sessions');
|
||||||
|
await sessionsCollection.updateOne(
|
||||||
|
{ token },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
lastActivity: new Date(),
|
||||||
|
isActive: isActive,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error actualizando actividad de sesión:', error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteSession(token) {
|
export async function deleteSession(token) {
|
||||||
if (!db) {
|
if (!db) {
|
||||||
return false;
|
return false;
|
||||||
@@ -1139,21 +1163,63 @@ export async function getAllSessions() {
|
|||||||
try {
|
try {
|
||||||
const sessionsCollection = db.collection('sessions');
|
const sessionsCollection = db.collection('sessions');
|
||||||
const sessions = await sessionsCollection.find({}).toArray();
|
const sessions = await sessionsCollection.find({}).toArray();
|
||||||
return sessions.map(session => ({
|
const now = new Date();
|
||||||
|
const INACTIVE_TIMEOUT = 5 * 60 * 1000; // 5 minutos
|
||||||
|
|
||||||
|
return sessions.map(session => {
|
||||||
|
const isExpired = session.expiresAt ? new Date(session.expiresAt) < now : false;
|
||||||
|
const timeSinceActivity = session.lastActivity ? now - new Date(session.lastActivity) : null;
|
||||||
|
const isActive = session.isActive && timeSinceActivity && timeSinceActivity < INACTIVE_TIMEOUT;
|
||||||
|
|
||||||
|
return {
|
||||||
token: session.token,
|
token: session.token,
|
||||||
username: session.username,
|
username: session.username,
|
||||||
fingerprint: session.fingerprint || null,
|
fingerprint: session.fingerprint || null,
|
||||||
deviceInfo: session.deviceInfo || null,
|
deviceInfo: session.deviceInfo || null,
|
||||||
createdAt: session.createdAt,
|
createdAt: session.createdAt,
|
||||||
expiresAt: session.expiresAt,
|
expiresAt: session.expiresAt,
|
||||||
isExpired: session.expiresAt ? new Date(session.expiresAt) < new Date() : false,
|
lastActivity: session.lastActivity || null,
|
||||||
}));
|
isActive: isActive || false,
|
||||||
|
isExpired: isExpired,
|
||||||
|
};
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error obteniendo todas las sesiones:', error.message);
|
console.error('Error obteniendo todas las sesiones:', error.message);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Obtener sesiones activas (usuarios conectados)
|
||||||
|
export async function getActiveSessions() {
|
||||||
|
if (!db) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionsCollection = db.collection('sessions');
|
||||||
|
const now = new Date();
|
||||||
|
const INACTIVE_TIMEOUT = 5 * 60 * 1000; // 5 minutos
|
||||||
|
const recentTime = new Date(now - INACTIVE_TIMEOUT);
|
||||||
|
|
||||||
|
// Buscar sesiones activas (con actividad reciente)
|
||||||
|
const sessions = await sessionsCollection.find({
|
||||||
|
isActive: true,
|
||||||
|
lastActivity: { $gte: recentTime },
|
||||||
|
expiresAt: { $gt: now }
|
||||||
|
}).toArray();
|
||||||
|
|
||||||
|
return sessions.map(session => ({
|
||||||
|
username: session.username,
|
||||||
|
lastActivity: session.lastActivity,
|
||||||
|
deviceInfo: session.deviceInfo || null,
|
||||||
|
fingerprint: session.fingerprint || null,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error obteniendo sesiones activas:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Funciones para artículos
|
// Funciones para artículos
|
||||||
export async function getArticle(platform, id, currentUsername = null) {
|
export async function getArticle(platform, id, currentUsername = null) {
|
||||||
if (!db) {
|
if (!db) {
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
import { getDB, getSession, getUser, deleteSession as deleteSessionFromDB } from './mongodb.js';
|
import { getDB, getSession, getUser, deleteSession as deleteSessionFromDB, updateSessionActivity } from './mongodb.js';
|
||||||
|
|
||||||
let wss = null;
|
let wss = null;
|
||||||
|
|
||||||
// Duración de la sesión en milisegundos (24 horas)
|
// Duración de la sesión en milisegundos (24 horas)
|
||||||
const SESSION_DURATION = 24 * 60 * 60 * 1000;
|
const SESSION_DURATION = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Tiempo de inactividad para marcar como inactivo (5 minutos)
|
||||||
|
const INACTIVE_TIMEOUT = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// Intervalo para limpiar conexiones inactivas (1 minuto)
|
||||||
|
const CLEANUP_INTERVAL = 60 * 1000;
|
||||||
|
|
||||||
// Inicializar WebSocket Server
|
// Inicializar WebSocket Server
|
||||||
export function initWebSocket(server) {
|
export function initWebSocket(server) {
|
||||||
wss = new WebSocketServer({ server, path: '/ws' });
|
wss = new WebSocketServer({ server, path: '/ws' });
|
||||||
@@ -73,18 +79,90 @@ export function initWebSocket(server) {
|
|||||||
role: user.role || 'user',
|
role: user.role || 'user',
|
||||||
token: token
|
token: token
|
||||||
};
|
};
|
||||||
|
ws.isAlive = true;
|
||||||
|
ws.lastActivity = new Date();
|
||||||
|
|
||||||
|
// Actualizar estado de conexión en la base de datos
|
||||||
|
await updateSessionActivity(token, true);
|
||||||
|
|
||||||
console.log(`Cliente WebSocket conectado: ${session.username} (${user.role || 'user'})`);
|
console.log(`Cliente WebSocket conectado: ${session.username} (${user.role || 'user'})`);
|
||||||
|
|
||||||
|
// Enviar confirmación de conexión
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'connection',
|
||||||
|
status: 'connected',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Broadcast a otros clientes que este usuario se conectó
|
||||||
|
broadcastUserStatus(session.username, 'online');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error validando token WebSocket:', error);
|
console.error('Error validando token WebSocket:', error);
|
||||||
ws.close(1011, 'Error de autenticación');
|
ws.close(1011, 'Error de autenticación');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.on('close', () => {
|
// Manejar mensajes del cliente
|
||||||
|
ws.on('message', async (data) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(data.toString());
|
||||||
|
|
||||||
|
// Manejar diferentes tipos de mensajes
|
||||||
|
switch (message.type) {
|
||||||
|
case 'ping':
|
||||||
|
case 'heartbeat':
|
||||||
|
// Actualizar actividad
|
||||||
|
ws.isAlive = true;
|
||||||
|
ws.lastActivity = new Date();
|
||||||
|
|
||||||
|
// Actualizar en base de datos
|
||||||
|
if (ws.user && ws.user.token) {
|
||||||
|
await updateSessionActivity(ws.user.token, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responder con pong
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'pong',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'activity':
|
||||||
|
// El usuario realizó alguna actividad (click, scroll, etc.)
|
||||||
|
ws.isAlive = true;
|
||||||
|
ws.lastActivity = new Date();
|
||||||
|
|
||||||
|
if (ws.user && ws.user.token) {
|
||||||
|
await updateSessionActivity(ws.user.token, true);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(`Mensaje WebSocket no manejado: ${message.type}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error procesando mensaje WebSocket:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manejar pong nativo de WebSocket
|
||||||
|
ws.on('pong', () => {
|
||||||
|
ws.isAlive = true;
|
||||||
|
ws.lastActivity = new Date();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', async () => {
|
||||||
if (ws.user) {
|
if (ws.user) {
|
||||||
console.log(`Cliente WebSocket desconectado: ${ws.user.username}`);
|
console.log(`Cliente WebSocket desconectado: ${ws.user.username}`);
|
||||||
|
|
||||||
|
// Actualizar estado en base de datos
|
||||||
|
if (ws.user.token) {
|
||||||
|
await updateSessionActivity(ws.user.token, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast a otros clientes que este usuario se desconectó
|
||||||
|
broadcastUserStatus(ws.user.username, 'offline');
|
||||||
} else {
|
} else {
|
||||||
console.log('Cliente WebSocket desconectado');
|
console.log('Cliente WebSocket desconectado');
|
||||||
}
|
}
|
||||||
@@ -95,9 +173,81 @@ export function initWebSocket(server) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Iniciar limpieza periódica de conexiones inactivas
|
||||||
|
startCleanupInterval();
|
||||||
|
|
||||||
return wss;
|
return wss;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Limpiar conexiones inactivas periódicamente
|
||||||
|
let cleanupIntervalId = null;
|
||||||
|
|
||||||
|
function startCleanupInterval() {
|
||||||
|
if (cleanupIntervalId) {
|
||||||
|
clearInterval(cleanupIntervalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupIntervalId = setInterval(() => {
|
||||||
|
if (!wss) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
wss.clients.forEach(async (ws) => {
|
||||||
|
// Si el cliente no ha respondido, marcarlo como inactivo
|
||||||
|
if (ws.isAlive === false) {
|
||||||
|
console.log(`Cerrando conexión inactiva: ${ws.user?.username || 'desconocido'}`);
|
||||||
|
|
||||||
|
// Actualizar estado en base de datos antes de cerrar
|
||||||
|
if (ws.user && ws.user.token) {
|
||||||
|
await updateSessionActivity(ws.user.token, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ws.terminate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar si ha pasado el timeout de inactividad
|
||||||
|
if (ws.lastActivity && (now - ws.lastActivity) > INACTIVE_TIMEOUT) {
|
||||||
|
console.log(`Usuario inactivo detectado: ${ws.user?.username || 'desconocido'}`);
|
||||||
|
|
||||||
|
// Actualizar estado en base de datos pero mantener conexión
|
||||||
|
if (ws.user && ws.user.token) {
|
||||||
|
await updateSessionActivity(ws.user.token, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marcar como no vivo y enviar ping
|
||||||
|
ws.isAlive = false;
|
||||||
|
ws.ping();
|
||||||
|
});
|
||||||
|
}, CLEANUP_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detener limpieza
|
||||||
|
function stopCleanupInterval() {
|
||||||
|
if (cleanupIntervalId) {
|
||||||
|
clearInterval(cleanupIntervalId);
|
||||||
|
cleanupIntervalId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast del estado de un usuario específico
|
||||||
|
function broadcastUserStatus(username, status) {
|
||||||
|
if (!wss) return;
|
||||||
|
|
||||||
|
const message = JSON.stringify({
|
||||||
|
type: 'user_status',
|
||||||
|
username: username,
|
||||||
|
status: status, // 'online' | 'offline' | 'inactive'
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
wss.clients.forEach((client) => {
|
||||||
|
if (client.readyState === 1) { // WebSocket.OPEN
|
||||||
|
client.send(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Broadcast a todos los clientes WebSocket
|
// Broadcast a todos los clientes WebSocket
|
||||||
export function broadcast(data) {
|
export function broadcast(data) {
|
||||||
if (!wss) return;
|
if (!wss) return;
|
||||||
@@ -114,3 +264,51 @@ export function getWebSocketServer() {
|
|||||||
return wss;
|
return wss;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Obtener usuarios activos conectados
|
||||||
|
export function getActiveUsers() {
|
||||||
|
if (!wss) return [];
|
||||||
|
|
||||||
|
const activeUsers = [];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
wss.clients.forEach((ws) => {
|
||||||
|
if (ws.user && ws.readyState === 1) { // WebSocket.OPEN
|
||||||
|
const isActive = ws.lastActivity && (now - ws.lastActivity) <= INACTIVE_TIMEOUT;
|
||||||
|
|
||||||
|
activeUsers.push({
|
||||||
|
username: ws.user.username,
|
||||||
|
role: ws.user.role,
|
||||||
|
status: isActive ? 'active' : 'inactive',
|
||||||
|
lastActivity: ws.lastActivity?.toISOString() || null,
|
||||||
|
connectedAt: ws.lastActivity?.toISOString() || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Eliminar duplicados (un usuario puede tener múltiples conexiones)
|
||||||
|
const uniqueUsers = [];
|
||||||
|
const seenUsernames = new Set();
|
||||||
|
|
||||||
|
for (const user of activeUsers) {
|
||||||
|
if (!seenUsernames.has(user.username)) {
|
||||||
|
seenUsernames.add(user.username);
|
||||||
|
uniqueUsers.push(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cerrar todas las conexiones y limpiar
|
||||||
|
export function closeWebSocket() {
|
||||||
|
stopCleanupInterval();
|
||||||
|
|
||||||
|
if (wss) {
|
||||||
|
wss.clients.forEach((ws) => {
|
||||||
|
ws.close(1001, 'Servidor cerrando');
|
||||||
|
});
|
||||||
|
wss.close();
|
||||||
|
wss = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -206,6 +206,8 @@ const pushEnabled = ref(false);
|
|||||||
const currentUser = ref(authService.getUsername() || null);
|
const currentUser = ref(authService.getUsername() || null);
|
||||||
const isAdmin = ref(false);
|
const isAdmin = ref(false);
|
||||||
let ws = null;
|
let ws = null;
|
||||||
|
let heartbeatInterval = null;
|
||||||
|
let activityThrottleTimeout = null;
|
||||||
|
|
||||||
const isDark = computed(() => darkMode.value);
|
const isDark = computed(() => darkMode.value);
|
||||||
const isAuthenticated = computed(() => authService.hasCredentials());
|
const isAuthenticated = computed(() => authService.hasCredentials());
|
||||||
@@ -335,6 +337,13 @@ onMounted(async () => {
|
|||||||
window.addEventListener('auth-login', handleAuthChange);
|
window.addEventListener('auth-login', handleAuthChange);
|
||||||
window.addEventListener('auth-logout', handleAuthChange);
|
window.addEventListener('auth-logout', handleAuthChange);
|
||||||
|
|
||||||
|
// Escuchar eventos de actividad del usuario para enviar al servidor
|
||||||
|
// Estos eventos ayudan a mantener la sesión activa
|
||||||
|
window.addEventListener('click', sendUserActivity);
|
||||||
|
window.addEventListener('keydown', sendUserActivity);
|
||||||
|
window.addEventListener('scroll', sendUserActivity, { passive: true });
|
||||||
|
window.addEventListener('mousemove', sendUserActivity, { passive: true });
|
||||||
|
|
||||||
// Si hay credenciales, validar y conectar websocket
|
// Si hay credenciales, validar y conectar websocket
|
||||||
if (authService.hasCredentials()) {
|
if (authService.hasCredentials()) {
|
||||||
// Validar si el token sigue siendo válido
|
// Validar si el token sigue siendo válido
|
||||||
@@ -358,6 +367,15 @@ onUnmounted(() => {
|
|||||||
window.removeEventListener('auth-login', handleAuthChange);
|
window.removeEventListener('auth-login', handleAuthChange);
|
||||||
window.removeEventListener('auth-logout', handleAuthChange);
|
window.removeEventListener('auth-logout', handleAuthChange);
|
||||||
|
|
||||||
|
// Limpiar listeners de actividad
|
||||||
|
window.removeEventListener('click', sendUserActivity);
|
||||||
|
window.removeEventListener('keydown', sendUserActivity);
|
||||||
|
window.removeEventListener('scroll', sendUserActivity);
|
||||||
|
window.removeEventListener('mousemove', sendUserActivity);
|
||||||
|
|
||||||
|
// Detener heartbeat
|
||||||
|
stopHeartbeat();
|
||||||
|
|
||||||
if (ws) {
|
if (ws) {
|
||||||
ws.close();
|
ws.close();
|
||||||
}
|
}
|
||||||
@@ -370,6 +388,12 @@ function connectWebSocket() {
|
|||||||
ws = null;
|
ws = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Limpiar heartbeat interval previo
|
||||||
|
if (heartbeatInterval) {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
heartbeatInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Verificar si hay token de autenticación
|
// Verificar si hay token de autenticación
|
||||||
const token = authService.getToken();
|
const token = authService.getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -404,11 +428,17 @@ function connectWebSocket() {
|
|||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
wsConnected.value = true;
|
wsConnected.value = true;
|
||||||
console.log('WebSocket conectado');
|
console.log('WebSocket conectado');
|
||||||
|
|
||||||
|
// Iniciar heartbeat cada 30 segundos
|
||||||
|
startHeartbeat();
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
wsConnected.value = false;
|
wsConnected.value = false;
|
||||||
|
|
||||||
|
// Detener heartbeat
|
||||||
|
stopHeartbeat();
|
||||||
|
|
||||||
// Si el cierre fue por autenticación fallida (código 1008), no reintentar
|
// Si el cierre fue por autenticación fallida (código 1008), no reintentar
|
||||||
if (event.code === 1008) {
|
if (event.code === 1008) {
|
||||||
console.log('WebSocket cerrado: autenticación fallida');
|
console.log('WebSocket cerrado: autenticación fallida');
|
||||||
@@ -434,9 +464,73 @@ function connectWebSocket() {
|
|||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
// Manejar respuesta de pong
|
||||||
|
if (data.type === 'pong') {
|
||||||
|
// El servidor respondió al heartbeat
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manejar confirmación de conexión
|
||||||
|
if (data.type === 'connection' && data.status === 'connected') {
|
||||||
|
console.log('Conexión WebSocket confirmada');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manejar cambios de estado de usuarios
|
||||||
|
if (data.type === 'user_status') {
|
||||||
|
console.log(`Usuario ${data.username} cambió a estado: ${data.status}`);
|
||||||
|
// Emitir evento para que otros componentes lo manejen
|
||||||
|
window.dispatchEvent(new CustomEvent('user-status-change', { detail: data }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Los componentes individuales manejarán los mensajes (incluyendo ToastContainer)
|
// Los componentes individuales manejarán los mensajes (incluyendo ToastContainer)
|
||||||
window.dispatchEvent(new CustomEvent('ws-message', { detail: data }));
|
window.dispatchEvent(new CustomEvent('ws-message', { detail: data }));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enviar heartbeat al servidor periódicamente
|
||||||
|
function startHeartbeat() {
|
||||||
|
// Detener heartbeat previo si existe
|
||||||
|
stopHeartbeat();
|
||||||
|
|
||||||
|
// Enviar heartbeat cada 30 segundos
|
||||||
|
heartbeatInterval = setInterval(() => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'heartbeat',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, 30000); // 30 segundos
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopHeartbeat() {
|
||||||
|
if (heartbeatInterval) {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
heartbeatInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enviar actividad del usuario al servidor (throttled)
|
||||||
|
function sendUserActivity() {
|
||||||
|
// Si ya hay un timeout pendiente, no hacer nada
|
||||||
|
if (activityThrottleTimeout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enviar actividad y crear throttle de 10 segundos
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'activity',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttle: no enviar más actividad por 10 segundos
|
||||||
|
activityThrottleTimeout = setTimeout(() => {
|
||||||
|
activityThrottleTimeout = null;
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
188
web/dashboard/src/components/ActiveUsers.vue
Normal file
188
web/dashboard/src/components/ActiveUsers.vue
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Usuarios Activos
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
@click="refreshUsers"
|
||||||
|
:disabled="loading"
|
||||||
|
class="p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||||
|
title="Actualizar"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon
|
||||||
|
class="w-5 h-5"
|
||||||
|
:class="{ 'animate-spin': loading }"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loading && !activeUsers.length" class="text-center py-8">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-gray-200 border-t-primary-600"></div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Cargando usuarios activos...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Active Users -->
|
||||||
|
<div v-else-if="!activeUsers.length" class="text-center py-8">
|
||||||
|
<UsersIcon class="w-12 h-12 mx-auto text-gray-400 mb-2" />
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">No hay usuarios activos en este momento</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Users List -->
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="user in activeUsers"
|
||||||
|
:key="user.username"
|
||||||
|
class="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-3 flex-1 min-w-0">
|
||||||
|
<!-- Status Indicator -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div
|
||||||
|
class="w-3 h-3 rounded-full"
|
||||||
|
:class="{
|
||||||
|
'bg-green-500': user.status === 'active',
|
||||||
|
'bg-yellow-500': user.status === 'inactive',
|
||||||
|
'bg-gray-400': user.status !== 'active' && user.status !== 'inactive'
|
||||||
|
}"
|
||||||
|
:title="user.status === 'active' ? 'Activo' : 'Inactivo'"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Info -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{{ user.username }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span v-if="user.lastActivity">
|
||||||
|
Última actividad: {{ formatTime(user.lastActivity) }}
|
||||||
|
</span>
|
||||||
|
<span v-else>Sin actividad reciente</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Role Badge -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
|
||||||
|
:class="{
|
||||||
|
'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400': user.role === 'admin',
|
||||||
|
'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400': user.role === 'user'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ user.role }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WebSocket Indicator -->
|
||||||
|
<div v-if="user.connectedViaWebSocket" class="flex-shrink-0" title="Conectado vía WebSocket">
|
||||||
|
<SignalIcon class="w-4 h-4 text-green-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div v-if="activeUsers.length" class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 text-center">
|
||||||
|
Total: {{ activeUsers.length }} {{ activeUsers.length === 1 ? 'usuario activo' : 'usuarios activos' }}
|
||||||
|
<span v-if="lastUpdate"> • Actualizado {{ formatTime(lastUpdate) }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { ArrowPathIcon, UsersIcon, SignalIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
const activeUsers = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const lastUpdate = ref(null);
|
||||||
|
let refreshInterval = null;
|
||||||
|
|
||||||
|
async function refreshUsers() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const data = await api.getActiveUsers();
|
||||||
|
activeUsers.value = data.activeUsers || [];
|
||||||
|
lastUpdate.value = new Date().toISOString();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error obteniendo usuarios activos:', error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timestamp) {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diffInSeconds = Math.floor((now - date) / 1000);
|
||||||
|
|
||||||
|
if (diffInSeconds < 60) {
|
||||||
|
return 'hace unos segundos';
|
||||||
|
} else if (diffInSeconds < 3600) {
|
||||||
|
const minutes = Math.floor(diffInSeconds / 60);
|
||||||
|
return `hace ${minutes} ${minutes === 1 ? 'minuto' : 'minutos'}`;
|
||||||
|
} else if (diffInSeconds < 86400) {
|
||||||
|
const hours = Math.floor(diffInSeconds / 3600);
|
||||||
|
return `hace ${hours} ${hours === 1 ? 'hora' : 'horas'}`;
|
||||||
|
} else {
|
||||||
|
return date.toLocaleString('es-ES', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar cuando un usuario cambie de estado (via WebSocket)
|
||||||
|
function handleUserStatusChange(event) {
|
||||||
|
const { username, status } = event.detail;
|
||||||
|
|
||||||
|
// Buscar usuario en la lista
|
||||||
|
const userIndex = activeUsers.value.findIndex(u => u.username === username);
|
||||||
|
|
||||||
|
if (status === 'online') {
|
||||||
|
// Si es online y no está en la lista, añadirlo
|
||||||
|
if (userIndex === -1) {
|
||||||
|
refreshUsers(); // Recargar lista completa
|
||||||
|
} else {
|
||||||
|
// Actualizar estado
|
||||||
|
activeUsers.value[userIndex].status = 'active';
|
||||||
|
activeUsers.value[userIndex].lastActivity = new Date().toISOString();
|
||||||
|
}
|
||||||
|
} else if (status === 'offline') {
|
||||||
|
// Si es offline, eliminarlo de la lista
|
||||||
|
if (userIndex !== -1) {
|
||||||
|
activeUsers.value.splice(userIndex, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Cargar usuarios activos inicialmente
|
||||||
|
refreshUsers();
|
||||||
|
|
||||||
|
// Actualizar cada 30 segundos
|
||||||
|
refreshInterval = setInterval(refreshUsers, 30000);
|
||||||
|
|
||||||
|
// Escuchar cambios de estado de usuarios
|
||||||
|
window.addEventListener('user-status-change', handleUserStatusChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (refreshInterval) {
|
||||||
|
clearInterval(refreshInterval);
|
||||||
|
}
|
||||||
|
window.removeEventListener('user-status-change', handleUserStatusChange);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
@@ -155,6 +155,11 @@ export default {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getActiveUsers() {
|
||||||
|
const response = await api.get('/users/active');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Admin - Rate Limiter
|
// Admin - Rate Limiter
|
||||||
async getRateLimiterInfo() {
|
async getRateLimiterInfo() {
|
||||||
const response = await api.get('/admin/rate-limiter');
|
const response = await api.get('/admin/rate-limiter');
|
||||||
|
|||||||
@@ -106,6 +106,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Users (Solo para admin) -->
|
||||||
|
<div v-if="isAdmin">
|
||||||
|
<ActiveUsers />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Charts and Quick Actions -->
|
<!-- Charts and Quick Actions -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<!-- Platform Distribution -->
|
<!-- Platform Distribution -->
|
||||||
@@ -229,6 +234,7 @@
|
|||||||
import { ref, onMounted, onUnmounted } from 'vue';
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import authService from '../services/auth';
|
import authService from '../services/auth';
|
||||||
|
import ActiveUsers from '../components/ActiveUsers.vue';
|
||||||
import {
|
import {
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
HeartIcon,
|
HeartIcon,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<!-- Estadísticas -->
|
<!-- Estadísticas -->
|
||||||
<div v-if="sessionsData && sessionsData.stats" class="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
|
<div v-if="sessionsData && sessionsData.stats" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Sesiones</div>
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Sesiones</div>
|
||||||
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
@@ -30,13 +30,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Sesiones Activas</div>
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Activas (Válidas)</div>
|
||||||
<div class="text-2xl font-bold text-green-600 dark:text-green-400">
|
<div class="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||||
{{ sessionsData.stats.active || 0 }}
|
{{ sessionsData.stats.active || 0 }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Sesiones Expiradas</div>
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">🟢 Conectadas</div>
|
||||||
|
<div class="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||||
|
{{ sessionsData.stats.connected || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">🟡 Inactivas</div>
|
||||||
|
<div class="text-2xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||||
|
{{ sessionsData.stats.inactive || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">🔴 Expiradas</div>
|
||||||
<div class="text-2xl font-bold text-red-600 dark:text-red-400">
|
<div class="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||||
{{ sessionsData.stats.expired || 0 }}
|
{{ sessionsData.stats.expired || 0 }}
|
||||||
</div>
|
</div>
|
||||||
@@ -79,6 +91,9 @@
|
|||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Dispositivo
|
Dispositivo
|
||||||
</th>
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Última Actividad
|
||||||
|
</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Token
|
Token
|
||||||
</th>
|
</th>
|
||||||
@@ -118,6 +133,19 @@
|
|||||||
Sin información
|
Sin información
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
<div v-if="session.lastActivity" class="space-y-1">
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ formatRelativeTime(session.lastActivity) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ formatDate(session.lastActivity) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-gray-400 dark:text-gray-500 italic">
|
||||||
|
Sin actividad
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td class="px-4 py-3 text-sm font-mono text-gray-600 dark:text-gray-400">
|
<td class="px-4 py-3 text-sm font-mono text-gray-600 dark:text-gray-400">
|
||||||
<span class="truncate max-w-xs inline-block" :title="session.token">
|
<span class="truncate max-w-xs inline-block" :title="session.token">
|
||||||
{{ session.token.substring(0, 16) }}...
|
{{ session.token.substring(0, 16) }}...
|
||||||
@@ -130,13 +158,26 @@
|
|||||||
{{ formatDate(session.expiresAt) }}
|
{{ formatDate(session.expiresAt) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap">
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<!-- Estado de sesión (válida o expirada) -->
|
||||||
<span
|
<span
|
||||||
:class="session.isExpired
|
:class="session.isExpired
|
||||||
? 'px-2 py-1 text-xs font-semibold rounded bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
|
? 'px-2 py-1 text-xs font-semibold rounded bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
|
||||||
: 'px-2 py-1 text-xs font-semibold rounded bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'"
|
: 'px-2 py-1 text-xs font-semibold rounded bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'"
|
||||||
>
|
>
|
||||||
{{ session.isExpired ? 'Expirada' : 'Activa' }}
|
{{ session.isExpired ? '🔴 Expirada' : '✅ Válida' }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<!-- Estado de conexión (conectado o inactivo) -->
|
||||||
|
<span
|
||||||
|
v-if="!session.isExpired"
|
||||||
|
:class="session.isActive
|
||||||
|
? 'px-2 py-1 text-xs font-semibold rounded bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
|
||||||
|
: 'px-2 py-1 text-xs font-semibold rounded bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200'"
|
||||||
|
>
|
||||||
|
{{ session.isActive ? '🟢 Conectado' : '🟡 Inactivo' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm">
|
<td class="px-4 py-3 whitespace-nowrap text-sm">
|
||||||
<button
|
<button
|
||||||
@@ -192,6 +233,27 @@ function formatDate(dateString) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(timestamp) {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diffInSeconds = Math.floor((now - date) / 1000);
|
||||||
|
|
||||||
|
if (diffInSeconds < 60) {
|
||||||
|
return 'Hace unos segundos';
|
||||||
|
} else if (diffInSeconds < 3600) {
|
||||||
|
const minutes = Math.floor(diffInSeconds / 60);
|
||||||
|
return `Hace ${minutes} ${minutes === 1 ? 'minuto' : 'minutos'}`;
|
||||||
|
} else if (diffInSeconds < 86400) {
|
||||||
|
const hours = Math.floor(diffInSeconds / 3600);
|
||||||
|
return `Hace ${hours} ${hours === 1 ? 'hora' : 'horas'}`;
|
||||||
|
} else {
|
||||||
|
const days = Math.floor(diffInSeconds / 86400);
|
||||||
|
return `Hace ${days} ${days === 1 ? 'día' : 'días'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatDeviceInfo(deviceInfo) {
|
function formatDeviceInfo(deviceInfo) {
|
||||||
if (!deviceInfo) return 'Unknown';
|
if (!deviceInfo) return 'Unknown';
|
||||||
|
|
||||||
|
|||||||
@@ -34,64 +34,54 @@
|
|||||||
<div
|
<div
|
||||||
v-for="user in users"
|
v-for="user in users"
|
||||||
:key="user.username"
|
:key="user.username"
|
||||||
class="card hover:shadow-lg transition-shadow"
|
class="card hover:shadow-lg transition-shadow overflow-hidden"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
<!-- Header con nombre y badges -->
|
||||||
<div class="flex-1">
|
<div class="flex items-center justify-between mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-3">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{{ user.username }}</h3>
|
<!-- Avatar -->
|
||||||
|
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center text-white font-bold text-lg">
|
||||||
|
{{ user.username.charAt(0).toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">{{ user.username }}</h3>
|
||||||
<span
|
<span
|
||||||
v-if="user.username === currentUser"
|
v-if="user.username === currentUser"
|
||||||
class="px-2 py-1 text-xs font-semibold rounded bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
|
class="px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
|
||||||
>
|
>
|
||||||
Tú
|
Tú
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="user.role === 'admin'"
|
v-if="user.role === 'admin'"
|
||||||
class="px-2 py-1 text-xs font-semibold rounded bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200"
|
class="px-2 py-0.5 text-xs font-semibold rounded-full bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200"
|
||||||
>
|
>
|
||||||
Admin
|
Admin
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600 dark:text-gray-400 mb-3">
|
<!-- Estado de conexión inline -->
|
||||||
<div v-if="user.createdAt">
|
<div v-if="isAdmin" class="flex items-center gap-2">
|
||||||
<span class="font-medium">Creado:</span>
|
|
||||||
<span class="ml-2">{{ formatDate(user.createdAt) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="user.createdBy">
|
|
||||||
<span class="font-medium">Por:</span>
|
|
||||||
<span class="ml-2">{{ user.createdBy }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="user.updatedAt">
|
|
||||||
<span class="font-medium">Actualizado:</span>
|
|
||||||
<span class="ml-2">{{ formatDate(user.updatedAt) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Información de suscripción -->
|
|
||||||
<div v-if="userSubscriptions[user.username]" class="mt-3 p-3 bg-gradient-to-r from-primary-50 to-teal-50 dark:from-primary-900/20 dark:to-teal-900/20 rounded-lg border border-primary-200 dark:border-primary-800">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<span class="text-xs font-semibold text-gray-700 dark:text-gray-300">Plan:</span>
|
|
||||||
<span class="ml-2 text-sm font-bold text-primary-700 dark:text-primary-400">
|
|
||||||
{{ userSubscriptions[user.username].plan?.name || 'Gratis' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span
|
<span
|
||||||
:class="userSubscriptions[user.username].subscription?.status === 'active' ? 'badge badge-success' : 'badge badge-warning'"
|
:class="{
|
||||||
class="text-xs"
|
'px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200': getUserConnectionStatus(user.username) === 'active',
|
||||||
|
'px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200': getUserConnectionStatus(user.username) === 'inactive',
|
||||||
|
'px-2 py-0.5 text-xs font-semibold rounded-full bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300': getUserConnectionStatus(user.username) === 'offline'
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
{{ userSubscriptions[user.username].subscription?.status === 'active' ? 'Activo' : 'Inactivo' }}
|
{{ getUserConnectionStatus(user.username) === 'active' ? '🟢 Conectado' : getUserConnectionStatus(user.username) === 'inactive' ? '🟡 Inactivo' : '⚫ Desconectado' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span v-if="getUserLastActivity(user.username)" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ formatRelativeTime(getUserLastActivity(user.username)) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
<div v-if="userSubscriptions[user.username].usage" class="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
<span>Uso: {{ userSubscriptions[user.username].usage.workers }} / {{ userSubscriptions[user.username].usage.maxWorkers === 'Ilimitado' ? '∞' : userSubscriptions[user.username].usage.maxWorkers }} búsquedas</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2 flex-wrap">
|
<!-- Botones de acción -->
|
||||||
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
v-if="isAdmin"
|
v-if="isAdmin"
|
||||||
@click="openSubscriptionModal(user.username)"
|
@click="openSubscriptionModal(user.username)"
|
||||||
@@ -110,6 +100,73 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid de información -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<!-- Columna izquierda: Información general -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Información</h4>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div v-if="user.createdAt" class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Creado:</span>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-gray-100">{{ formatDate(user.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="user.createdBy" class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Por:</span>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-gray-100">{{ user.createdBy }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="user.updatedAt" class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Actualizado:</span>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-gray-100">{{ formatDate(user.updatedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="isAdmin && getUserSessionCount(user.username) > 0" class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Sesiones activas:</span>
|
||||||
|
<span class="font-semibold text-primary-600 dark:text-primary-400">{{ getUserSessionCount(user.username) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Columna derecha: Suscripción -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Suscripción</h4>
|
||||||
|
<div v-if="userSubscriptions[user.username]" class="p-3 bg-gradient-to-br from-primary-50 to-teal-50 dark:from-primary-900/20 dark:to-teal-900/20 rounded-lg border border-primary-200 dark:border-primary-700">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400">Plan</p>
|
||||||
|
<p class="text-lg font-bold text-primary-700 dark:text-primary-300">
|
||||||
|
{{ userSubscriptions[user.username].subscription?.plan?.name || 'Gratis' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
:class="userSubscriptions[user.username].subscription?.status === 'active' ? 'badge badge-success' : 'badge badge-warning'"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
{{ userSubscriptions[user.username].subscription?.status === 'active' ? 'Activo' : 'Inactivo' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="userSubscriptions[user.username].usage" class="pt-2 border-t border-primary-200 dark:border-primary-800">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400">Uso de búsquedas</span>
|
||||||
|
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ userSubscriptions[user.username].usage.workers }} / {{ userSubscriptions[user.username].usage.maxWorkers === 'Ilimitado' ? '∞' : userSubscriptions[user.username].usage.maxWorkers }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Barra de progreso -->
|
||||||
|
<div v-if="userSubscriptions[user.username].usage.maxWorkers !== 'Ilimitado'" class="mt-2 w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
class="bg-gradient-to-r from-primary-500 to-primary-600 h-2 rounded-full transition-all"
|
||||||
|
:style="{ width: `${Math.min(100, (userSubscriptions[user.username].usage.workers / userSubscriptions[user.username].usage.maxWorkers) * 100)}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Sin información de suscripción
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -419,6 +476,8 @@ const availablePlans = ref([]);
|
|||||||
const subscriptionError = ref('');
|
const subscriptionError = ref('');
|
||||||
const subscriptionSuccess = ref('');
|
const subscriptionSuccess = ref('');
|
||||||
const addError = ref('');
|
const addError = ref('');
|
||||||
|
const activeUsers = ref([]);
|
||||||
|
const userSessions = ref({});
|
||||||
|
|
||||||
const userForm = ref({
|
const userForm = ref({
|
||||||
username: '',
|
username: '',
|
||||||
@@ -458,6 +517,80 @@ function formatDate(dateString) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(timestamp) {
|
||||||
|
if (!timestamp) return 'Nunca';
|
||||||
|
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diffInSeconds = Math.floor((now - date) / 1000);
|
||||||
|
|
||||||
|
if (diffInSeconds < 60) {
|
||||||
|
return 'Hace unos segundos';
|
||||||
|
} else if (diffInSeconds < 3600) {
|
||||||
|
const minutes = Math.floor(diffInSeconds / 60);
|
||||||
|
return `Hace ${minutes} ${minutes === 1 ? 'minuto' : 'minutos'}`;
|
||||||
|
} else if (diffInSeconds < 86400) {
|
||||||
|
const hours = Math.floor(diffInSeconds / 3600);
|
||||||
|
return `Hace ${hours} ${hours === 1 ? 'hora' : 'horas'}`;
|
||||||
|
} else {
|
||||||
|
const days = Math.floor(diffInSeconds / 86400);
|
||||||
|
return `Hace ${days} ${days === 1 ? 'día' : 'días'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener el estado de conexión de un usuario
|
||||||
|
function getUserConnectionStatus(username) {
|
||||||
|
// Buscar si el usuario tiene alguna sesión activa
|
||||||
|
const activeSessions = userSessions.value[username] || [];
|
||||||
|
|
||||||
|
if (activeSessions.length === 0) {
|
||||||
|
return 'offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar si alguna sesión está activamente conectada
|
||||||
|
const hasActiveSession = activeSessions.some(session => session.isActive);
|
||||||
|
if (hasActiveSession) {
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si tiene sesiones válidas pero ninguna activa
|
||||||
|
const hasValidSession = activeSessions.some(session => !session.isExpired);
|
||||||
|
if (hasValidSession) {
|
||||||
|
return 'inactive';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener la última actividad de un usuario (de todas sus sesiones)
|
||||||
|
function getUserLastActivity(username) {
|
||||||
|
const sessions = userSessions.value[username] || [];
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encontrar la sesión con la actividad más reciente
|
||||||
|
let latestActivity = null;
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (session.lastActivity) {
|
||||||
|
const activityDate = new Date(session.lastActivity);
|
||||||
|
if (!latestActivity || activityDate > latestActivity) {
|
||||||
|
latestActivity = activityDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestActivity ? latestActivity.toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener el número de sesiones activas de un usuario
|
||||||
|
function getUserSessionCount(username) {
|
||||||
|
const sessions = userSessions.value[username] || [];
|
||||||
|
return sessions.filter(s => !s.isExpired).length;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -466,7 +599,10 @@ async function loadUsers() {
|
|||||||
|
|
||||||
// Cargar información de suscripción para todos los usuarios (solo si es admin)
|
// Cargar información de suscripción para todos los usuarios (solo si es admin)
|
||||||
if (isAdmin.value) {
|
if (isAdmin.value) {
|
||||||
await loadAllUserSubscriptions();
|
await Promise.all([
|
||||||
|
loadAllUserSubscriptions(),
|
||||||
|
loadUserSessions(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cargando usuarios:', error);
|
console.error('Error cargando usuarios:', error);
|
||||||
@@ -476,6 +612,29 @@ async function loadUsers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadUserSessions() {
|
||||||
|
if (!isAdmin.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Obtener todas las sesiones
|
||||||
|
const data = await api.getSessions();
|
||||||
|
const sessions = data.sessions || [];
|
||||||
|
|
||||||
|
// Agrupar sesiones por usuario
|
||||||
|
const sessionsByUser = {};
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (!sessionsByUser[session.username]) {
|
||||||
|
sessionsByUser[session.username] = [];
|
||||||
|
}
|
||||||
|
sessionsByUser[session.username].push(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
userSessions.value = sessionsByUser;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cargando sesiones de usuarios:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAllUserSubscriptions() {
|
async function loadAllUserSubscriptions() {
|
||||||
// Cargar suscripciones de todos los usuarios en paralelo
|
// Cargar suscripciones de todos los usuarios en paralelo
|
||||||
const subscriptionPromises = users.value.map(async (user) => {
|
const subscriptionPromises = users.value.map(async (user) => {
|
||||||
@@ -489,10 +648,31 @@ async function loadAllUserSubscriptions() {
|
|||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
console.log(`Suscripción de ${user.username}:`, data);
|
||||||
userSubscriptions.value[user.username] = data;
|
userSubscriptions.value[user.username] = data;
|
||||||
|
} else {
|
||||||
|
console.warn(`No se pudo cargar suscripción de ${user.username}, usando valores por defecto`);
|
||||||
|
// Valores por defecto si no se puede cargar
|
||||||
|
userSubscriptions.value[user.username] = {
|
||||||
|
subscription: {
|
||||||
|
planId: 'free',
|
||||||
|
status: 'active',
|
||||||
|
plan: { name: 'Gratis', description: 'Plan gratuito' }
|
||||||
|
},
|
||||||
|
usage: { workers: 0, maxWorkers: 2 }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error cargando suscripción de ${user.username}:`, error);
|
console.error(`Error cargando suscripción de ${user.username}:`, error);
|
||||||
|
// Valores por defecto en caso de error
|
||||||
|
userSubscriptions.value[user.username] = {
|
||||||
|
subscription: {
|
||||||
|
planId: 'free',
|
||||||
|
status: 'active',
|
||||||
|
plan: { name: 'Gratis', description: 'Plan gratuito' }
|
||||||
|
},
|
||||||
|
usage: { workers: 0, maxWorkers: 2 }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await Promise.all(subscriptionPromises);
|
await Promise.all(subscriptionPromises);
|
||||||
@@ -687,8 +867,22 @@ function handleAuthLogout() {
|
|||||||
showAddModal.value = false;
|
showAddModal.value = false;
|
||||||
userToDelete.value = null;
|
userToDelete.value = null;
|
||||||
addError.value = '';
|
addError.value = '';
|
||||||
|
activeUsers.value = [];
|
||||||
|
userSessions.value = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manejar cambios de estado de usuarios vía WebSocket
|
||||||
|
function handleUserStatusChange(event) {
|
||||||
|
const { username, status } = event.detail;
|
||||||
|
|
||||||
|
// Recargar sesiones para actualizar el estado
|
||||||
|
if (isAdmin.value) {
|
||||||
|
loadUserSessions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshInterval = null;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
window.addEventListener('auth-logout', handleAuthLogout);
|
window.addEventListener('auth-logout', handleAuthLogout);
|
||||||
@@ -696,11 +890,26 @@ onMounted(() => {
|
|||||||
window.addEventListener('auth-login', () => {
|
window.addEventListener('auth-login', () => {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Escuchar cambios de estado de usuarios
|
||||||
|
window.addEventListener('user-status-change', handleUserStatusChange);
|
||||||
|
|
||||||
|
// Actualizar sesiones periódicamente (cada 30 segundos)
|
||||||
|
if (isAdmin.value) {
|
||||||
|
refreshInterval = setInterval(() => {
|
||||||
|
loadUserSessions();
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('auth-logout', handleAuthLogout);
|
window.removeEventListener('auth-logout', handleAuthLogout);
|
||||||
window.removeEventListener('auth-login', loadUsers);
|
window.removeEventListener('auth-login', loadUsers);
|
||||||
|
window.removeEventListener('user-status-change', handleUserStatusChange);
|
||||||
|
|
||||||
|
if (refreshInterval) {
|
||||||
|
clearInterval(refreshInterval);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user