Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
Omar Sánchez Pizarro
2026-01-21 10:31:06 +01:00
parent c72ef29319
commit 7289ad6c26
10 changed files with 977 additions and 71 deletions

View File

@@ -206,6 +206,8 @@ const pushEnabled = ref(false);
const currentUser = ref(authService.getUsername() || null);
const isAdmin = ref(false);
let ws = null;
let heartbeatInterval = null;
let activityThrottleTimeout = null;
const isDark = computed(() => darkMode.value);
const isAuthenticated = computed(() => authService.hasCredentials());
@@ -335,6 +337,13 @@ onMounted(async () => {
window.addEventListener('auth-login', 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
if (authService.hasCredentials()) {
// Validar si el token sigue siendo válido
@@ -358,6 +367,15 @@ onUnmounted(() => {
window.removeEventListener('auth-login', 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) {
ws.close();
}
@@ -370,6 +388,12 @@ function connectWebSocket() {
ws = null;
}
// Limpiar heartbeat interval previo
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
// Verificar si hay token de autenticación
const token = authService.getToken();
if (!token) {
@@ -404,11 +428,17 @@ function connectWebSocket() {
ws.onopen = () => {
wsConnected.value = true;
console.log('WebSocket conectado');
// Iniciar heartbeat cada 30 segundos
startHeartbeat();
};
ws.onclose = (event) => {
wsConnected.value = false;
// Detener heartbeat
stopHeartbeat();
// Si el cierre fue por autenticación fallida (código 1008), no reintentar
if (event.code === 1008) {
console.log('WebSocket cerrado: autenticación fallida');
@@ -434,9 +464,73 @@ function connectWebSocket() {
ws.onmessage = (event) => {
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)
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>

View 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>

View File

@@ -155,6 +155,11 @@ export default {
return response.data;
},
async getActiveUsers() {
const response = await api.get('/users/active');
return response.data;
},
// Admin - Rate Limiter
async getRateLimiterInfo() {
const response = await api.get('/admin/rate-limiter');

View File

@@ -106,6 +106,11 @@
</div>
</div>
<!-- Active Users (Solo para admin) -->
<div v-if="isAdmin">
<ActiveUsers />
</div>
<!-- Charts and Quick Actions -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Platform Distribution -->
@@ -229,6 +234,7 @@
import { ref, onMounted, onUnmounted } from 'vue';
import api from '../services/api';
import authService from '../services/auth';
import ActiveUsers from '../components/ActiveUsers.vue';
import {
Cog6ToothIcon,
HeartIcon,

View File

@@ -22,7 +22,7 @@
<div v-else>
<!-- 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="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">
@@ -30,13 +30,25 @@
</div>
</div>
<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">
{{ sessionsData.stats.active || 0 }}
</div>
</div>
<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">
{{ sessionsData.stats.expired || 0 }}
</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">
Dispositivo
</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">
Token
</th>
@@ -118,6 +133,19 @@
Sin información
</div>
</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">
<span class="truncate max-w-xs inline-block" :title="session.token">
{{ session.token.substring(0, 16) }}...
@@ -130,13 +158,26 @@
{{ formatDate(session.expiresAt) }}
</td>
<td class="px-4 py-3 whitespace-nowrap">
<span
: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-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'"
>
{{ session.isExpired ? 'Expirada' : 'Activa' }}
</span>
<div class="flex flex-col gap-1">
<!-- Estado de sesión (válida o expirada) -->
<span
: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-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'"
>
{{ session.isExpired ? '🔴 Expirada' : '✅ Válida' }}
</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 class="px-4 py-3 whitespace-nowrap text-sm">
<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) {
if (!deviceInfo) return 'Unknown';

View File

@@ -34,64 +34,54 @@
<div
v-for="user in users"
: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">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{{ user.username }}</h3>
<span
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"
>
</span>
<span
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"
>
Admin
</span>
<!-- Header con nombre y badges -->
<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-3">
<!-- 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 class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600 dark:text-gray-400 mb-3">
<div v-if="user.createdAt">
<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
:class="userSubscriptions[user.username].subscription?.status === 'active' ? 'badge badge-success' : 'badge badge-warning'"
class="text-xs"
<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
v-if="user.username === currentUser"
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"
>
{{ userSubscriptions[user.username].subscription?.status === 'active' ? 'Activo' : 'Inactivo' }}
</span>
<span
v-if="user.role === 'admin'"
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
</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>
<!-- Estado de conexión inline -->
<div v-if="isAdmin" class="flex items-center gap-2">
<span
:class="{
'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'
}"
>
{{ 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>
</div>
</div>
</div>
<div class="flex gap-2 flex-wrap">
<!-- Botones de acción -->
<div class="flex gap-2">
<button
v-if="isAdmin"
@click="openSubscriptionModal(user.username)"
@@ -110,6 +100,73 @@
</button>
</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>
@@ -419,6 +476,8 @@ const availablePlans = ref([]);
const subscriptionError = ref('');
const subscriptionSuccess = ref('');
const addError = ref('');
const activeUsers = ref([]);
const userSessions = ref({});
const userForm = ref({
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() {
loading.value = true;
try {
@@ -466,7 +599,10 @@ async function loadUsers() {
// Cargar información de suscripción para todos los usuarios (solo si es admin)
if (isAdmin.value) {
await loadAllUserSubscriptions();
await Promise.all([
loadAllUserSubscriptions(),
loadUserSessions(),
]);
}
} catch (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() {
// Cargar suscripciones de todos los usuarios en paralelo
const subscriptionPromises = users.value.map(async (user) => {
@@ -489,10 +648,31 @@ async function loadAllUserSubscriptions() {
});
if (response.ok) {
const data = await response.json();
console.log(`Suscripción de ${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) {
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);
@@ -687,8 +867,22 @@ function handleAuthLogout() {
showAddModal.value = false;
userToDelete.value = null;
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(() => {
loadUsers();
window.addEventListener('auth-logout', handleAuthLogout);
@@ -696,11 +890,26 @@ onMounted(() => {
window.addEventListener('auth-login', () => {
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(() => {
window.removeEventListener('auth-logout', handleAuthLogout);
window.removeEventListener('auth-login', loadUsers);
window.removeEventListener('user-status-change', handleUserStatusChange);
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
</script>