From 08a9a277f5d5472584e3589d6a2c84ad88a745ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20S=C3=A1nchez=20Pizarro?= Date: Mon, 19 Jan 2026 23:06:33 +0100 Subject: [PATCH] fix:logs and new articles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Omar Sánchez Pizarro --- web/backend/server.js | 138 +++++++++++++++++++++++++++---- web/frontend/src/App.vue | 92 ++++++++++++++++++++- web/frontend/src/services/api.js | 10 ++- web/frontend/src/style.css | 15 ++++ web/frontend/src/views/Logs.vue | 106 +++++++++--------------- 5 files changed, 273 insertions(+), 88 deletions(-) diff --git a/web/backend/server.js b/web/backend/server.js index 178d1dd..539c836 100644 --- a/web/backend/server.js +++ b/web/backend/server.js @@ -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}`); diff --git a/web/frontend/src/App.vue b/web/frontend/src/App.vue index 02a6221..c24c8be 100644 --- a/web/frontend/src/App.vue +++ b/web/frontend/src/App.vue @@ -83,6 +83,65 @@
+ + +
+
+
+
+ +
+ 📦 +
+
+
+
+
+
+ + {{ toast.platform?.toUpperCase() }} + +
+

+ {{ toast.title || 'Nuevo artículo' }} +

+

+ {{ toast.price }} {{ toast.currency || '€' }} +

+ + Ver artículo → + +
+ +
+
+
+
+
@@ -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 })); }; } diff --git a/web/frontend/src/services/api.js b/web/frontend/src/services/api.js index 1853173..eae6eda 100644 --- a/web/frontend/src/services/api.js +++ b/web/frontend/src/services/api.js @@ -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; }, diff --git a/web/frontend/src/style.css b/web/frontend/src/style.css index 5ab50fe..465c802 100644 --- a/web/frontend/src/style.css +++ b/web/frontend/src/style.css @@ -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; +} + diff --git a/web/frontend/src/views/Logs.vue b/web/frontend/src/views/Logs.vue index 8562928..89d98f3 100644 --- a/web/frontend/src/views/Logs.vue +++ b/web/frontend/src/views/Logs.vue @@ -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; + 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, ...newLogs].slice(-maxLogs); - // 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) { - // 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 - - lastLoadedLogHash.value = newLastLogHash; - - await nextTick(); - - // Ajustar el scroll para mantener la posición visual - 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; - } - } + // Actualizar el número de la última línea leída + lastLineNumber.value = newLastLineNumber; + + await nextTick(); + + // 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 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(() => {