Enhance caching mechanism and logging configuration
- Updated .gitignore to include additional IDE and OS files, as well as log and web build directories. - Expanded config.sample.yaml to include cache configuration options for memory and Redis. - Modified wallamonitor.py to load cache configuration and initialize ArticleCache. - Refactored QueueManager to utilize ArticleCache for tracking notified articles. - Improved logging setup to dynamically determine log file path based on environment.
This commit is contained in:
104
web/frontend/src/App.vue
Normal file
104
web/frontend/src/App.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<nav class="bg-white shadow-lg">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<h1 class="text-2xl font-bold text-primary-600">🛎️ Wallamonitor</h1>
|
||||
</div>
|
||||
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
<router-link
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-900 hover:text-primary-600 border-b-2 border-transparent hover:border-primary-600"
|
||||
active-class="border-primary-600 text-primary-600"
|
||||
>
|
||||
<component :is="item.icon" class="w-5 h-5 mr-2" />
|
||||
{{ item.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="wsConnected ? 'bg-green-500' : 'bg-red-500'"
|
||||
></div>
|
||||
<span class="text-sm text-gray-600">
|
||||
{{ wsConnected ? 'Conectado' : 'Desconectado' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import {
|
||||
HomeIcon,
|
||||
DocumentTextIcon,
|
||||
HeartIcon,
|
||||
Cog6ToothIcon,
|
||||
DocumentMagnifyingGlassIcon,
|
||||
} from '@heroicons/vue/24/outline';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', name: 'Dashboard', icon: HomeIcon },
|
||||
{ path: '/articles', name: 'Artículos', icon: DocumentTextIcon },
|
||||
{ path: '/favorites', name: 'Favoritos', icon: HeartIcon },
|
||||
{ path: '/workers', name: 'Workers', icon: Cog6ToothIcon },
|
||||
{ path: '/logs', name: 'Logs', icon: DocumentMagnifyingGlassIcon },
|
||||
];
|
||||
|
||||
const wsConnected = ref(false);
|
||||
let ws = null;
|
||||
|
||||
onMounted(() => {
|
||||
connectWebSocket();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.hostname}:3001`;
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
wsConnected.value = true;
|
||||
console.log('WebSocket conectado');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
wsConnected.value = false;
|
||||
console.log('WebSocket desconectado, reintentando...');
|
||||
setTimeout(connectWebSocket, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('Error WebSocket:', error);
|
||||
wsConnected.value = false;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
// Los componentes individuales manejarán los mensajes
|
||||
window.dispatchEvent(new CustomEvent('ws-message', { detail: data }));
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
27
web/frontend/src/main.js
Normal file
27
web/frontend/src/main.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import App from './App.vue';
|
||||
import Dashboard from './views/Dashboard.vue';
|
||||
import Articles from './views/Articles.vue';
|
||||
import Favorites from './views/Favorites.vue';
|
||||
import Workers from './views/Workers.vue';
|
||||
import Logs from './views/Logs.vue';
|
||||
import './style.css';
|
||||
|
||||
const routes = [
|
||||
{ path: '/', component: Dashboard },
|
||||
{ path: '/articles', component: Articles },
|
||||
{ path: '/favorites', component: Favorites },
|
||||
{ path: '/workers', component: Workers },
|
||||
{ path: '/logs', component: Logs },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
|
||||
66
web/frontend/src/services/api.js
Normal file
66
web/frontend/src/services/api.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
// Estadísticas
|
||||
async getStats() {
|
||||
const response = await api.get('/stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Workers
|
||||
async getWorkers() {
|
||||
const response = await api.get('/workers');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateWorkers(workers) {
|
||||
const response = await api.put('/workers', workers);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Favoritos
|
||||
async getFavorites() {
|
||||
const response = await api.get('/favorites');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async addFavorite(favorite) {
|
||||
const response = await api.post('/favorites', favorite);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async removeFavorite(platform, id) {
|
||||
const response = await api.delete(`/favorites/${platform}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Artículos
|
||||
async getArticles(limit = 100, offset = 0) {
|
||||
const response = await api.get('/articles', {
|
||||
params: { limit, offset },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Logs
|
||||
async getLogs(limit = 100) {
|
||||
const response = await api.get('/logs', {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Configuración
|
||||
async getConfig() {
|
||||
const response = await api.get('/config');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
36
web/frontend/src/style.css
Normal file
36
web/frontend/src/style.css
Normal file
@@ -0,0 +1,36 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-md p-6;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white hover:bg-primary-700;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 text-gray-800 hover:bg-gray-300;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white hover:bg-red-700;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
212
web/frontend/src/views/Articles.vue
Normal file
212
web/frontend/src/views/Articles.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Artículos Notificados</h1>
|
||||
<div class="flex items-center space-x-4">
|
||||
<select
|
||||
v-model="selectedPlatform"
|
||||
@change="loadArticles"
|
||||
class="input"
|
||||
style="width: auto;"
|
||||
>
|
||||
<option value="">Todas las plataformas</option>
|
||||
<option value="wallapop">Wallapop</option>
|
||||
<option value="vinted">Vinted</option>
|
||||
</select>
|
||||
<button @click="loadArticles" class="btn btn-primary">
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<p class="mt-2 text-gray-600">Cargando artículos...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="articles.length === 0" class="card text-center py-12">
|
||||
<p class="text-gray-600">No hay artículos para mostrar</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="article in articles"
|
||||
:key="`${article.platform}-${article.id}`"
|
||||
class="card hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<!-- Imagen del artículo -->
|
||||
<div class="flex-shrink-0">
|
||||
<div v-if="article.images && article.images.length > 0" class="w-32 h-32 relative">
|
||||
<img
|
||||
:src="article.images[0]"
|
||||
:alt="article.title || 'Sin título'"
|
||||
class="w-32 h-32 object-cover rounded-lg"
|
||||
@error="($event) => handleImageError($event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="w-32 h-32 bg-gray-200 rounded-lg flex items-center justify-center">
|
||||
<span class="text-gray-400 text-xs">Sin imagen</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Información del artículo -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-semibold rounded flex-shrink-0"
|
||||
:class="
|
||||
article.platform === 'wallapop'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-green-100 text-green-800'
|
||||
"
|
||||
>
|
||||
{{ article.platform?.toUpperCase() || 'N/A' }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 whitespace-nowrap">
|
||||
{{ formatDate(article.notifiedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1 truncate" :title="article.title">
|
||||
{{ article.title || 'Sin título' }}
|
||||
</h3>
|
||||
|
||||
<div v-if="article.price !== null && article.price !== undefined" class="mb-2">
|
||||
<span class="text-xl font-bold text-primary-600">
|
||||
{{ article.price }} {{ article.currency || '€' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 text-sm text-gray-600 mb-2">
|
||||
<div v-if="article.location" class="flex items-center">
|
||||
<span class="font-medium">📍 Localidad:</span>
|
||||
<span class="ml-2">{{ article.location }}</span>
|
||||
</div>
|
||||
<div v-if="article.allows_shipping !== null" class="flex items-center">
|
||||
<span class="font-medium">🚚 Envío:</span>
|
||||
<span class="ml-2">{{ article.allows_shipping ? '✅ Acepta envíos' : '❌ No acepta envíos' }}</span>
|
||||
</div>
|
||||
<div v-if="article.modified_at" class="flex items-center">
|
||||
<span class="font-medium">🕒 Modificado:</span>
|
||||
<span class="ml-2">{{ article.modified_at }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="article.description" class="text-sm text-gray-700 mb-2 overflow-hidden" style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;">
|
||||
{{ article.description }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center space-x-4 mt-3">
|
||||
<a
|
||||
v-if="article.url"
|
||||
:href="article.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 text-sm font-medium"
|
||||
>
|
||||
🔗 Ver anuncio
|
||||
</a>
|
||||
<span class="text-xs text-gray-400">
|
||||
ID: {{ article.id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center space-x-2 mt-6">
|
||||
<button
|
||||
@click="loadMore"
|
||||
:disabled="articles.length >= total"
|
||||
class="btn btn-secondary"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': articles.length >= total }"
|
||||
>
|
||||
Cargar más
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-sm text-gray-500 mt-4">
|
||||
Mostrando {{ articles.length }} de {{ total }} artículos
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
|
||||
const articles = ref([]);
|
||||
const loading = ref(true);
|
||||
const total = ref(0);
|
||||
const offset = ref(0);
|
||||
const limit = 50;
|
||||
const selectedPlatform = ref('');
|
||||
|
||||
function formatDate(timestamp) {
|
||||
if (!timestamp) return 'N/A';
|
||||
return new Date(timestamp).toLocaleString('es-ES');
|
||||
}
|
||||
|
||||
async function loadArticles(reset = true) {
|
||||
if (reset) {
|
||||
offset.value = 0;
|
||||
articles.value = [];
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await api.getArticles(limit, offset.value);
|
||||
|
||||
let filtered = data.articles;
|
||||
if (selectedPlatform.value) {
|
||||
filtered = filtered.filter(a => a.platform === selectedPlatform.value);
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
articles.value = filtered;
|
||||
} else {
|
||||
articles.value.push(...filtered);
|
||||
}
|
||||
|
||||
total.value = data.total;
|
||||
offset.value += limit;
|
||||
} catch (error) {
|
||||
console.error('Error cargando artículos:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
loadArticles(false);
|
||||
}
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
if (data.type === 'articles_updated') {
|
||||
loadArticles();
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageError(event) {
|
||||
// Si la imagen falla al cargar, reemplazar con placeholder
|
||||
event.target.onerror = null; // Prevenir bucle infinito
|
||||
event.target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgdmlld0JveD0iMCAwIDEyOCAxMjgiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik00OCA0OEg4ME04MCA4MEg0OE00OCA0OEw2NCA2NEw4MCA0OE00OCA4MEw2NCA2NE04MCA4MEw2NCA2NEw0OCA4MCIgc3Ryb2tlPSIjOUI5Q0E0IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadArticles();
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('ws-message', handleWSMessage);
|
||||
});
|
||||
</script>
|
||||
|
||||
178
web/frontend/src/views/Dashboard.vue
Normal file
178
web/frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-6">Dashboard</h1>
|
||||
|
||||
<!-- Estadísticas -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div class="card">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 bg-primary-100 rounded-lg p-3">
|
||||
<Cog6ToothIcon class="w-6 h-6 text-primary-600" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">Workers Activos</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ stats.activeWorkers }}/{{ stats.totalWorkers }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 bg-green-100 rounded-lg p-3">
|
||||
<HeartIcon class="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">Favoritos</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ stats.totalFavorites }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 bg-blue-100 rounded-lg p-3">
|
||||
<DocumentTextIcon class="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">Artículos Notificados</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ stats.totalNotified }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 bg-purple-100 rounded-lg p-3">
|
||||
<ChartBarIcon class="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">Plataformas</p>
|
||||
<p class="text-sm font-bold text-gray-900">
|
||||
W: {{ stats.platforms?.wallapop || 0 }} | V: {{ stats.platforms?.vinted || 0 }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gráfico de plataformas -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="card">
|
||||
<h2 class="text-xl font-bold text-gray-900 mb-4">Distribución por Plataforma</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700">Wallapop</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ stats.platforms?.wallapop || 0 }}</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="bg-primary-600 h-2 rounded-full"
|
||||
:style="{
|
||||
width: `${getPercentage(stats.platforms?.wallapop || 0, stats.totalNotified)}%`,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700">Vinted</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ stats.platforms?.vinted || 0 }}</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="bg-green-600 h-2 rounded-full"
|
||||
:style="{
|
||||
width: `${getPercentage(stats.platforms?.vinted || 0, stats.totalNotified)}%`,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="text-xl font-bold text-gray-900 mb-4">Accesos Rápidos</h2>
|
||||
<div class="space-y-3">
|
||||
<router-link
|
||||
to="/articles"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<span class="text-sm font-medium text-gray-700">Ver todos los artículos</span>
|
||||
<ArrowRightIcon class="w-5 h-5 text-gray-400" />
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/favorites"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<span class="text-sm font-medium text-gray-700">Ver favoritos</span>
|
||||
<ArrowRightIcon class="w-5 h-5 text-gray-400" />
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/workers"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<span class="text-sm font-medium text-gray-700">Gestionar workers</span>
|
||||
<ArrowRightIcon class="w-5 h-5 text-gray-400" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import {
|
||||
Cog6ToothIcon,
|
||||
HeartIcon,
|
||||
DocumentTextIcon,
|
||||
ChartBarIcon,
|
||||
ArrowRightIcon,
|
||||
} from '@heroicons/vue/24/outline';
|
||||
|
||||
const stats = ref({
|
||||
totalWorkers: 0,
|
||||
activeWorkers: 0,
|
||||
totalFavorites: 0,
|
||||
totalNotified: 0,
|
||||
platforms: {},
|
||||
});
|
||||
|
||||
function getPercentage(value, total) {
|
||||
if (!total || total === 0) return 0;
|
||||
return Math.round((value / total) * 100);
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
stats.value = await api.getStats();
|
||||
} catch (error) {
|
||||
console.error('Error cargando estadísticas:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
if (data.type === 'workers_updated' || data.type === 'favorites_updated') {
|
||||
loadStats();
|
||||
}
|
||||
}
|
||||
|
||||
let interval = null;
|
||||
|
||||
onMounted(() => {
|
||||
loadStats();
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
interval = setInterval(loadStats, 10000); // Actualizar cada 10 segundos
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
window.removeEventListener('ws-message', handleWSMessage);
|
||||
});
|
||||
</script>
|
||||
|
||||
151
web/frontend/src/views/Favorites.vue
Normal file
151
web/frontend/src/views/Favorites.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Favoritos</h1>
|
||||
<button @click="loadFavorites" class="btn btn-primary">
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<p class="mt-2 text-gray-600">Cargando favoritos...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="favorites.length === 0" class="card text-center py-12">
|
||||
<HeartIcon class="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p class="text-gray-600 text-lg">No tienes favoritos aún</p>
|
||||
<p class="text-gray-400 text-sm mt-2">
|
||||
Los artículos que marques como favoritos aparecerán aquí
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="favorite in favorites"
|
||||
:key="`${favorite.platform}-${favorite.id}`"
|
||||
class="card hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-semibold rounded"
|
||||
:class="
|
||||
favorite.platform === 'wallapop'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-green-100 text-green-800'
|
||||
"
|
||||
>
|
||||
{{ favorite.platform?.toUpperCase() || 'N/A' }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
||||
{{ favorite.title || 'Sin título' }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 mb-2">
|
||||
{{ favorite.description?.substring(0, 100) }}...
|
||||
</p>
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<span class="text-xl font-bold text-primary-600">
|
||||
{{ favorite.price }} {{ favorite.currency }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ favorite.location }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 mt-4">
|
||||
<a
|
||||
:href="favorite.url"
|
||||
target="_blank"
|
||||
class="flex-1 btn btn-primary text-center"
|
||||
>
|
||||
Ver artículo
|
||||
</a>
|
||||
<button
|
||||
@click="removeFavorite(favorite.platform, favorite.id)"
|
||||
class="btn btn-danger"
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="favorite.images && favorite.images.length > 0" class="mt-4">
|
||||
<img
|
||||
:src="favorite.images[0]"
|
||||
:alt="favorite.title"
|
||||
class="w-full h-48 object-cover rounded-lg"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-400 mt-2">
|
||||
Añadido: {{ formatDate(favorite.addedAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import { HeartIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
const favorites = ref([]);
|
||||
const loading = ref(true);
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleString('es-ES');
|
||||
}
|
||||
|
||||
function handleImageError(event) {
|
||||
event.target.style.display = 'none';
|
||||
}
|
||||
|
||||
async function loadFavorites() {
|
||||
loading.value = true;
|
||||
try {
|
||||
favorites.value = await api.getFavorites();
|
||||
} catch (error) {
|
||||
console.error('Error cargando favoritos:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFavorite(platform, id) {
|
||||
if (!confirm('¿Estás seguro de que quieres eliminar este favorito?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.removeFavorite(platform, id);
|
||||
await loadFavorites();
|
||||
} catch (error) {
|
||||
console.error('Error eliminando favorito:', error);
|
||||
alert('Error al eliminar el favorito');
|
||||
}
|
||||
}
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
if (data.type === 'favorites_updated') {
|
||||
favorites.value = data.data;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFavorites();
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('ws-message', handleWSMessage);
|
||||
});
|
||||
</script>
|
||||
|
||||
111
web/frontend/src/views/Logs.vue
Normal file
111
web/frontend/src/views/Logs.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Logs del Sistema</h1>
|
||||
<div class="flex items-center space-x-4">
|
||||
<select v-model="logLevel" @change="loadLogs" class="input" style="width: auto;">
|
||||
<option value="">Todos los niveles</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
</select>
|
||||
<button @click="loadLogs" class="btn btn-primary">
|
||||
Actualizar
|
||||
</button>
|
||||
<button @click="autoRefresh = !autoRefresh" class="btn btn-secondary">
|
||||
{{ autoRefresh ? '⏸ Pausar' : '▶ Auto-refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="bg-gray-900 text-green-400 font-mono text-sm p-4 rounded-lg overflow-x-auto max-h-[600px] overflow-y-auto">
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-green-400"></div>
|
||||
<p class="mt-2 text-gray-400">Cargando logs...</p>
|
||||
</div>
|
||||
<div v-else-if="filteredLogs.length === 0" class="text-gray-500">
|
||||
No hay logs disponibles
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="(log, index) in filteredLogs"
|
||||
:key="index"
|
||||
class="mb-1"
|
||||
:class="getLogColor(log)"
|
||||
>
|
||||
{{ log }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
|
||||
const logs = ref([]);
|
||||
const loading = ref(true);
|
||||
const logLevel = ref('');
|
||||
const autoRefresh = ref(false);
|
||||
let refreshInterval = null;
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
if (!logLevel.value) {
|
||||
return logs.value;
|
||||
}
|
||||
return logs.value.filter(log => log.includes(logLevel.value));
|
||||
});
|
||||
|
||||
function getLogColor(log) {
|
||||
if (log.includes('ERROR')) return 'text-red-400';
|
||||
if (log.includes('WARNING')) return 'text-yellow-400';
|
||||
if (log.includes('INFO')) return 'text-blue-400';
|
||||
if (log.includes('DEBUG')) return 'text-gray-400';
|
||||
return 'text-green-400';
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await api.getLogs(500);
|
||||
logs.value = data.logs || [];
|
||||
} catch (error) {
|
||||
console.error('Error cargando logs:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
if (data.type === 'logs_updated') {
|
||||
loadLogs();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadLogs();
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
|
||||
// Auto-refresh
|
||||
const checkAutoRefresh = () => {
|
||||
if (autoRefresh.value) {
|
||||
loadLogs();
|
||||
}
|
||||
};
|
||||
|
||||
refreshInterval = setInterval(checkAutoRefresh, 5000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
window.removeEventListener('ws-message', handleWSMessage);
|
||||
});
|
||||
</script>
|
||||
|
||||
297
web/frontend/src/views/Workers.vue
Normal file
297
web/frontend/src/views/Workers.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Gestión de Workers</h1>
|
||||
<button @click="showAddModal = true" class="btn btn-primary">
|
||||
+ Añadir Worker
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<p class="mt-2 text-gray-600">Cargando workers...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Workers activos -->
|
||||
<div v-if="activeWorkers.length > 0">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Workers Activos</h2>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div
|
||||
v-for="(worker, index) in activeWorkers"
|
||||
:key="index"
|
||||
class="card"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ worker.name }}</h3>
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded bg-green-100 text-green-800">
|
||||
Activo
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-600">Plataforma:</span>
|
||||
<p class="font-medium">{{ worker.platform || 'wallapop' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">Búsqueda:</span>
|
||||
<p class="font-medium">{{ worker.search_query }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">Precio:</span>
|
||||
<p class="font-medium">
|
||||
{{ worker.min_price || 'N/A' }} - {{ worker.max_price || 'N/A' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">Thread ID:</span>
|
||||
<p class="font-medium">{{ worker.thread_id || 'General' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-2 ml-4">
|
||||
<button
|
||||
@click="editWorker(worker, index)"
|
||||
class="btn btn-secondary text-sm"
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
@click="disableWorker(worker.name)"
|
||||
class="btn btn-danger text-sm"
|
||||
>
|
||||
Desactivar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workers desactivados -->
|
||||
<div v-if="disabledWorkers.length > 0" class="mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Workers Desactivados</h2>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div
|
||||
v-for="(worker, index) in disabledWorkers"
|
||||
:key="index"
|
||||
class="card opacity-60"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ worker.name }}</h3>
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded bg-red-100 text-red-800">
|
||||
Desactivado
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="enableWorker(worker.name)"
|
||||
class="btn btn-primary text-sm ml-4"
|
||||
>
|
||||
Activar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeWorkers.length === 0 && disabledWorkers.length === 0" class="card text-center py-12">
|
||||
<p class="text-gray-600">No hay workers configurados</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal para añadir/editar worker -->
|
||||
<div
|
||||
v-if="showAddModal || editingWorker"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div class="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">
|
||||
{{ editingWorker ? 'Editar Worker' : 'Añadir Worker' }}
|
||||
</h2>
|
||||
<form @submit.prevent="saveWorker" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Nombre</label>
|
||||
<input v-model="workerForm.name" type="text" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Plataforma</label>
|
||||
<select v-model="workerForm.platform" class="input">
|
||||
<option value="wallapop">Wallapop</option>
|
||||
<option value="vinted">Vinted</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Búsqueda</label>
|
||||
<input v-model="workerForm.search_query" type="text" class="input" required />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Precio Mínimo</label>
|
||||
<input v-model.number="workerForm.min_price" type="number" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Precio Máximo</label>
|
||||
<input v-model.number="workerForm.max_price" type="number" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Thread ID (opcional)</label>
|
||||
<input v-model.number="workerForm.thread_id" type="number" class="input" />
|
||||
</div>
|
||||
<div class="flex justify-end space-x-2 pt-4">
|
||||
<button type="button" @click="closeModal" class="btn btn-secondary">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
|
||||
const workers = ref({ items: [], disabled: [], general: {} });
|
||||
const loading = ref(true);
|
||||
const showAddModal = ref(false);
|
||||
const editingWorker = ref(null);
|
||||
|
||||
const activeWorkers = computed(() => {
|
||||
return workers.value.items?.filter(
|
||||
w => !workers.value.disabled?.includes(w.name)
|
||||
) || [];
|
||||
});
|
||||
|
||||
const disabledWorkers = computed(() => {
|
||||
return workers.value.items?.filter(
|
||||
w => workers.value.disabled?.includes(w.name)
|
||||
) || [];
|
||||
});
|
||||
|
||||
const workerForm = ref({
|
||||
name: '',
|
||||
platform: 'wallapop',
|
||||
search_query: '',
|
||||
min_price: null,
|
||||
max_price: null,
|
||||
thread_id: null,
|
||||
});
|
||||
|
||||
async function loadWorkers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
workers.value = await api.getWorkers();
|
||||
} catch (error) {
|
||||
console.error('Error cargando workers:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function editWorker(worker, index) {
|
||||
editingWorker.value = { worker, index };
|
||||
workerForm.value = { ...worker };
|
||||
showAddModal.value = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showAddModal.value = false;
|
||||
editingWorker.value = null;
|
||||
workerForm.value = {
|
||||
name: '',
|
||||
platform: 'wallapop',
|
||||
search_query: '',
|
||||
min_price: null,
|
||||
max_price: null,
|
||||
thread_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveWorker() {
|
||||
try {
|
||||
const updatedWorkers = { ...workers.value };
|
||||
|
||||
if (editingWorker.value) {
|
||||
// Editar worker existente
|
||||
const index = editingWorker.value.index;
|
||||
updatedWorkers.items[index] = { ...workerForm.value };
|
||||
} else {
|
||||
// Añadir nuevo worker
|
||||
if (!updatedWorkers.items) {
|
||||
updatedWorkers.items = [];
|
||||
}
|
||||
updatedWorkers.items.push({ ...workerForm.value });
|
||||
}
|
||||
|
||||
await api.updateWorkers(updatedWorkers);
|
||||
await loadWorkers();
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
console.error('Error guardando worker:', error);
|
||||
alert('Error al guardar el worker');
|
||||
}
|
||||
}
|
||||
|
||||
async function disableWorker(name) {
|
||||
if (!confirm(`¿Desactivar el worker "${name}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedWorkers = { ...workers.value };
|
||||
if (!updatedWorkers.disabled) {
|
||||
updatedWorkers.disabled = [];
|
||||
}
|
||||
if (!updatedWorkers.disabled.includes(name)) {
|
||||
updatedWorkers.disabled.push(name);
|
||||
}
|
||||
await api.updateWorkers(updatedWorkers);
|
||||
await loadWorkers();
|
||||
} catch (error) {
|
||||
console.error('Error desactivando worker:', error);
|
||||
alert('Error al desactivar el worker');
|
||||
}
|
||||
}
|
||||
|
||||
async function enableWorker(name) {
|
||||
try {
|
||||
const updatedWorkers = { ...workers.value };
|
||||
if (updatedWorkers.disabled) {
|
||||
updatedWorkers.disabled = updatedWorkers.disabled.filter(n => n !== name);
|
||||
}
|
||||
await api.updateWorkers(updatedWorkers);
|
||||
await loadWorkers();
|
||||
} catch (error) {
|
||||
console.error('Error activando worker:', error);
|
||||
alert('Error al activar el worker');
|
||||
}
|
||||
}
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
if (data.type === 'workers_updated') {
|
||||
workers.value = data.data;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadWorkers();
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('ws-message', handleWSMessage);
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user