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:
Omar Sánchez Pizarro
2026-01-19 19:42:12 +01:00
parent b32b0b2e09
commit 9939c4d9ed
41 changed files with 6742 additions and 28 deletions

7
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
*.log
.DS_Store
.env
.env.local

72
web/QUICKSTART.md Normal file
View File

@@ -0,0 +1,72 @@
# 🚀 Inicio Rápido - Interfaz Web
## Instalación Rápida
### 1. Instalar dependencias
**Backend:**
```bash
cd web/backend
npm install
```
**Frontend:**
```bash
cd web/frontend
npm install
```
### 2. Iniciar los servidores
**Opción A - Script automático (recomendado):**
```bash
cd web
./start.sh
```
**Opción B - Manual:**
Terminal 1 (Backend):
```bash
cd web/backend
npm start
```
Terminal 2 (Frontend):
```bash
cd web/frontend
npm run dev
```
### 3. Acceder a la interfaz
Abre tu navegador en: **http://localhost:3000**
## 🎯 Características Principales
- **Dashboard**: Estadísticas en tiempo real
- **Artículos**: Visualiza todos los artículos notificados
- **Favoritos**: Gestiona tus artículos favoritos
- **Workers**: Configura y gestiona tus búsquedas
- **Logs**: Monitorea los logs del sistema
## ⚙️ Configuración
La interfaz web lee automáticamente:
- `workers.json` - Configuración de workers
- `favorites.json` - Lista de favoritos
- `config.yaml` - Configuración general (solo lectura)
- `monitor.log` - Logs del sistema
## 🔧 Requisitos
- Node.js 18+
- El sistema Python de Wallamonitor debe estar ejecutándose
- Redis (opcional, pero recomendado)
## 📝 Notas
- Los cambios en la interfaz web se guardan automáticamente en los archivos JSON
- El sistema Python debe estar ejecutándose para que los workers funcionen
- Los artículos notificados solo están disponibles si usas Redis como cache

207
web/README.md Normal file
View File

@@ -0,0 +1,207 @@
# 🎨 Wallamonitor Web Interface
Interfaz web moderna para visualizar y gestionar tu sistema Wallamonitor. Construida con **Node.js**, **Express**, **Vue 3** y **Tailwind CSS**.
## 🚀 Características
- **Dashboard en tiempo real** con estadísticas y métricas
- **Visualización de artículos** notificados con filtros por plataforma
- **Gestión de favoritos** con interfaz intuitiva
- **Configuración de workers** desde la interfaz web
- **Visualización de logs** del sistema en tiempo real
- **Actualizaciones en tiempo real** mediante WebSockets
- **Diseño responsive** y moderno con Tailwind CSS
## 📋 Requisitos Previos
- Node.js 18+ y npm
- El sistema Python de Wallamonitor ejecutándose
- Redis (opcional, pero recomendado para mejor rendimiento)
## 🔧 Instalación
### Backend
```bash
cd web/backend
npm install
```
### Frontend
```bash
cd web/frontend
npm install
```
## 🎯 Uso
### 1. Iniciar el Backend
```bash
cd web/backend
npm start
```
O en modo desarrollo (con auto-reload):
```bash
npm run dev
```
El backend se ejecutará en `http://localhost:3001`
### 2. Iniciar el Frontend
En otra terminal:
```bash
cd web/frontend
npm run dev
```
El frontend se ejecutará en `http://localhost:3000`
### 3. Acceder a la Interfaz
Abre tu navegador en `http://localhost:3000`
## 📁 Estructura del Proyecto
```
web/
├── backend/
│ ├── server.js # Servidor Express con WebSockets
│ └── package.json
└── frontend/
├── src/
│ ├── views/ # Vistas principales
│ │ ├── Dashboard.vue
│ │ ├── Articles.vue
│ │ ├── Favorites.vue
│ │ ├── Workers.vue
│ │ └── Logs.vue
│ ├── services/
│ │ └── api.js # Cliente API
│ ├── App.vue # Componente principal
│ ├── main.js # Punto de entrada
│ └── style.css # Estilos globales
├── index.html
├── vite.config.js
└── package.json
```
## 🔌 API Endpoints
### Estadísticas
- `GET /api/stats` - Obtener estadísticas generales
### Workers
- `GET /api/workers` - Obtener lista de workers
- `PUT /api/workers` - Actualizar configuración de workers
### Favoritos
- `GET /api/favorites` - Obtener favoritos
- `POST /api/favorites` - Añadir favorito
- `DELETE /api/favorites/:platform/:id` - Eliminar favorito
### Artículos
- `GET /api/articles` - Obtener artículos notificados (con paginación)
### Logs
- `GET /api/logs` - Obtener logs del sistema
### Configuración
- `GET /api/config` - Obtener configuración (sin tokens)
## 🔄 WebSockets
El servidor expone un WebSocket en `ws://localhost:3001` que envía actualizaciones en tiempo real:
- `workers_updated` - Cuando se actualiza la configuración de workers
- `favorites_updated` - Cuando cambian los favoritos
- `logs_updated` - Cuando se actualizan los logs
## 🎨 Personalización
### Colores
Los colores se pueden personalizar en `frontend/tailwind.config.js`:
```js
colors: {
primary: {
// Personaliza los colores primarios
}
}
```
### Puerto del Backend
Cambia el puerto del backend modificando la variable `PORT` en `backend/server.js` o usando la variable de entorno:
```bash
PORT=3001 npm start
```
### Puerto del Frontend
Modifica el puerto en `frontend/vite.config.js`:
```js
server: {
port: 3000,
}
```
## 🐛 Solución de Problemas
### El backend no se conecta a Redis
- Verifica que Redis esté ejecutándose
- Revisa la configuración en `config.yaml`
- El sistema funcionará sin Redis, pero con funcionalidad limitada
### No se ven los artículos
- Asegúrate de que el sistema Python esté ejecutándose
- Verifica que Redis esté configurado si usas cache Redis
- Revisa los logs del backend
### WebSocket no conecta
- Verifica que el backend esté ejecutándose
- Revisa la consola del navegador para errores
- Asegúrate de que no haya un firewall bloqueando el puerto
## 📝 Notas
- La interfaz web lee los archivos `workers.json` y `favorites.json` del directorio raíz del proyecto
- Los cambios en la interfaz web se reflejan automáticamente en los archivos JSON
- El sistema Python debe estar ejecutándose para que los workers funcionen
- Los artículos notificados solo están disponibles si usas Redis como cache
## 🚀 Producción
### Build del Frontend
```bash
cd web/frontend
npm run build
```
Los archivos estáticos se generarán en `web/frontend/dist/`
### Servir el Frontend desde el Backend
Puedes modificar `backend/server.js` para servir los archivos estáticos:
```js
import { join } from 'path';
app.use(express.static(join(__dirname, '../frontend/dist')));
```
## 📄 Licencia
MIT

View File

@@ -0,0 +1,6 @@
node_modules/
npm-debug.log*
.env
.env.local
*.log

26
web/backend/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM node:18-alpine
# Instalar wget para healthcheck
RUN apk add --no-cache wget
WORKDIR /app
# Copiar archivos de dependencias
COPY package.json package-lock.json* ./
# Instalar dependencias
RUN npm ci --only=production
# Copiar código de la aplicación
COPY server.js .
# Exponer puerto
EXPOSE 3001
# Healthcheck
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:3001/api/stats || exit 1
# Comando por defecto
CMD ["node", "server.js"]

1177
web/backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
web/backend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "wallamonitor-backend",
"version": "1.0.0",
"description": "Backend API para Wallamonitor Dashboard",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"keywords": ["wallamonitor", "api", "express"],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"ws": "^8.14.2",
"redis": "^4.6.10",
"yaml": "^2.3.4",
"chokidar": "^3.5.3"
}
}

390
web/backend/server.js Normal file
View File

@@ -0,0 +1,390 @@
import express from 'express';
import cors from 'cors';
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { readFileSync, writeFileSync, existsSync, statSync } from 'fs';
import { watch } from 'chokidar';
import yaml from 'yaml';
import redis from 'redis';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// En Docker, usar PROJECT_ROOT de env, sino usar ruta relativa
const PROJECT_ROOT = process.env.PROJECT_ROOT || join(__dirname, '../..');
const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server });
app.use(cors());
app.use(express.json());
// Configuración
const CONFIG_PATH = join(PROJECT_ROOT, 'config.yaml');
const WORKERS_PATH = join(PROJECT_ROOT, 'workers.json');
const FAVORITES_PATH = join(PROJECT_ROOT, 'favorites.json');
// Función para obtener la ruta del log (en Docker puede estar en /data/logs)
function getLogPath() {
const logsDirPath = join(PROJECT_ROOT, 'logs', 'monitor.log');
const rootLogPath = join(PROJECT_ROOT, 'monitor.log');
if (existsSync(logsDirPath)) {
return logsDirPath;
}
return rootLogPath;
}
const LOG_PATH = getLogPath();
let redisClient = null;
let config = null;
// Inicializar Redis si está configurado
async function initRedis() {
try {
config = yaml.parse(readFileSync(CONFIG_PATH, 'utf8'));
const cacheConfig = config?.cache;
if (cacheConfig?.type === 'redis') {
const redisConfig = cacheConfig.redis;
// En Docker, usar el nombre del servicio si no se especifica host
const redisHost = process.env.REDIS_HOST || redisConfig.host || 'localhost';
redisClient = redis.createClient({
socket: {
host: redisHost,
port: redisConfig.port || 6379,
},
password: redisConfig.password || undefined,
database: redisConfig.db || 0,
});
redisClient.on('error', (err) => console.error('Redis Client Error', err));
await redisClient.connect();
console.log('✅ Conectado a Redis');
} else {
console.log(' Redis no configurado, usando modo memoria');
}
} catch (error) {
console.error('Error inicializando Redis:', error.message);
}
}
// Broadcast a todos los clientes WebSocket
function broadcast(data) {
wss.clients.forEach((client) => {
if (client.readyState === 1) { // WebSocket.OPEN
client.send(JSON.stringify(data));
}
});
}
// Leer archivo JSON de forma segura
function readJSON(path, defaultValue = {}) {
try {
if (existsSync(path)) {
return JSON.parse(readFileSync(path, 'utf8'));
}
return defaultValue;
} catch (error) {
console.error(`Error leyendo ${path}:`, error.message);
return defaultValue;
}
}
// Escribir archivo JSON
function writeJSON(path, data) {
try {
writeFileSync(path, JSON.stringify(data, null, 2), 'utf8');
return true;
} catch (error) {
console.error(`Error escribiendo ${path}:`, error.message);
return false;
}
}
// Obtener artículos notificados desde Redis
async function getNotifiedArticles() {
if (!redisClient) {
return [];
}
try {
const keys = await redisClient.keys('notified:*');
const articles = [];
for (const key of keys) {
const parts = key.split(':');
if (parts.length >= 3) {
const platform = parts[1];
const id = parts.slice(2).join(':');
const ttl = await redisClient.ttl(key);
const value = await redisClient.get(key);
// Intentar parsear como JSON (nuevo formato con toda la info)
let articleData = {};
try {
if (value && value !== '1') {
articleData = JSON.parse(value);
}
} catch (e) {
// Si no es JSON válido, usar valor por defecto
}
articles.push({
platform: articleData.platform || platform,
id: articleData.id || id,
title: articleData.title || null,
description: articleData.description || null,
price: articleData.price || null,
currency: articleData.currency || null,
location: articleData.location || null,
allows_shipping: articleData.allows_shipping !== undefined ? articleData.allows_shipping : null,
url: articleData.url || null,
images: articleData.images || [],
modified_at: articleData.modified_at || null,
notifiedAt: Date.now() - (7 * 24 * 60 * 60 - ttl) * 1000,
expiresAt: Date.now() + ttl * 1000,
});
}
}
return articles;
} catch (error) {
console.error('Error obteniendo artículos de Redis:', error.message);
return [];
}
}
// API Routes
// Obtener estadísticas
app.get('/api/stats', async (req, res) => {
try {
const workers = readJSON(WORKERS_PATH, { items: [] });
const favorites = readJSON(FAVORITES_PATH, []);
const notifiedArticles = await getNotifiedArticles();
const stats = {
totalWorkers: workers.items?.length || 0,
activeWorkers: (workers.items || []).filter(w => !workers.disabled?.includes(w.name)).length,
totalFavorites: favorites.length,
totalNotified: notifiedArticles.length,
platforms: {
wallapop: notifiedArticles.filter(a => a.platform === 'wallapop').length,
vinted: notifiedArticles.filter(a => a.platform === 'vinted').length,
},
};
res.json(stats);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Obtener workers
app.get('/api/workers', (req, res) => {
try {
const workers = readJSON(WORKERS_PATH, { items: [], general: {}, disabled: [] });
res.json(workers);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Actualizar workers
app.put('/api/workers', (req, res) => {
try {
const workers = req.body;
if (writeJSON(WORKERS_PATH, workers)) {
broadcast({ type: 'workers_updated', data: workers });
res.json({ success: true });
} else {
res.status(500).json({ error: 'Error guardando workers' });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Obtener favoritos
app.get('/api/favorites', (req, res) => {
try {
const favorites = readJSON(FAVORITES_PATH, []);
res.json(favorites);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Añadir favorito
app.post('/api/favorites', (req, res) => {
try {
const favorite = req.body;
const favorites = readJSON(FAVORITES_PATH, []);
// Evitar duplicados
if (!favorites.find(f => f.id === favorite.id && f.platform === favorite.platform)) {
favorites.push({
...favorite,
addedAt: new Date().toISOString(),
});
writeJSON(FAVORITES_PATH, favorites);
broadcast({ type: 'favorites_updated', data: favorites });
res.json({ success: true, favorites });
} else {
res.json({ success: false, message: 'Ya existe en favoritos' });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Eliminar favorito
app.delete('/api/favorites/:platform/:id', (req, res) => {
try {
const { platform, id } = req.params;
const favorites = readJSON(FAVORITES_PATH, []);
const filtered = favorites.filter(
f => !(f.platform === platform && f.id === id)
);
writeJSON(FAVORITES_PATH, filtered);
broadcast({ type: 'favorites_updated', data: filtered });
res.json({ success: true, favorites: filtered });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Obtener artículos notificados
app.get('/api/articles', async (req, res) => {
try {
const articles = await getNotifiedArticles();
const limit = parseInt(req.query.limit) || 100;
const offset = parseInt(req.query.offset) || 0;
const sorted = articles.sort((a, b) => b.notifiedAt - a.notifiedAt);
const paginated = sorted.slice(offset, offset + limit);
res.json({
articles: paginated,
total: articles.length,
limit,
offset,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Obtener logs (últimas líneas)
app.get('/api/logs', (req, res) => {
try {
// Intentar múltiples ubicaciones posibles
let logFile = LOG_PATH;
if (!existsSync(logFile)) {
// Intentar en el directorio de logs
const altPath = join(PROJECT_ROOT, 'logs', 'monitor.log');
if (existsSync(altPath)) {
logFile = altPath;
} else {
return res.json({ logs: [] });
}
}
// Verificar que no sea un directorio
try {
const stats = statSync(logFile);
if (stats.isDirectory()) {
return res.json({ logs: ['Error: monitor.log es un directorio. Por favor, elimínalo y reinicia.'] });
}
} catch (e) {
return res.json({ logs: [] });
}
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);
res.json({ logs: lastLines });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Obtener configuración
app.get('/api/config', (req, res) => {
try {
if (!config) {
config = yaml.parse(readFileSync(CONFIG_PATH, 'utf8'));
}
// No enviar token por seguridad
const safeConfig = { ...config };
if (safeConfig.telegram_token) {
safeConfig.telegram_token = '***';
}
res.json(safeConfig);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// WebSocket connection
wss.on('connection', (ws) => {
console.log('Cliente WebSocket conectado');
ws.on('close', () => {
console.log('Cliente WebSocket desconectado');
});
ws.on('error', (error) => {
console.error('Error WebSocket:', error);
});
});
// Determinar la ruta del log para el watcher
let watchLogPath = LOG_PATH;
if (!existsSync(watchLogPath)) {
const altPath = join(PROJECT_ROOT, 'logs', 'monitor.log');
if (existsSync(altPath)) {
watchLogPath = altPath;
}
}
// Watch files for changes
const watcher = watch([WORKERS_PATH, FAVORITES_PATH, watchLogPath].filter(p => existsSync(p)), {
persistent: true,
ignoreInitial: true,
});
watcher.on('change', (path) => {
console.log(`Archivo cambiado: ${path}`);
if (path === WORKERS_PATH) {
const workers = readJSON(WORKERS_PATH);
broadcast({ type: 'workers_updated', data: workers });
} else if (path === FAVORITES_PATH) {
const favorites = readJSON(FAVORITES_PATH);
broadcast({ type: 'favorites_updated', data: favorites });
} else if (path === LOG_PATH) {
broadcast({ type: 'logs_updated' });
}
});
// Inicializar servidor
const PORT = process.env.PORT || 3001;
async function startServer() {
await initRedis();
server.listen(PORT, () => {
console.log(`🚀 Servidor backend ejecutándose en http://localhost:${PORT}`);
console.log(`📡 WebSocket disponible en ws://localhost:${PORT}`);
});
}
startServer().catch(console.error);

View File

@@ -0,0 +1,8 @@
node_modules/
dist/
.vite/
npm-debug.log*
.env
.env.local
*.log

29
web/frontend/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM node:18-alpine AS builder
WORKDIR /app
# Copiar archivos de dependencias
COPY package.json package-lock.json* ./
# Instalar dependencias
RUN npm ci
# Copiar código fuente
COPY . .
# Construir aplicación
RUN npm run build
# Stage de producción - servir con nginx
FROM nginx:alpine
# Copiar archivos construidos
COPY --from=builder /app/dist /usr/share/nginx/html
# Copiar configuración de nginx
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

14
web/frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wallamonitor Dashboard</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

49
web/frontend/nginx.conf Normal file
View File

@@ -0,0 +1,49 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
# SPA routing
location / {
try_files $uri $uri/ /index.html;
}
# API proxy
location /api {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket proxy
location /ws {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

2615
web/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
web/frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "wallamonitor-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.3.4",
"vue-router": "^4.2.5",
"axios": "^1.6.0",
"@heroicons/vue": "^2.1.1",
"chart.js": "^4.4.0",
"vue-chartjs": "^5.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.4.0",
"vite": "^5.0.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5"
}
}

View File

@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

104
web/frontend/src/App.vue Normal file
View 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
View 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');

View 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;
},
};

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

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
},
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,26 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'url';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:3001',
ws: true,
},
},
},
});

63
web/start.sh Executable file
View File

@@ -0,0 +1,63 @@
#!/bin/bash
# Script para iniciar el servidor web de Wallamonitor
echo "🚀 Iniciando Wallamonitor Web Interface..."
echo ""
# Verificar que Node.js esté instalado
if ! command -v node &> /dev/null; then
echo "❌ Node.js no está instalado. Por favor, instálalo primero."
exit 1
fi
# Verificar que npm esté instalado
if ! command -v npm &> /dev/null; then
echo "❌ npm no está instalado. Por favor, instálalo primero."
exit 1
fi
# Instalar dependencias del backend si no existen
if [ ! -d "backend/node_modules" ]; then
echo "📦 Instalando dependencias del backend..."
cd backend
npm install
cd ..
fi
# Instalar dependencias del frontend si no existen
if [ ! -d "frontend/node_modules" ]; then
echo "📦 Instalando dependencias del frontend..."
cd frontend
npm install
cd ..
fi
# Iniciar backend en background
echo "🔧 Iniciando backend..."
cd backend
npm start &
BACKEND_PID=$!
cd ..
# Esperar un poco para que el backend se inicie
sleep 2
# Iniciar frontend
echo "🎨 Iniciando frontend..."
cd frontend
npm run dev &
FRONTEND_PID=$!
cd ..
echo ""
echo "✅ Servidores iniciados!"
echo "📡 Backend: http://localhost:3001"
echo "🎨 Frontend: http://localhost:3000"
echo ""
echo "Presiona Ctrl+C para detener los servidores"
# Esperar a que se presione Ctrl+C
trap "kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit" INT TERM
wait