fix:logs and new articles

Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
Omar Sánchez Pizarro
2026-01-19 23:06:33 +01:00
parent ec512e2809
commit 08a9a277f5
5 changed files with 273 additions and 88 deletions

View File

@@ -63,6 +63,15 @@ async function initRedis() {
redisClient.on('error', (err) => console.error('Redis Client Error', err)); redisClient.on('error', (err) => console.error('Redis Client Error', err));
await redisClient.connect(); await redisClient.connect();
console.log('✅ Conectado a Redis'); 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 { } else {
console.log(' Redis no configurado, usando modo memoria'); 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) => { app.get('/api/logs', (req, res) => {
try { try {
// Intentar múltiples ubicaciones posibles // Intentar múltiples ubicaciones posibles
@@ -457,7 +466,7 @@ app.get('/api/logs', (req, res) => {
if (existsSync(altPath)) { if (existsSync(altPath)) {
logFile = altPath; logFile = altPath;
} else { } else {
return res.json({ logs: [] }); return res.json({ logs: [], totalLines: 0, lastLineNumber: 0 });
} }
} }
@@ -465,19 +474,37 @@ app.get('/api/logs', (req, res) => {
try { try {
const stats = statSync(logFile); const stats = statSync(logFile);
if (stats.isDirectory()) { 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) { } catch (e) {
return res.json({ logs: [] }); return res.json({ logs: [], totalLines: 0, lastLineNumber: 0 });
} }
const logs = readFileSync(logFile, 'utf8'); const logsContent = readFileSync(logFile, 'utf8');
const lines = logs.split('\n').filter(l => l.trim()); const allLines = logsContent.split('\n').filter(l => l.trim());
const limit = parseInt(req.query.limit) || 100; const totalLines = allLines.length;
const lastLines = lines.slice(-limit);
// Mantener orden natural: más antiguo a más reciente
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) { } catch (error) {
res.status(500).json({ error: error.message }); 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) // Watch files for changes (ya no vigilamos logs porque usa polling)
const watcher = watch([WORKERS_PATH, watchLogPath].filter(p => existsSync(p)), { const watcher = watch([WORKERS_PATH].filter(p => existsSync(p)), {
persistent: true, persistent: true,
ignoreInitial: true, ignoreInitial: true,
}); });
@@ -591,17 +618,100 @@ watcher.on('change', async (path) => {
if (path === WORKERS_PATH) { if (path === WORKERS_PATH) {
const workers = readJSON(WORKERS_PATH); const workers = readJSON(WORKERS_PATH);
broadcast({ type: 'workers_updated', data: workers }); 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 // Inicializar servidor
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
async function startServer() { async function startServer() {
await initRedis(); await initRedis();
// Iniciar monitoreo de artículos nuevos
await startArticleMonitoring();
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(`🚀 Servidor backend ejecutándose en http://localhost:${PORT}`); console.log(`🚀 Servidor backend ejecutándose en http://localhost:${PORT}`);
console.log(`📡 WebSocket disponible en ws://localhost:${PORT}`); console.log(`📡 WebSocket disponible en ws://localhost:${PORT}`);

View File

@@ -83,6 +83,65 @@
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6"> <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6">
<router-view /> <router-view />
</main> </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> </div>
</template> </template>
@@ -111,10 +170,32 @@ const navItems = [
const wsConnected = ref(false); const wsConnected = ref(false);
const mobileMenuOpen = ref(false); const mobileMenuOpen = ref(false);
const darkMode = ref(false); const darkMode = ref(false);
const toasts = ref([]);
let ws = null; let ws = null;
let toastIdCounter = 0;
const isDark = computed(() => darkMode.value); 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() { function toggleDarkMode() {
darkMode.value = !darkMode.value; darkMode.value = !darkMode.value;
if (darkMode.value) { if (darkMode.value) {
@@ -173,7 +254,16 @@ function connectWebSocket() {
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); 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 })); window.dispatchEvent(new CustomEvent('ws-message', { detail: data }));
}; };
} }

View File

@@ -57,10 +57,12 @@ export default {
}, },
// Logs // Logs
async getLogs(limit = 100) { async getLogs(limit = 500, sinceLine = null) {
const response = await api.get('/logs', { const params = { limit };
params: { limit }, if (sinceLine !== null && sinceLine > 0) {
}); params.since = sinceLine;
}
const response = await api.get('/logs', { params });
return response.data; return response.data;
}, },

View File

@@ -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;
}

View File

@@ -95,7 +95,7 @@ const autoRefresh = ref(false);
const refreshIntervalSeconds = ref(5); const refreshIntervalSeconds = ref(5);
const followLatestLog = ref(true); const followLatestLog = ref(true);
const logsContainer = ref(null); 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; let refreshInterval = null;
const filteredLogs = computed(() => { const filteredLogs = computed(() => {
@@ -117,24 +117,31 @@ async function loadLogs(forceReload = false, shouldScroll = null) {
// Si shouldScroll es null, usar la configuración de followLatestLog // Si shouldScroll es null, usar la configuración de followLatestLog
const shouldAutoScroll = shouldScroll !== null ? shouldScroll : followLatestLog.value; 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 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 // Solo mostrar loader en carga inicial o recarga forzada
const isInitialLoad = logs.value.length === 0 || lastLoadedLogHash.value === null; const isInitialLoad = forceReload || lastLineNumber.value === -1;
if (forceReload || isInitialLoad) { if (isInitialLoad) {
loading.value = true; loading.value = true;
} }
try { try {
const data = await api.getLogs(500); // Si es carga inicial o forzada, no enviar sinceLine (cargar últimas líneas)
const newLogs = data.logs || []; // 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 // Carga inicial o recarga forzada: reemplazar todo
logs.value = newLogs; logs.value = newLogs;
lastLoadedLogHash.value = newLogs.length > 0 ? hashLog(newLogs[newLogs.length - 1]) : null; lastLineNumber.value = newLastLineNumber;
await nextTick(); await nextTick();
if (logsContainer.value && shouldAutoScroll) { if (logsContainer.value && shouldAutoScroll) {
@@ -143,73 +150,40 @@ async function loadLogs(forceReload = false, shouldScroll = null) {
} }
} else { } else {
// Actualización incremental: añadir solo las líneas nuevas al final // Actualización incremental: añadir solo las líneas nuevas al final
if (newLogs.length > 0) { if (newLogs.length > 0 && newLastLineNumber > lastLineNumber.value) {
const currentLastLog = logs.value.length > 0 ? logs.value[logs.value.length - 1] : null; // Añadir las nuevas líneas al final
const newLastLog = newLogs[newLogs.length - 1]; // Limitar el número total de logs para evitar crecimiento infinito
const newLastLogHash = hashLog(newLastLog); const maxLogs = 1000;
const lastLoadedHash = lastLoadedLogHash.value; logs.value = [...logs.value, ...newLogs].slice(-maxLogs);
// Si el último log cambió, hay nuevos logs // Actualizar el número de la última línea leída
if (currentLastLog !== newLastLog && newLastLogHash !== lastLoadedHash) { lastLineNumber.value = newLastLineNumber;
// Crear un Set de los logs actuales para búsqueda rápida
const currentLogsSet = new Set(logs.value); await nextTick();
// Encontrar qué logs son nuevos (no están en los logs actuales) // Ajustar el scroll
const logsToAdd = []; if (logsContainer.value) {
// Recorrer desde el final para encontrar los nuevos if (shouldAutoScroll) {
for (let i = newLogs.length - 1; i >= 0; i--) { // Si debe seguir el último log, ir al final (abajo)
const log = newLogs[i]; logsContainer.value.scrollTop = logsContainer.value.scrollHeight;
if (!currentLogsSet.has(log)) { } else if (wasAtBottom) {
logsToAdd.unshift(log); // Añadir al principio del array para mantener orden // Si estaba abajo, mantenerlo abajo
} else { logsContainer.value.scrollTop = logsContainer.value.scrollHeight;
// 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;
}
}
} }
// Si no estaba abajo y no sigue logs, mantener posición (no hacer nada)
} }
} }
} }
} catch (error) { } catch (error) {
console.error('Error cargando logs:', error); console.error('Error cargando logs:', error);
// Asegurar que el loader se oculte incluso si hay error
loading.value = false; loading.value = false;
} finally { } finally {
// Solo ocultar loader si se mostró if (isInitialLoad) {
if (forceReload || isInitialLoad) {
loading.value = false; 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() { function handleAutoRefreshChange() {
updateRefreshInterval(); updateRefreshInterval();
} }
@@ -236,13 +210,7 @@ function updateRefreshInterval() {
function handleWSMessage(event) { function handleWSMessage(event) {
const data = event.detail; const data = event.detail;
if (data.type === 'logs_updated') { // Ya no escuchamos logs_updated porque usamos polling con números de línea
// Solo actualizar si auto-refresh está activado
if (autoRefresh.value) {
// Actualización incremental (no forzada) cuando llega WebSocket
loadLogs(false, followLatestLog.value);
}
}
} }
onMounted(() => { onMounted(() => {