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:
52
.dockerignore
Normal file
52
.dockerignore
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Web
|
||||||
|
web/frontend/dist/
|
||||||
|
web/frontend/.vite/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
monitor.log
|
||||||
|
|
||||||
|
# Config (se montan como volúmenes)
|
||||||
|
config.yaml
|
||||||
|
workers.json
|
||||||
|
favorites.json
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
41
.gitignore
vendored
41
.gitignore
vendored
@@ -106,14 +106,13 @@ ipython_config.py
|
|||||||
|
|
||||||
# pdm
|
# pdm
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
#pdm.lock
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
# commonly ignored for libraries.
|
||||||
# in version control.
|
|
||||||
# https://pdm.fming.dev/#use-with-ide
|
# https://pdm.fming.dev/#use-with-ide
|
||||||
.pdm.toml
|
#.pdm.toml
|
||||||
|
|
||||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
__pypackages__/
|
#__pypackages__/
|
||||||
|
|
||||||
# Celery stuff
|
# Celery stuff
|
||||||
celerybeat-schedule
|
celerybeat-schedule
|
||||||
@@ -155,12 +154,28 @@ dmypy.json
|
|||||||
# Cython debug symbols
|
# Cython debug symbols
|
||||||
cython_debug/
|
cython_debug/
|
||||||
|
|
||||||
# PyCharm
|
# IDEs
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
.vscode/
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
.idea/
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
*.swp
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
*.swo
|
||||||
#.idea/
|
*~
|
||||||
|
|
||||||
config.yaml
|
# OS
|
||||||
favorites.json
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
monitor.log
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Web build
|
||||||
|
web/frontend/dist/
|
||||||
|
web/frontend/.vite/
|
||||||
|
|||||||
215
DOCKER.md
Normal file
215
DOCKER.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# 🐳 Guía de Docker para Wallamonitor
|
||||||
|
|
||||||
|
Esta guía explica cómo ejecutar Wallamonitor usando Docker Compose.
|
||||||
|
|
||||||
|
## 📋 Requisitos Previos
|
||||||
|
|
||||||
|
- Docker 20.10+
|
||||||
|
- Docker Compose 2.0+
|
||||||
|
|
||||||
|
## 🚀 Inicio Rápido
|
||||||
|
|
||||||
|
### 1. Configurar archivos
|
||||||
|
|
||||||
|
Asegúrate de tener los archivos de configuración:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Si no existen, cópialos desde los archivos de muestra
|
||||||
|
cp config.sample.yaml config.yaml
|
||||||
|
cp workers.sample.json workers.json
|
||||||
|
|
||||||
|
# Edita config.yaml con tus credenciales de Telegram
|
||||||
|
nano config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Construir e iniciar servicios
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto iniciará:
|
||||||
|
- **Redis** (puerto 6379) - Cache de artículos
|
||||||
|
- **Backend** (puerto 3001) - API Node.js
|
||||||
|
- **Frontend** (puerto 3000) - Interfaz web Vue
|
||||||
|
- **Wallamonitor Python** - Servicio principal de monitoreo
|
||||||
|
|
||||||
|
### 3. Acceder a la interfaz
|
||||||
|
|
||||||
|
Abre tu navegador en: **http://localhost:3000**
|
||||||
|
|
||||||
|
## 📊 Servicios
|
||||||
|
|
||||||
|
### Redis
|
||||||
|
- **Puerto**: 6379
|
||||||
|
- **Volumen**: `redis-data` (persistente)
|
||||||
|
- **Uso**: Cache de artículos notificados
|
||||||
|
|
||||||
|
### Backend (Node.js)
|
||||||
|
- **Puerto**: 3001
|
||||||
|
- **API**: http://localhost:3001/api
|
||||||
|
- **WebSocket**: ws://localhost:3001
|
||||||
|
- **Funciones**: API REST y WebSockets para la interfaz web
|
||||||
|
|
||||||
|
### Frontend (Vue + Nginx)
|
||||||
|
- **Puerto**: 3000
|
||||||
|
- **URL**: http://localhost:3000
|
||||||
|
- **Funciones**: Interfaz web moderna
|
||||||
|
|
||||||
|
### Wallamonitor (Python)
|
||||||
|
- **Sin puertos expuestos** (solo comunicación interna)
|
||||||
|
- **Funciones**: Monitoreo de marketplaces y envío de notificaciones
|
||||||
|
|
||||||
|
## 🔧 Comandos Útiles
|
||||||
|
|
||||||
|
### Ver logs
|
||||||
|
```bash
|
||||||
|
# Todos los servicios
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Servicio específico
|
||||||
|
docker-compose logs -f wallamonitor
|
||||||
|
docker-compose logs -f backend
|
||||||
|
docker-compose logs -f frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Detener servicios
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Detener y eliminar volúmenes
|
||||||
|
```bash
|
||||||
|
docker-compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reconstruir imágenes
|
||||||
|
```bash
|
||||||
|
docker-compose build --no-cache
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reiniciar un servicio específico
|
||||||
|
```bash
|
||||||
|
docker-compose restart backend
|
||||||
|
docker-compose restart wallamonitor
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ver estado de servicios
|
||||||
|
```bash
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Volúmenes y Archivos
|
||||||
|
|
||||||
|
Los siguientes archivos se montan como volúmenes:
|
||||||
|
|
||||||
|
- `config.yaml` - Configuración (solo lectura en backend)
|
||||||
|
- `workers.json` - Configuración de workers (lectura/escritura)
|
||||||
|
- `favorites.json` - Favoritos (lectura/escritura)
|
||||||
|
- `logs/` - Directorio de logs (lectura/escritura)
|
||||||
|
|
||||||
|
**Nota**: Los cambios en `workers.json` y `favorites.json` desde la interfaz web se guardan directamente en estos archivos.
|
||||||
|
|
||||||
|
## 🔐 Configuración de Redis
|
||||||
|
|
||||||
|
Si usas Redis en Docker, el backend se conecta automáticamente al servicio `redis`.
|
||||||
|
|
||||||
|
Para usar Redis desde fuera de Docker (por ejemplo, desde tu máquina local), actualiza `config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cache:
|
||||||
|
type: "redis"
|
||||||
|
redis:
|
||||||
|
host: "localhost" # O la IP de tu máquina host
|
||||||
|
port: 6379
|
||||||
|
db: 0
|
||||||
|
password: null
|
||||||
|
```
|
||||||
|
|
||||||
|
Y en `docker-compose.yml`, cambia `REDIS_HOST` en el servicio backend a `host.docker.internal` (en Mac/Windows) o la IP de tu host.
|
||||||
|
|
||||||
|
## 🐛 Solución de Problemas
|
||||||
|
|
||||||
|
### El backend no se conecta a Redis
|
||||||
|
|
||||||
|
Verifica que Redis esté ejecutándose:
|
||||||
|
```bash
|
||||||
|
docker-compose ps redis
|
||||||
|
docker-compose logs redis
|
||||||
|
```
|
||||||
|
|
||||||
|
### Los archivos no se actualizan
|
||||||
|
|
||||||
|
Asegúrate de que los archivos existan y tengan los permisos correctos:
|
||||||
|
```bash
|
||||||
|
touch config.yaml workers.json favorites.json
|
||||||
|
mkdir -p logs
|
||||||
|
chmod 666 workers.json favorites.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error: monitor.log es un directorio
|
||||||
|
|
||||||
|
Si ves este error, significa que Docker creó un directorio en lugar de un archivo:
|
||||||
|
```bash
|
||||||
|
# Eliminar el directorio si existe
|
||||||
|
rm -rf monitor.log
|
||||||
|
|
||||||
|
# Crear el directorio de logs correcto
|
||||||
|
mkdir -p logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### El frontend no carga
|
||||||
|
|
||||||
|
Verifica los logs:
|
||||||
|
```bash
|
||||||
|
docker-compose logs frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reconstruir todo desde cero
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down -v
|
||||||
|
docker-compose build --no-cache
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Actualizar la Aplicación
|
||||||
|
|
||||||
|
Para actualizar después de cambios en el código:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Reconstruir solo el servicio que cambió
|
||||||
|
docker-compose build backend
|
||||||
|
docker-compose up -d backend
|
||||||
|
|
||||||
|
# O reconstruir todo
|
||||||
|
docker-compose build
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Variables de Entorno
|
||||||
|
|
||||||
|
Puedes personalizar el comportamiento usando variables de entorno en `docker-compose.yml`:
|
||||||
|
|
||||||
|
- `PROJECT_ROOT`: Ruta donde se montan los archivos (backend)
|
||||||
|
- `REDIS_HOST`: Host de Redis (por defecto: `redis`)
|
||||||
|
- `NODE_ENV`: Entorno Node.js (production/development)
|
||||||
|
- `PORT`: Puerto del backend (por defecto: 3001)
|
||||||
|
|
||||||
|
## 🚀 Producción
|
||||||
|
|
||||||
|
Para producción, considera:
|
||||||
|
|
||||||
|
1. **Usar un proxy reverso** (nginx/traefik) delante de los servicios
|
||||||
|
2. **Configurar SSL/TLS** para HTTPS
|
||||||
|
3. **Usar secrets de Docker** para credenciales sensibles
|
||||||
|
4. **Configurar backups** de los volúmenes de Redis
|
||||||
|
5. **Monitoreo y logging** con herramientas como Prometheus/Grafana
|
||||||
|
|
||||||
|
## 📚 Más Información
|
||||||
|
|
||||||
|
- [Documentación de Docker Compose](https://docs.docker.com/compose/)
|
||||||
|
- [README principal](../README.md)
|
||||||
|
- [Guía de la interfaz web](../web/README.md)
|
||||||
|
|
||||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Instalar dependencias del sistema necesarias para compilar paquetes Python
|
||||||
|
# Separar los comandos para mejor debugging y manejo de errores
|
||||||
|
RUN set -ex; \
|
||||||
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
gcc \
|
||||||
|
libffi-dev \
|
||||||
|
libssl-dev \
|
||||||
|
pkg-config; \
|
||||||
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
|
apt-get clean
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copiar requirements primero para aprovechar cache de Docker
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copiar el resto del código
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# El archivo config.yaml se genera automáticamente desde config.sample.yaml en el primer arranque
|
||||||
|
# En Docker, se recomienda montar config.yaml como volumen para gestionar la configuración fuera de la imagen.
|
||||||
|
|
||||||
|
# Healthcheck simple (verifica que el proceso esté ejecutándose)
|
||||||
|
HEALTHCHECK --interval=60s --timeout=10s --start-period=30s --retries=3 \
|
||||||
|
CMD pgrep -f "python.*wallamonitor.py" || exit 1
|
||||||
|
|
||||||
|
CMD ["python", "wallamonitor.py"]
|
||||||
@@ -1,2 +1,20 @@
|
|||||||
telegram_token: ""
|
telegram_token: ""
|
||||||
telegram_channel: "@canal_o_grupo"
|
telegram_channel: "@canal_o_grupo"
|
||||||
|
|
||||||
|
# Configuración del cache de artículos notificados
|
||||||
|
# cache_type: "memory" o "redis"
|
||||||
|
# - "memory": Almacena en memoria (no requiere Redis, limitado por el límite configurado)
|
||||||
|
# - "redis": Almacena en Redis (requiere servidor Redis, ilimitado con TTL de 7 días)
|
||||||
|
cache:
|
||||||
|
type: "memory" # "memory" o "redis"
|
||||||
|
|
||||||
|
# Configuración para cache en memoria
|
||||||
|
memory:
|
||||||
|
limit: 300 # Límite de artículos a mantener en memoria
|
||||||
|
|
||||||
|
# Configuración para cache en Redis (solo necesario si type: "redis")
|
||||||
|
redis:
|
||||||
|
host: "localhost"
|
||||||
|
port: 6379
|
||||||
|
db: 0
|
||||||
|
password: null # null o string con la contraseña
|
||||||
|
|||||||
96
docker-compose.README.md
Normal file
96
docker-compose.README.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# 🐳 Docker Compose - Inicio Rápido
|
||||||
|
|
||||||
|
## ⚡ Inicio Rápido (3 pasos)
|
||||||
|
|
||||||
|
### 1. Configurar archivos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copiar archivo de configuración
|
||||||
|
cp config.sample.yaml config.yaml
|
||||||
|
|
||||||
|
# Editar con tus credenciales de Telegram
|
||||||
|
nano config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Importante para Docker**: Si vas a usar Redis (recomendado), actualiza `config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cache:
|
||||||
|
type: "redis"
|
||||||
|
redis:
|
||||||
|
host: "redis" # Nombre del servicio en Docker
|
||||||
|
port: 6379
|
||||||
|
db: 0
|
||||||
|
password: null
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Crear directorio de logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Iniciar servicios
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Acceder a la interfaz
|
||||||
|
|
||||||
|
Abre: **http://localhost:3000**
|
||||||
|
|
||||||
|
## 📋 Servicios Incluidos
|
||||||
|
|
||||||
|
| Servicio | Puerto | Descripción |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| **Frontend** | 3000 | Interfaz web Vue |
|
||||||
|
| **Backend** | 3001 | API Node.js |
|
||||||
|
| **Redis** | 6379 | Cache de artículos |
|
||||||
|
| **Wallamonitor** | - | Servicio Python (interno) |
|
||||||
|
|
||||||
|
## 🔧 Comandos Básicos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ver logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Ver logs de un servicio específico
|
||||||
|
docker-compose logs -f wallamonitor
|
||||||
|
|
||||||
|
# Detener
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Reiniciar
|
||||||
|
docker-compose restart
|
||||||
|
|
||||||
|
# Ver estado
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Notas Importantes
|
||||||
|
|
||||||
|
1. **Redis en Docker**: El `host` debe ser `"redis"` (nombre del servicio)
|
||||||
|
2. **Archivos de configuración**: Se montan como volúmenes, los cambios persisten
|
||||||
|
3. **Logs**: Se guardan en el directorio `./logs/` en el host
|
||||||
|
4. **Primera ejecución**: Puede tardar unos minutos en construir las imágenes
|
||||||
|
|
||||||
|
## 🆘 Problemas Comunes
|
||||||
|
|
||||||
|
**El backend no conecta a Redis**
|
||||||
|
- Verifica que `config.yaml` tenga `host: "redis"` (no "localhost")
|
||||||
|
|
||||||
|
**Error: monitor.log es un directorio**
|
||||||
|
- Elimina el directorio si existe: `rm -rf monitor.log`
|
||||||
|
- Crea el directorio de logs: `mkdir -p logs`
|
||||||
|
|
||||||
|
**Los archivos no se actualizan**
|
||||||
|
- Asegúrate de que existan: `touch config.yaml workers.json favorites.json`
|
||||||
|
- Crea el directorio de logs: `mkdir -p logs`
|
||||||
|
|
||||||
|
**Error al construir**
|
||||||
|
- Limpia y reconstruye: `docker-compose build --no-cache`
|
||||||
|
|
||||||
|
## 📚 Más Información
|
||||||
|
|
||||||
|
Ver [DOCKER.md](./DOCKER.md) para documentación completa.
|
||||||
106
docker-compose.yml
Normal file
106
docker-compose.yml
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Redis para cache de artículos
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: wallamonitor-redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- wallamonitor-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Backend Node.js API
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./web/backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: wallamonitor-backend
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3001
|
||||||
|
- PROJECT_ROOT=/data
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
volumes:
|
||||||
|
# Montar archivos de configuración y datos en ubicación predecible
|
||||||
|
- ./config.yaml:/data/config.yaml:ro
|
||||||
|
- ./workers.json:/data/workers.json:rw
|
||||||
|
- ./favorites.json:/data/favorites.json:rw
|
||||||
|
- ./logs:/data/logs:rw
|
||||||
|
# Montar el directorio raíz para acceso a archivos
|
||||||
|
- .:/data/project:ro
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- wallamonitor-network
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3001/api/stats"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# Frontend Vue
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./web/frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: wallamonitor-frontend
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- wallamonitor-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Servicio Python principal (Wallamonitor)
|
||||||
|
# NOTA: Para usar Redis, asegúrate de que config.yaml tenga:
|
||||||
|
# cache:
|
||||||
|
# type: "redis"
|
||||||
|
# redis:
|
||||||
|
# host: "redis" # Nombre del servicio en Docker
|
||||||
|
wallamonitor:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: wallamonitor-python
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
volumes:
|
||||||
|
# Montar archivos de configuración
|
||||||
|
- ./config.yaml:/app/config.yaml:ro
|
||||||
|
- ./workers.json:/app/workers.json:ro
|
||||||
|
- ./favorites.json:/app/favorites.json:rw
|
||||||
|
# Montar directorio de logs en lugar del archivo para evitar problemas
|
||||||
|
- ./logs:/app/logs:rw
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- wallamonitor-network
|
||||||
|
restart: unless-stopped
|
||||||
|
# El servicio Python no necesita exponer puertos, solo se comunica con Redis y Telegram
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
wallamonitor-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
2
favorites.json
Normal file
2
favorites.json
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[
|
||||||
|
]
|
||||||
157
managers/article_cache.py
Normal file
157
managers/article_cache.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import logging
|
||||||
|
import redis
|
||||||
|
import json
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
NOTIFIED_ARTICLE_TTL = 7 * 24 * 60 * 60 # TTL de 7 días en segundos para artículos notificados (solo Redis)
|
||||||
|
DEFAULT_MEMORY_LIMIT = 300 # Límite por defecto de artículos en memoria
|
||||||
|
|
||||||
|
class MemoryArticleCache:
|
||||||
|
"""Maneja el cache de artículos notificados usando memoria (lista con límite)"""
|
||||||
|
|
||||||
|
def __init__(self, limit=DEFAULT_MEMORY_LIMIT):
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self._notified_articles = deque(maxlen=limit)
|
||||||
|
self._limit = limit
|
||||||
|
self.logger.info(f"Cache de artículos en memoria inicializado (límite: {limit})")
|
||||||
|
|
||||||
|
def is_article_notified(self, article):
|
||||||
|
"""Verifica si un artículo ya ha sido notificado"""
|
||||||
|
return article in self._notified_articles
|
||||||
|
|
||||||
|
def mark_article_as_notified(self, article):
|
||||||
|
"""Marca un artículo como notificado en memoria"""
|
||||||
|
if article not in self._notified_articles:
|
||||||
|
self._notified_articles.append(article)
|
||||||
|
self.logger.debug(f"Artículo marcado como notificado (total en memoria: {len(self._notified_articles)})")
|
||||||
|
|
||||||
|
def mark_articles_as_notified(self, articles):
|
||||||
|
"""Añade múltiples artículos a la lista de artículos ya notificados en memoria"""
|
||||||
|
article_list = articles if isinstance(articles, list) else [articles]
|
||||||
|
added = 0
|
||||||
|
for article in article_list:
|
||||||
|
if article not in self._notified_articles:
|
||||||
|
self._notified_articles.append(article)
|
||||||
|
added += 1
|
||||||
|
self.logger.debug(f"{added} artículos marcados como notificados (total en memoria: {len(self._notified_articles)}/{self._limit})")
|
||||||
|
|
||||||
|
|
||||||
|
class RedisArticleCache:
|
||||||
|
"""Maneja el cache de artículos notificados usando Redis"""
|
||||||
|
|
||||||
|
def __init__(self, redis_host='localhost', redis_port=6379, redis_db=0, redis_password=None):
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Inicializar conexión Redis
|
||||||
|
try:
|
||||||
|
self._redis_client = redis.Redis(
|
||||||
|
host=redis_host,
|
||||||
|
port=redis_port,
|
||||||
|
db=redis_db,
|
||||||
|
password=redis_password,
|
||||||
|
decode_responses=True,
|
||||||
|
socket_connect_timeout=5,
|
||||||
|
socket_timeout=5
|
||||||
|
)
|
||||||
|
# Verificar conexión
|
||||||
|
self._redis_client.ping()
|
||||||
|
self.logger.info(f"Conectado a Redis en {redis_host}:{redis_port} (db={redis_db})")
|
||||||
|
except (redis.ConnectionError, redis.TimeoutError) as e:
|
||||||
|
self.logger.error(f"Error conectando a Redis: {e}")
|
||||||
|
self.logger.error("Redis no está disponible. El sistema no podrá evitar duplicados sin Redis.")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error inesperado inicializando Redis: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _get_article_key(self, article):
|
||||||
|
"""Genera una clave única para un artículo en Redis"""
|
||||||
|
return f"notified:{article.get_platform()}:{article.get_id()}"
|
||||||
|
|
||||||
|
def is_article_notified(self, article):
|
||||||
|
"""Verifica si un artículo ya ha sido notificado"""
|
||||||
|
try:
|
||||||
|
key = self._get_article_key(article)
|
||||||
|
return self._redis_client.exists(key) > 0
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error verificando artículo en Redis: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def mark_article_as_notified(self, article):
|
||||||
|
"""Marca un artículo como notificado en Redis con TTL, guardando toda la información del artículo"""
|
||||||
|
try:
|
||||||
|
key = self._get_article_key(article)
|
||||||
|
# Guardar toda la información del artículo como JSON
|
||||||
|
article_data = {
|
||||||
|
'id': article.get_id(),
|
||||||
|
'title': article.get_title(),
|
||||||
|
'description': article._description, # Acceder al campo privado para obtener la descripción completa
|
||||||
|
'price': article.get_price(),
|
||||||
|
'currency': article.get_currency(),
|
||||||
|
'location': article.get_location(),
|
||||||
|
'allows_shipping': article._allows_shipping, # Acceder al campo privado para obtener el valor booleano
|
||||||
|
'url': article.get_url(),
|
||||||
|
'images': article.get_images(),
|
||||||
|
'modified_at': article.get_modified_at(),
|
||||||
|
'platform': article.get_platform(),
|
||||||
|
}
|
||||||
|
self._redis_client.setex(key, NOTIFIED_ARTICLE_TTL, json.dumps(article_data))
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error marcando artículo como notificado en Redis: {e}")
|
||||||
|
|
||||||
|
def mark_articles_as_notified(self, articles):
|
||||||
|
"""Añade múltiples artículos a la lista de artículos ya notificados en Redis"""
|
||||||
|
article_list = articles if isinstance(articles, list) else [articles]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Usar pipeline para mejor rendimiento al añadir múltiples artículos
|
||||||
|
pipe = self._redis_client.pipeline()
|
||||||
|
for article in article_list:
|
||||||
|
key = self._get_article_key(article)
|
||||||
|
# Guardar toda la información del artículo como JSON
|
||||||
|
article_data = {
|
||||||
|
'id': article.get_id(),
|
||||||
|
'title': article.get_title(),
|
||||||
|
'description': article._description, # Acceder al campo privado para obtener la descripción completa
|
||||||
|
'price': article.get_price(),
|
||||||
|
'currency': article.get_currency(),
|
||||||
|
'location': article.get_location(),
|
||||||
|
'allows_shipping': article._allows_shipping, # Acceder al campo privado para obtener el valor booleano
|
||||||
|
'url': article.get_url(),
|
||||||
|
'images': article.get_images(),
|
||||||
|
'modified_at': article.get_modified_at(),
|
||||||
|
'platform': article.get_platform(),
|
||||||
|
}
|
||||||
|
pipe.setex(key, NOTIFIED_ARTICLE_TTL, json.dumps(article_data))
|
||||||
|
pipe.execute()
|
||||||
|
self.logger.debug(f"{len(article_list)} artículos marcados como notificados en Redis")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error añadiendo artículos a Redis: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def create_article_cache(cache_type='memory', **kwargs):
|
||||||
|
"""
|
||||||
|
Factory function para crear el cache de artículos apropiado.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_type: 'memory' o 'redis'
|
||||||
|
**kwargs: Argumentos adicionales según el tipo de cache:
|
||||||
|
- Para 'memory': limit (opcional, default=300)
|
||||||
|
- Para 'redis': redis_host, redis_port, redis_db, redis_password
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MemoryArticleCache o RedisArticleCache
|
||||||
|
"""
|
||||||
|
if cache_type == 'memory':
|
||||||
|
limit = kwargs.get('limit', DEFAULT_MEMORY_LIMIT)
|
||||||
|
return MemoryArticleCache(limit=limit)
|
||||||
|
elif cache_type == 'redis':
|
||||||
|
return RedisArticleCache(
|
||||||
|
redis_host=kwargs.get('redis_host', 'localhost'),
|
||||||
|
redis_port=kwargs.get('redis_port', 6379),
|
||||||
|
redis_db=kwargs.get('redis_db', 0),
|
||||||
|
redis_password=kwargs.get('redis_password')
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Tipo de cache desconocido: {cache_type}. Debe ser 'memory' o 'redis'")
|
||||||
|
|
||||||
@@ -5,15 +5,14 @@ import logging
|
|||||||
from managers.telegram_manager import TelegramManager
|
from managers.telegram_manager import TelegramManager
|
||||||
|
|
||||||
MESSAGE_DELAY = 3.0 # Tiempo de espera entre mensajes en segundos
|
MESSAGE_DELAY = 3.0 # Tiempo de espera entre mensajes en segundos
|
||||||
NOTIFIED_ARTICLES_LIMIT = 300 # Límite de artículos notificados a mantener en memoria
|
|
||||||
RETRY_TIMES = 3
|
RETRY_TIMES = 3
|
||||||
|
|
||||||
class QueueManager:
|
class QueueManager:
|
||||||
def __init__(self):
|
def __init__(self, article_cache):
|
||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
self._queue = queue.Queue() # Cola thread-safe
|
self._queue = queue.Queue() # Cola thread-safe
|
||||||
self._notified_articles = []
|
|
||||||
self._telegram_manager = TelegramManager()
|
self._telegram_manager = TelegramManager()
|
||||||
|
self._article_cache = article_cache
|
||||||
self._running = True
|
self._running = True
|
||||||
|
|
||||||
# Iniciar el thread de procesamiento
|
# Iniciar el thread de procesamiento
|
||||||
@@ -22,7 +21,7 @@ class QueueManager:
|
|||||||
|
|
||||||
def add_to_queue(self, article, search_name=None, thread_id=None, retry_times=RETRY_TIMES):
|
def add_to_queue(self, article, search_name=None, thread_id=None, retry_times=RETRY_TIMES):
|
||||||
# Verificar si el artículo ya ha sido enviado
|
# Verificar si el artículo ya ha sido enviado
|
||||||
if article in self._notified_articles:
|
if self._article_cache.is_article_notified(article):
|
||||||
return
|
return
|
||||||
|
|
||||||
if search_name is None:
|
if search_name is None:
|
||||||
@@ -30,14 +29,11 @@ class QueueManager:
|
|||||||
self._queue.put((search_name, article, thread_id, retry_times))
|
self._queue.put((search_name, article, thread_id, retry_times))
|
||||||
self.logger.debug(f"Artículo añadido a la cola: {article.get_title()}")
|
self.logger.debug(f"Artículo añadido a la cola: {article.get_title()}")
|
||||||
|
|
||||||
self.add_to_notified_articles(article)
|
self._article_cache.mark_article_as_notified(article)
|
||||||
|
|
||||||
def add_to_notified_articles(self, articles):
|
def add_to_notified_articles(self, articles):
|
||||||
"""Añade artículos a la lista de artículos ya notificados"""
|
"""Añade artículos a la lista de artículos ya notificados"""
|
||||||
if isinstance(articles, list):
|
self._article_cache.mark_articles_as_notified(articles)
|
||||||
self._notified_articles.extend(articles)
|
|
||||||
else:
|
|
||||||
self._notified_articles.append(articles)
|
|
||||||
|
|
||||||
def _process_queue(self):
|
def _process_queue(self):
|
||||||
self.logger.info("Procesador de cola: Iniciado")
|
self.logger.info("Procesador de cola: Iniciado")
|
||||||
|
|||||||
@@ -210,7 +210,8 @@ class TelegramManager:
|
|||||||
[InlineKeyboardButton("✅ En favoritos", callback_data=f"already_fav_{article_id}")],
|
[InlineKeyboardButton("✅ En favoritos", callback_data=f"already_fav_{article_id}")],
|
||||||
[InlineKeyboardButton("🗑️ Quitar de favoritos", callback_data=f"unfav_{article_id}")]
|
[InlineKeyboardButton("🗑️ Quitar de favoritos", callback_data=f"unfav_{article_id}")]
|
||||||
]
|
]
|
||||||
await query.edit_message_text(
|
# Como el mensaje original es una foto con caption, usar edit_message_reply_markup
|
||||||
|
await query.edit_message_reply_markup(
|
||||||
reply_markup=InlineKeyboardMarkup(new_keyboard)
|
reply_markup=InlineKeyboardMarkup(new_keyboard)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -281,8 +282,8 @@ class TelegramManager:
|
|||||||
keyboard = [
|
keyboard = [
|
||||||
[InlineKeyboardButton("⭐ Añadir a favoritos", callback_data=f"fav_{article_id}_unknown")]
|
[InlineKeyboardButton("⭐ Añadir a favoritos", callback_data=f"fav_{article_id}_unknown")]
|
||||||
]
|
]
|
||||||
await query.edit_message_text(
|
# Como el mensaje original es una foto con caption, usar edit_message_reply_markup
|
||||||
text="💾 Acciones",
|
await query.edit_message_reply_markup(
|
||||||
reply_markup=InlineKeyboardMarkup(keyboard)
|
reply_markup=InlineKeyboardMarkup(keyboard)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
class WorkerConditions:
|
class WorkerConditions:
|
||||||
def __init__(self, item_monitoring, general_args):
|
def __init__(self, item_monitoring, general_args):
|
||||||
self._item_monitoring = item_monitoring
|
self._item_monitoring = item_monitoring
|
||||||
self._general_args = general_args
|
self._general_args = general_args
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def _has_words(self, text, word_list):
|
def _has_words(self, text, word_list):
|
||||||
return any(word in text for word in word_list)
|
return any(word in text for word in word_list)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
python-telegram-bot==21.6
|
python-telegram-bot==21.6
|
||||||
PyYAML==6.0.2
|
PyYAML==6.0.2
|
||||||
Requests==2.32.3
|
Requests==2.32.3
|
||||||
Pandas
|
Pandas
|
||||||
|
redis==5.0.1
|
||||||
@@ -4,11 +4,13 @@ from logging.handlers import RotatingFileHandler
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import yaml
|
||||||
|
|
||||||
from datalayer.item_monitor import ItemMonitor
|
from datalayer.item_monitor import ItemMonitor
|
||||||
from datalayer.general_monitor import GeneralMonitor
|
from datalayer.general_monitor import GeneralMonitor
|
||||||
from managers.worker import Worker
|
from managers.worker import Worker
|
||||||
from managers.queue_manager import QueueManager
|
from managers.queue_manager import QueueManager
|
||||||
|
from managers.article_cache import create_article_cache
|
||||||
|
|
||||||
def initialize_config_files():
|
def initialize_config_files():
|
||||||
"""
|
"""
|
||||||
@@ -38,13 +40,26 @@ def initialize_config_files():
|
|||||||
)
|
)
|
||||||
|
|
||||||
def configure_logger():
|
def configure_logger():
|
||||||
|
import os
|
||||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
|
||||||
console_handler = logging.StreamHandler()
|
console_handler = logging.StreamHandler()
|
||||||
console_handler.setLevel(logging.INFO)
|
console_handler.setLevel(logging.INFO)
|
||||||
console_handler.setFormatter(logging.Formatter('%(levelname)s [%(asctime)s] %(name)s - %(message)s'))
|
console_handler.setFormatter(logging.Formatter('%(levelname)s [%(asctime)s] %(name)s - %(message)s'))
|
||||||
|
|
||||||
file_handler = RotatingFileHandler('monitor.log', maxBytes=10e6)
|
# Determinar la ruta del archivo de log
|
||||||
|
# En Docker, usar /app/logs si existe, sino usar el directorio actual
|
||||||
|
if os.path.isdir('/app/logs'):
|
||||||
|
log_path = '/app/logs/monitor.log'
|
||||||
|
else:
|
||||||
|
log_path = 'monitor.log'
|
||||||
|
|
||||||
|
# Asegurarse de que el directorio existe
|
||||||
|
log_dir = os.path.dirname(log_path)
|
||||||
|
if log_dir and not os.path.exists(log_dir):
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
|
||||||
|
file_handler = RotatingFileHandler(log_path, maxBytes=10e6)
|
||||||
file_handler.setLevel(logging.DEBUG)
|
file_handler.setLevel(logging.DEBUG)
|
||||||
file_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
file_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
||||||
|
|
||||||
@@ -64,12 +79,60 @@ def parse_items_to_monitor():
|
|||||||
general_args = GeneralMonitor.load_from_json(args['general'])
|
general_args = GeneralMonitor.load_from_json(args['general'])
|
||||||
return items, general_args
|
return items, general_args
|
||||||
|
|
||||||
|
def load_cache_config():
|
||||||
|
"""Carga la configuración del cache desde config.yaml"""
|
||||||
|
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
config_path = os.path.join(base_dir, "config.yaml")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_path, 'r') as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
cache_config = config.get('cache', {})
|
||||||
|
cache_type = cache_config.get('type', 'memory')
|
||||||
|
|
||||||
|
if cache_type == 'memory':
|
||||||
|
memory_config = cache_config.get('memory', {})
|
||||||
|
return {
|
||||||
|
'cache_type': 'memory',
|
||||||
|
'limit': memory_config.get('limit', 300)
|
||||||
|
}
|
||||||
|
elif cache_type == 'redis':
|
||||||
|
redis_config = cache_config.get('redis', {})
|
||||||
|
return {
|
||||||
|
'cache_type': 'redis',
|
||||||
|
'redis_host': redis_config.get('host', 'localhost'),
|
||||||
|
'redis_port': redis_config.get('port', 6379),
|
||||||
|
'redis_db': redis_config.get('db', 0),
|
||||||
|
'redis_password': redis_config.get('password')
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.warning(f"Tipo de cache desconocido: {cache_type}, usando 'memory'")
|
||||||
|
return {
|
||||||
|
'cache_type': 'memory',
|
||||||
|
'limit': 300
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error cargando configuración de cache, usando valores por defecto (memory): {e}")
|
||||||
|
return {
|
||||||
|
'cache_type': 'memory',
|
||||||
|
'limit': 300
|
||||||
|
}
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
initialize_config_files()
|
initialize_config_files()
|
||||||
configure_logger()
|
configure_logger()
|
||||||
items, general_args = parse_items_to_monitor()
|
items, general_args = parse_items_to_monitor()
|
||||||
|
|
||||||
queue_manager = QueueManager()
|
# Cargar configuración de cache y crear ArticleCache
|
||||||
|
cache_config = load_cache_config()
|
||||||
|
cache_type = cache_config['cache_type']
|
||||||
|
# Crear kwargs sin cache_type
|
||||||
|
cache_kwargs = {k: v for k, v in cache_config.items() if k != 'cache_type'}
|
||||||
|
article_cache = create_article_cache(cache_type, **cache_kwargs)
|
||||||
|
|
||||||
|
# Crear QueueManager con ArticleCache
|
||||||
|
queue_manager = QueueManager(article_cache)
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=1000) as executor:
|
with ThreadPoolExecutor(max_workers=1000) as executor:
|
||||||
for item in items:
|
for item in items:
|
||||||
|
|||||||
7
web/.gitignore
vendored
Normal file
7
web/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
72
web/QUICKSTART.md
Normal file
72
web/QUICKSTART.md
Normal 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
207
web/README.md
Normal 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
|
||||||
|
|
||||||
6
web/backend/.dockerignore
Normal file
6
web/backend/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
|
||||||
26
web/backend/Dockerfile
Normal file
26
web/backend/Dockerfile
Normal 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
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
23
web/backend/package.json
Normal 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
390
web/backend/server.js
Normal 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);
|
||||||
|
|
||||||
8
web/frontend/.dockerignore
Normal file
8
web/frontend/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
npm-debug.log*
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
|
||||||
29
web/frontend/Dockerfile
Normal file
29
web/frontend/Dockerfile
Normal 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
14
web/frontend/index.html
Normal 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
49
web/frontend/nginx.conf
Normal 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
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
26
web/frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
7
web/frontend/postcss.config.js
Normal file
7
web/frontend/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
|
||||||
27
web/frontend/tailwind.config.js
Normal file
27
web/frontend/tailwind.config.js
Normal 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: [],
|
||||||
|
}
|
||||||
|
|
||||||
26
web/frontend/vite.config.js
Normal file
26
web/frontend/vite.config.js
Normal 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
63
web/start.sh
Executable 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
|
||||||
|
|
||||||
Reference in New Issue
Block a user