fix:logs and new articles
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
@@ -63,6 +63,15 @@ async function initRedis() {
|
||||
redisClient.on('error', (err) => console.error('Redis Client Error', err));
|
||||
await redisClient.connect();
|
||||
console.log('✅ Conectado a Redis');
|
||||
|
||||
// Inicializar claves conocidas para evitar notificar artículos existentes
|
||||
try {
|
||||
const initialKeys = await redisClient.keys('notified:*');
|
||||
notifiedArticleKeys = new Set(initialKeys);
|
||||
console.log(`📋 ${notifiedArticleKeys.size} artículos ya notificados detectados`);
|
||||
} catch (error) {
|
||||
console.error('Error inicializando claves de artículos:', error.message);
|
||||
}
|
||||
} else {
|
||||
console.log('ℹ️ Redis no configurado, usando modo memoria');
|
||||
}
|
||||
@@ -446,7 +455,7 @@ app.get('/api/articles/search', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener logs (últimas líneas)
|
||||
// Obtener logs (últimas líneas o nuevas líneas desde un número de línea)
|
||||
app.get('/api/logs', (req, res) => {
|
||||
try {
|
||||
// Intentar múltiples ubicaciones posibles
|
||||
@@ -457,7 +466,7 @@ app.get('/api/logs', (req, res) => {
|
||||
if (existsSync(altPath)) {
|
||||
logFile = altPath;
|
||||
} else {
|
||||
return res.json({ logs: [] });
|
||||
return res.json({ logs: [], totalLines: 0, lastLineNumber: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,19 +474,37 @@ app.get('/api/logs', (req, res) => {
|
||||
try {
|
||||
const stats = statSync(logFile);
|
||||
if (stats.isDirectory()) {
|
||||
return res.json({ logs: ['Error: monitor.log es un directorio. Por favor, elimínalo y reinicia.'] });
|
||||
return res.json({ logs: ['Error: monitor.log es un directorio. Por favor, elimínalo y reinicia.'], totalLines: 0, lastLineNumber: 0 });
|
||||
}
|
||||
} catch (e) {
|
||||
return res.json({ logs: [] });
|
||||
return res.json({ logs: [], totalLines: 0, lastLineNumber: 0 });
|
||||
}
|
||||
|
||||
const logs = readFileSync(logFile, 'utf8');
|
||||
const lines = logs.split('\n').filter(l => l.trim());
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const lastLines = lines.slice(-limit);
|
||||
// Mantener orden natural: más antiguo a más reciente
|
||||
const logsContent = readFileSync(logFile, 'utf8');
|
||||
const allLines = logsContent.split('\n').filter(l => l.trim());
|
||||
const totalLines = allLines.length;
|
||||
|
||||
res.json({ logs: lastLines });
|
||||
// Si se proporciona since (número de línea desde el que empezar), devolver solo las nuevas
|
||||
const sinceLine = parseInt(req.query.since) || 0;
|
||||
|
||||
if (sinceLine > 0 && sinceLine < totalLines) {
|
||||
// Devolver solo las líneas nuevas después de sinceLine
|
||||
const newLines = allLines.slice(sinceLine);
|
||||
return res.json({
|
||||
logs: newLines,
|
||||
totalLines: totalLines,
|
||||
lastLineNumber: totalLines - 1 // Índice de la última línea
|
||||
});
|
||||
} else {
|
||||
// Carga inicial: devolver las últimas líneas
|
||||
const limit = parseInt(req.query.limit) || 500;
|
||||
const lastLines = allLines.slice(-limit);
|
||||
return res.json({
|
||||
logs: lastLines,
|
||||
totalLines: totalLines,
|
||||
lastLineNumber: totalLines - 1 // Índice de la última línea
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
@@ -580,8 +607,8 @@ if (!existsSync(watchLogPath)) {
|
||||
}
|
||||
}
|
||||
|
||||
// Watch files for changes (ya no vigilamos FAVORITES_PATH porque usa Redis)
|
||||
const watcher = watch([WORKERS_PATH, watchLogPath].filter(p => existsSync(p)), {
|
||||
// Watch files for changes (ya no vigilamos logs porque usa polling)
|
||||
const watcher = watch([WORKERS_PATH].filter(p => existsSync(p)), {
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
});
|
||||
@@ -591,17 +618,100 @@ watcher.on('change', async (path) => {
|
||||
if (path === WORKERS_PATH) {
|
||||
const workers = readJSON(WORKERS_PATH);
|
||||
broadcast({ type: 'workers_updated', data: workers });
|
||||
} else if (path === LOG_PATH) {
|
||||
broadcast({ type: 'logs_updated' });
|
||||
}
|
||||
});
|
||||
|
||||
// Rastrear artículos ya notificados para detectar nuevos
|
||||
let notifiedArticleKeys = new Set();
|
||||
let articlesCheckInterval = null;
|
||||
|
||||
// Función para detectar y enviar artículos nuevos
|
||||
async function checkForNewArticles() {
|
||||
if (!redisClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentKeys = await redisClient.keys('notified:*');
|
||||
const currentKeysSet = new Set(currentKeys);
|
||||
|
||||
// Encontrar claves nuevas
|
||||
const newKeys = currentKeys.filter(key => !notifiedArticleKeys.has(key));
|
||||
|
||||
if (newKeys.length > 0) {
|
||||
// Obtener los artículos nuevos
|
||||
const newArticles = [];
|
||||
for (const key of newKeys) {
|
||||
try {
|
||||
const value = await redisClient.get(key);
|
||||
if (value) {
|
||||
// Intentar parsear como JSON
|
||||
let articleData = {};
|
||||
try {
|
||||
articleData = JSON.parse(value);
|
||||
} catch (e) {
|
||||
// Si no es JSON válido, extraer información de la key
|
||||
const parts = key.split(':');
|
||||
if (parts.length >= 3) {
|
||||
articleData = {
|
||||
platform: parts[1],
|
||||
id: parts.slice(2).join(':'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Añadir información adicional si está disponible
|
||||
if (articleData.platform && articleData.id) {
|
||||
newArticles.push({
|
||||
platform: articleData.platform || 'unknown',
|
||||
id: articleData.id || 'unknown',
|
||||
title: articleData.title || null,
|
||||
price: articleData.price || null,
|
||||
currency: articleData.currency || '€',
|
||||
url: articleData.url || null,
|
||||
images: articleData.images || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error obteniendo artículo de Redis (${key}):`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Enviar artículos nuevos por WebSocket
|
||||
if (newArticles.length > 0) {
|
||||
broadcast({
|
||||
type: 'new_articles',
|
||||
data: newArticles
|
||||
});
|
||||
}
|
||||
|
||||
// Actualizar el set de claves notificadas
|
||||
notifiedArticleKeys = currentKeysSet;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error verificando artículos nuevos:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Inicializar el check de artículos cuando Redis esté listo
|
||||
async function startArticleMonitoring() {
|
||||
if (redisClient) {
|
||||
// Iniciar intervalo para verificar nuevos artículos cada 3 segundos
|
||||
articlesCheckInterval = setInterval(checkForNewArticles, 3000);
|
||||
console.log('✅ Monitoreo de artículos nuevos iniciado');
|
||||
}
|
||||
}
|
||||
|
||||
// Inicializar servidor
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
async function startServer() {
|
||||
await initRedis();
|
||||
|
||||
// Iniciar monitoreo de artículos nuevos
|
||||
await startArticleMonitoring();
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🚀 Servidor backend ejecutándose en http://localhost:${PORT}`);
|
||||
console.log(`📡 WebSocket disponible en ws://localhost:${PORT}`);
|
||||
|
||||
@@ -83,6 +83,65 @@
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- Toast notifications container -->
|
||||
<div class="fixed bottom-4 right-4 z-50 space-y-2">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 max-w-md min-w-[320px] animate-slide-in"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
v-if="toast.image"
|
||||
:src="toast.image"
|
||||
:alt="toast.title"
|
||||
class="w-16 h-16 object-cover rounded"
|
||||
@error="($event) => $event.target.style.display = 'none'"
|
||||
/>
|
||||
<div v-else class="w-16 h-16 bg-gray-200 dark:bg-gray-700 rounded flex items-center justify-center">
|
||||
<span class="text-gray-400 text-xs">📦</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-semibold rounded"
|
||||
:class="toast.platform === 'wallapop' ? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200' : 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'"
|
||||
>
|
||||
{{ toast.platform?.toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm mb-1 line-clamp-2">
|
||||
{{ toast.title || 'Nuevo artículo' }}
|
||||
</h4>
|
||||
<p v-if="toast.price" class="text-lg font-bold text-primary-600 dark:text-primary-400 mb-2">
|
||||
{{ toast.price }} {{ toast.currency || '€' }}
|
||||
</p>
|
||||
<a
|
||||
v-if="toast.url"
|
||||
:href="toast.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-primary-600 dark:text-primary-400 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Ver artículo →
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
@click="removeToast(toast.id)"
|
||||
class="flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -111,10 +170,32 @@ const navItems = [
|
||||
const wsConnected = ref(false);
|
||||
const mobileMenuOpen = ref(false);
|
||||
const darkMode = ref(false);
|
||||
const toasts = ref([]);
|
||||
let ws = null;
|
||||
let toastIdCounter = 0;
|
||||
|
||||
const isDark = computed(() => darkMode.value);
|
||||
|
||||
function addToast(article) {
|
||||
const id = ++toastIdCounter;
|
||||
toasts.value.push({
|
||||
id,
|
||||
...article,
|
||||
});
|
||||
|
||||
// Auto-remover después de 8 segundos
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
function removeToast(id) {
|
||||
const index = toasts.value.findIndex(t => t.id === id);
|
||||
if (index > -1) {
|
||||
toasts.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDarkMode() {
|
||||
darkMode.value = !darkMode.value;
|
||||
if (darkMode.value) {
|
||||
@@ -173,7 +254,16 @@ function connectWebSocket() {
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
// Los componentes individuales manejarán los mensajes
|
||||
|
||||
// Manejar notificaciones de artículos nuevos
|
||||
if (data.type === 'new_articles' && data.data) {
|
||||
// Mostrar toasts para cada artículo nuevo
|
||||
for (const article of data.data) {
|
||||
addToast(article);
|
||||
}
|
||||
}
|
||||
|
||||
// Los componentes individuales manejarán otros mensajes
|
||||
window.dispatchEvent(new CustomEvent('ws-message', { detail: data }));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,10 +57,12 @@ export default {
|
||||
},
|
||||
|
||||
// Logs
|
||||
async getLogs(limit = 100) {
|
||||
const response = await api.get('/logs', {
|
||||
params: { limit },
|
||||
});
|
||||
async getLogs(limit = 500, sinceLine = null) {
|
||||
const params = { limit };
|
||||
if (sinceLine !== null && sinceLine > 0) {
|
||||
params.since = sinceLine;
|
||||
}
|
||||
const response = await api.get('/logs', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -42,3 +42,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ const autoRefresh = ref(false);
|
||||
const refreshIntervalSeconds = ref(5);
|
||||
const followLatestLog = ref(true);
|
||||
const logsContainer = ref(null);
|
||||
const lastLoadedLogHash = ref(null); // Para rastrear el último log cargado
|
||||
const lastLineNumber = ref(-1); // Número de la última línea leída
|
||||
let refreshInterval = null;
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
@@ -117,24 +117,31 @@ async function loadLogs(forceReload = false, shouldScroll = null) {
|
||||
// Si shouldScroll es null, usar la configuración de followLatestLog
|
||||
const shouldAutoScroll = shouldScroll !== null ? shouldScroll : followLatestLog.value;
|
||||
|
||||
// Guardar la posición del scroll actual antes de actualizar
|
||||
// Guardar la posición del scroll antes de actualizar
|
||||
const previousScrollTop = logsContainer.value?.scrollTop || 0;
|
||||
const previousScrollHeight = logsContainer.value?.scrollHeight || 0;
|
||||
const wasAtBottom = logsContainer.value
|
||||
? logsContainer.value.scrollTop + logsContainer.value.clientHeight >= logsContainer.value.scrollHeight - 10
|
||||
: false;
|
||||
|
||||
// Solo mostrar loader en carga inicial o recarga forzada
|
||||
const isInitialLoad = logs.value.length === 0 || lastLoadedLogHash.value === null;
|
||||
if (forceReload || isInitialLoad) {
|
||||
const isInitialLoad = forceReload || lastLineNumber.value === -1;
|
||||
if (isInitialLoad) {
|
||||
loading.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.getLogs(500);
|
||||
const newLogs = data.logs || [];
|
||||
// Si es carga inicial o forzada, no enviar sinceLine (cargar últimas líneas)
|
||||
// Si es actualización incremental, enviar lastLineNumber + 1 para obtener solo las nuevas
|
||||
const sinceLine = isInitialLoad ? null : lastLineNumber.value + 1;
|
||||
const data = await api.getLogs(500, sinceLine);
|
||||
|
||||
if (forceReload || isInitialLoad) {
|
||||
const newLogs = data.logs || [];
|
||||
const newLastLineNumber = data.lastLineNumber !== undefined ? data.lastLineNumber : -1;
|
||||
|
||||
if (isInitialLoad) {
|
||||
// Carga inicial o recarga forzada: reemplazar todo
|
||||
logs.value = newLogs;
|
||||
lastLoadedLogHash.value = newLogs.length > 0 ? hashLog(newLogs[newLogs.length - 1]) : null;
|
||||
lastLineNumber.value = newLastLineNumber;
|
||||
|
||||
await nextTick();
|
||||
if (logsContainer.value && shouldAutoScroll) {
|
||||
@@ -143,73 +150,40 @@ async function loadLogs(forceReload = false, shouldScroll = null) {
|
||||
}
|
||||
} else {
|
||||
// Actualización incremental: añadir solo las líneas nuevas al final
|
||||
if (newLogs.length > 0) {
|
||||
const currentLastLog = logs.value.length > 0 ? logs.value[logs.value.length - 1] : null;
|
||||
const newLastLog = newLogs[newLogs.length - 1];
|
||||
const newLastLogHash = hashLog(newLastLog);
|
||||
const lastLoadedHash = lastLoadedLogHash.value;
|
||||
|
||||
// Si el último log cambió, hay nuevos logs
|
||||
if (currentLastLog !== newLastLog && newLastLogHash !== lastLoadedHash) {
|
||||
// Crear un Set de los logs actuales para búsqueda rápida
|
||||
const currentLogsSet = new Set(logs.value);
|
||||
|
||||
// Encontrar qué logs son nuevos (no están en los logs actuales)
|
||||
const logsToAdd = [];
|
||||
// Recorrer desde el final para encontrar los nuevos
|
||||
for (let i = newLogs.length - 1; i >= 0; i--) {
|
||||
const log = newLogs[i];
|
||||
if (!currentLogsSet.has(log)) {
|
||||
logsToAdd.unshift(log); // Añadir al principio del array para mantener orden
|
||||
} else {
|
||||
// Si encontramos un log que ya existe, no hay más logs nuevos antes
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Si hay logs nuevos, añadirlos al final
|
||||
if (logsToAdd.length > 0) {
|
||||
if (newLogs.length > 0 && newLastLineNumber > lastLineNumber.value) {
|
||||
// Añadir las nuevas líneas al final
|
||||
// Limitar el número total de logs para evitar crecimiento infinito
|
||||
const maxLogs = 1000;
|
||||
logs.value = [...logs.value, ...logsToAdd].slice(-maxLogs); // Mantener los últimos maxLogs
|
||||
logs.value = [...logs.value, ...newLogs].slice(-maxLogs);
|
||||
|
||||
lastLoadedLogHash.value = newLastLogHash;
|
||||
// Actualizar el número de la última línea leída
|
||||
lastLineNumber.value = newLastLineNumber;
|
||||
|
||||
await nextTick();
|
||||
|
||||
// Ajustar el scroll para mantener la posición visual
|
||||
// Ajustar el scroll
|
||||
if (logsContainer.value) {
|
||||
if (shouldAutoScroll) {
|
||||
// Si debe seguir el último log, ir al final (abajo)
|
||||
logsContainer.value.scrollTop = logsContainer.value.scrollHeight;
|
||||
} else {
|
||||
// Mantener la posición del scroll sin cambios
|
||||
logsContainer.value.scrollTop = previousScrollTop;
|
||||
}
|
||||
}
|
||||
} else if (wasAtBottom) {
|
||||
// Si estaba abajo, mantenerlo abajo
|
||||
logsContainer.value.scrollTop = logsContainer.value.scrollHeight;
|
||||
}
|
||||
// Si no estaba abajo y no sigue logs, mantener posición (no hacer nada)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cargando logs:', error);
|
||||
// Asegurar que el loader se oculte incluso si hay error
|
||||
loading.value = false;
|
||||
} finally {
|
||||
// Solo ocultar loader si se mostró
|
||||
if (forceReload || isInitialLoad) {
|
||||
if (isInitialLoad) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Función auxiliar para crear un hash simple de un log
|
||||
function hashLog(log) {
|
||||
if (!log) return null;
|
||||
// Usar los primeros 100 caracteres como identificador
|
||||
return log.substring(0, 100);
|
||||
}
|
||||
|
||||
function handleAutoRefreshChange() {
|
||||
updateRefreshInterval();
|
||||
}
|
||||
@@ -236,13 +210,7 @@ function updateRefreshInterval() {
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
if (data.type === 'logs_updated') {
|
||||
// Solo actualizar si auto-refresh está activado
|
||||
if (autoRefresh.value) {
|
||||
// Actualización incremental (no forzada) cuando llega WebSocket
|
||||
loadLogs(false, followLatestLog.value);
|
||||
}
|
||||
}
|
||||
// Ya no escuchamos logs_updated porque usamos polling con números de línea
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
Reference in New Issue
Block a user