activity
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
@@ -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"
|
||||
>
|
||||
Tú
|
||||
</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' }}
|
||||
Tú
|
||||
</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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user