Refactor worker management and enhance configuration options

- Updated docker-compose.yml to allow read-write access for workers.json.
- Introduced WorkerManager class in wallamonitor.py to manage workers dynamically based on workers.json configuration.
- Enhanced Worker class to support controlled start/stop operations and updated general arguments.
- Improved Workers.vue to include a general configuration modal and refined UI for active and disabled workers.
- Added functionality for global filters and improved worker editing capabilities.
- Implemented methods for saving general configuration and deleting workers.
This commit is contained in:
Omar Sánchez Pizarro
2026-01-19 21:00:59 +01:00
parent 06e763c7ab
commit c3ef3daf5e
5 changed files with 729 additions and 92 deletions

View File

@@ -81,7 +81,7 @@ services:
volumes: volumes:
# Montar archivos de configuración # Montar archivos de configuración
- ./config.yaml:/app/config.yaml:ro - ./config.yaml:/app/config.yaml:ro
- ./workers.json:/app/workers.json:ro - ./workers.json:/app/workers.json:rw
# Montar directorio de logs en lugar del archivo para evitar problemas # Montar directorio de logs en lugar del archivo para evitar problemas
- ./logs:/app/logs:rw - ./logs:/app/logs:rw
depends_on: depends_on:

View File

@@ -1,6 +1,7 @@
import time import time
import logging import logging
import traceback import traceback
import threading
from platforms.platform_factory import PlatformFactory from platforms.platform_factory import PlatformFactory
from managers.worker_conditions import WorkerConditions from managers.worker_conditions import WorkerConditions
@@ -13,6 +14,8 @@ class Worker:
self._general_args = general_args self._general_args = general_args
self._queue_manager = queue_manager self._queue_manager = queue_manager
self._worker_conditions = WorkerConditions(item_to_monitor, general_args) self._worker_conditions = WorkerConditions(item_to_monitor, general_args)
self._running = True
self._stop_event = threading.Event()
# Initialize the platform based on item_to_monitor configuration # Initialize the platform based on item_to_monitor configuration
platform_name = self._item_monitoring.get_platform() platform_name = self._item_monitoring.get_platform()
try: try:
@@ -24,6 +27,21 @@ class Worker:
# Initialize the queue with existing articles # Initialize the queue with existing articles
self._queue_manager.add_to_notified_articles(self._request_articles()) self._queue_manager.add_to_notified_articles(self._request_articles())
def update_general_args(self, general_args):
"""Actualiza los argumentos generales del worker"""
self._general_args = general_args
self._worker_conditions = WorkerConditions(self._item_monitoring, general_args)
def stop(self):
"""Detiene el worker de forma controlada"""
self.logger.info(f"Deteniendo worker: {self._item_monitoring.get_name()}")
self._running = False
self._stop_event.set()
def is_running(self):
"""Verifica si el worker está corriendo"""
return self._running
def _request_articles(self): def _request_articles(self):
return self._platform.fetch_articles() return self._platform.fetch_articles()
@@ -31,30 +49,55 @@ class Worker:
def work(self): def work(self):
exec_times = [] exec_times = []
while True: while self._running and not self._stop_event.is_set():
start_time = time.time() start_time = time.time()
articles = self._request_articles() try:
for article in articles: articles = self._request_articles()
if self._worker_conditions.meets_item_conditions(article): for article in articles:
try: if not self._running or self._stop_event.is_set():
self._queue_manager.add_to_queue(article, self._item_monitoring.get_name(), self._item_monitoring.get_thread_id()) break
except Exception as e: if self._worker_conditions.meets_item_conditions(article):
self.logger.error(f"{self._item_monitoring.get_name()} worker crashed: {e}") try:
time.sleep(self._item_monitoring.get_check_every()) self._queue_manager.add_to_queue(article, self._item_monitoring.get_name(), self._item_monitoring.get_thread_id())
exec_times.append(time.time() - start_time - self._item_monitoring.get_check_every()) except Exception as e:
self.logger.info( self.logger.error(f"{self._item_monitoring.get_name()} worker crashed: {e}")
f"Worker '{self._item_monitoring.get_name()}', "
f"Execution time stats - Last: {exec_times[-1]:.2f}s, Max: {max(exec_times):.2f}s, " if not self._running or self._stop_event.is_set():
f"Average: {sum(exec_times) / len(exec_times):.2f}s." break
)
# Sleep con posibilidad de cancelación
check_every = self._item_monitoring.get_check_every()
sleep_time = 0
while sleep_time < check_every and self._running and not self._stop_event.is_set():
time.sleep(min(1.0, check_every - sleep_time))
sleep_time += 1.0
if exec_times:
exec_times.append(time.time() - start_time - check_every)
self.logger.info(
f"Worker '{self._item_monitoring.get_name()}', "
f"Execution time stats - Last: {exec_times[-1]:.2f}s, Max: {max(exec_times):.2f}s, "
f"Average: {sum(exec_times) / len(exec_times):.2f}s."
)
except Exception as e:
if self._running and not self._stop_event.is_set():
self.logger.error(f"Error en worker {self._item_monitoring.get_name()}: {e}")
time.sleep(1)
def run(self): def run(self):
while True: while self._running and not self._stop_event.is_set():
try: try:
platform_name = self._platform.get_platform_name() platform_name = self._platform.get_platform_name()
self.logger.info(f"{platform_name.capitalize()} monitor worker started - {self._item_monitoring.get_name()}") self.logger.info(f"{platform_name.capitalize()} monitor worker started - {self._item_monitoring.get_name()}")
self.work() self.work()
# Si el worker se detuvo normalmente, salir
if not self._running or self._stop_event.is_set():
self.logger.info(f"Worker '{self._item_monitoring.get_name()}' detenido")
break
except Exception as e: except Exception as e:
if not self._running or self._stop_event.is_set():
break
self.logger.error(f"{''.join(traceback.format_exception(None, e, e.__traceback__))}") self.logger.error(f"{''.join(traceback.format_exception(None, e, e.__traceback__))}")
self.logger.error(f"{self._item_monitoring.get_name()} worker crashed. Restarting worker...") self.logger.error(f"{self._item_monitoring.get_name()} worker crashed. Restarting worker...")
time.sleep(ERROR_SLEEP_TIME) time.sleep(ERROR_SLEEP_TIME)

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "wallamonitor",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -1,10 +1,12 @@
import json import json
import logging import logging
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor, Future
import os import os
import shutil import shutil
import yaml import yaml
import time
import threading
from datalayer.item_monitor import ItemMonitor from datalayer.item_monitor import ItemMonitor
from datalayer.general_monitor import GeneralMonitor from datalayer.general_monitor import GeneralMonitor
@@ -119,23 +121,247 @@ def load_cache_config():
'limit': 300 'limit': 300
} }
class WorkerManager:
"""Gestiona workers dinámicamente, iniciando y deteniendo según workers.json"""
def __init__(self, general_args, queue_manager):
self.logger = logging.getLogger(__name__)
self._general_args = general_args
self._queue_manager = queue_manager
self._executor = ThreadPoolExecutor(max_workers=1000)
self._workers = {} # Dict: worker_name -> {'worker': Worker, 'future': Future, 'item': ItemMonitor}
self._running = True
self._lock = threading.Lock()
base_dir = os.path.dirname(os.path.abspath(__file__))
self._workers_path = os.path.join(base_dir, "workers.json")
def load_workers_config(self):
"""Carga la configuración de workers desde workers.json"""
try:
with open(self._workers_path, 'r') as f:
config = json.load(f)
disabled = set(config.get('disabled', []))
items = []
for item_data in config.get('items', []):
item = ItemMonitor.load_from_json(item_data)
items.append((item, item.get_name() not in disabled))
general = GeneralMonitor.load_from_json(config.get('general', {}))
return items, general, disabled
except Exception as e:
self.logger.error(f"Error cargando workers.json: {e}")
return [], GeneralMonitor([], [], [], [], []), set()
def start_worker(self, item, general_args):
"""Inicia un worker"""
try:
worker = Worker(item, general_args, self._queue_manager)
future = self._executor.submit(worker.run)
self._workers[item.get_name()] = {
'worker': worker,
'future': future,
'item': item
}
self.logger.info(f"Worker '{item.get_name()}' iniciado")
return True
except Exception as e:
self.logger.error(f"Error iniciando worker '{item.get_name()}': {e}")
return False
def stop_worker(self, worker_name):
"""Detiene un worker"""
if worker_name not in self._workers:
return False
try:
worker_data = self._workers[worker_name]
worker = worker_data['worker']
future = worker_data['future']
# Detener el worker
worker.stop()
# Intentar cancelar el future si aún no está ejecutándose
if not future.done():
future.cancel()
# No esperamos a que termine, se detendrá automáticamente
del self._workers[worker_name]
self.logger.info(f"Worker '{worker_name}' detenido")
return True
except Exception as e:
self.logger.error(f"Error deteniendo worker '{worker_name}': {e}")
# Asegurarse de eliminar la entrada aunque haya error
if worker_name in self._workers:
del self._workers[worker_name]
return False
def sync_workers(self):
"""Sincroniza los workers con workers.json"""
items, general_args, disabled = self.load_workers_config()
# Actualizar general_args en todos los workers activos
old_general_args = self._general_args
self._general_args = general_args
current_worker_names = set(self._workers.keys())
enabled_worker_names = {item.get_name() for item, enabled in items if enabled}
# Actualizar general_args en workers existentes
with self._lock:
for worker_data in self._workers.values():
worker_data['worker'].update_general_args(general_args)
# Detener workers que ya no existen o están deshabilitados
to_stop = current_worker_names - enabled_worker_names
for worker_name in list(to_stop):
with self._lock:
if worker_name in self._workers:
worker = self._workers[worker_name]['worker']
worker.stop()
future = self._workers[worker_name]['future']
if not future.done():
future.cancel()
del self._workers[worker_name]
self.logger.info(f"Worker '{worker_name}' detenido")
# Iniciar workers nuevos o que se hayan habilitado
for item, enabled in items:
worker_name = item.get_name()
if enabled and worker_name not in current_worker_names:
# Nuevo worker o recién activado
with self._lock:
if worker_name not in self._workers:
self.start_worker(item, general_args)
elif enabled and worker_name in current_worker_names:
# Worker existente, verificar si hay cambios significativos
needs_restart = False
with self._lock:
if worker_name in self._workers:
old_item = self._workers[worker_name]['item']
# Comparar si hay cambios significativos
if self._has_changes(old_item, item):
self.logger.info(f"Reiniciando worker '{worker_name}' por cambios en configuración")
worker = self._workers[worker_name]['worker']
worker.stop()
future = self._workers[worker_name]['future']
if not future.done():
future.cancel()
del self._workers[worker_name]
needs_restart = True
else:
# Actualizar la referencia al item sin reiniciar
self._workers[worker_name]['item'] = item
# Actualizar general_args en el worker
self._workers[worker_name]['worker'].update_general_args(general_args)
# Reiniciar fuera del lock para evitar deadlocks
if needs_restart:
time.sleep(0.5) # Dar tiempo para detener
with self._lock:
if worker_name not in self._workers: # Verificar que no se haya añadido en otro thread
self.start_worker(item, general_args)
def _has_changes(self, old_item, new_item):
"""Verifica si hay cambios significativos entre dos items"""
# Comparar campos importantes
return (
old_item.get_search_query() != new_item.get_search_query() or
old_item.get_min_price() != new_item.get_min_price() or
old_item.get_max_price() != new_item.get_max_price() or
old_item.get_thread_id() != new_item.get_thread_id() or
old_item.get_platform() != new_item.get_platform() or
old_item.get_check_every() != new_item.get_check_every() or
old_item.get_latitude() != new_item.get_latitude() or
old_item.get_longitude() != new_item.get_longitude() or
old_item.get_max_distance() != new_item.get_max_distance()
)
def monitor_workers_file(self):
"""Monitorea el archivo workers.json y sincroniza workers usando polling"""
self.logger.info("Iniciando monitor de workers.json...")
last_mtime = 0
try:
if os.path.exists(self._workers_path):
last_mtime = os.path.getmtime(self._workers_path)
except Exception as e:
self.logger.warning(f"Error obteniendo mtime inicial: {e}")
while self._running:
try:
time.sleep(2) # Verificar cada 2 segundos
if not os.path.exists(self._workers_path):
continue
current_mtime = os.path.getmtime(self._workers_path)
if current_mtime != last_mtime:
self.logger.info("Detectado cambio en workers.json, sincronizando workers...")
time.sleep(0.5) # Esperar un poco para que se termine de escribir el archivo
self.sync_workers()
last_mtime = current_mtime
except Exception as e:
self.logger.error(f"Error monitoreando workers.json: {e}")
time.sleep(5) # Esperar más tiempo si hay error
def start_monitoring(self):
"""Inicia el monitoreo en un thread separado"""
monitor_thread = threading.Thread(target=self.monitor_workers_file, daemon=True)
monitor_thread.start()
return monitor_thread
def stop_all(self):
"""Detiene todos los workers"""
self._running = False
worker_names = list(self._workers.keys())
for worker_name in worker_names:
try:
with self._lock:
if worker_name in self._workers:
worker = self._workers[worker_name]['worker']
worker.stop()
future = self._workers[worker_name]['future']
if not future.done():
future.cancel()
del self._workers[worker_name]
except Exception as e:
self.logger.error(f"Error deteniendo worker '{worker_name}': {e}")
self._executor.shutdown(wait=False)
self.logger.info("Todos los workers detenidos")
if __name__ == "__main__": if __name__ == "__main__":
initialize_config_files() initialize_config_files()
configure_logger() configure_logger()
items, general_args = parse_items_to_monitor() logger = logging.getLogger(__name__)
# Cargar configuración de cache y crear ArticleCache # Cargar configuración de cache y crear ArticleCache
cache_config = load_cache_config() cache_config = load_cache_config()
cache_type = cache_config['cache_type'] 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'} cache_kwargs = {k: v for k, v in cache_config.items() if k != 'cache_type'}
article_cache = create_article_cache(cache_type, **cache_kwargs) article_cache = create_article_cache(cache_type, **cache_kwargs)
# Crear QueueManager con ArticleCache # Crear QueueManager con ArticleCache
queue_manager = QueueManager(article_cache) queue_manager = QueueManager(article_cache)
with ThreadPoolExecutor(max_workers=1000) as executor: # Crear WorkerManager
for item in items: items, general_args = parse_items_to_monitor()
worker = Worker(item, general_args, queue_manager) worker_manager = WorkerManager(general_args, queue_manager)
executor.submit(worker.run)
# Sincronizar workers iniciales
worker_manager.sync_workers()
# Iniciar monitoreo del archivo workers.json
worker_manager.start_monitoring()
try:
logger.info("Sistema de monitoreo iniciado. Esperando cambios en workers.json...")
# Mantener el programa corriendo
while True:
time.sleep(60)
# Sincronización periódica por si acaso
worker_manager.sync_workers()
except KeyboardInterrupt:
logger.info("Deteniendo sistema...")
finally:
worker_manager.stop_all()

View File

@@ -2,9 +2,14 @@
<div> <div>
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900">Gestión de Workers</h1> <h1 class="text-3xl font-bold text-gray-900">Gestión de Workers</h1>
<button @click="showAddModal = true" class="btn btn-primary"> <div class="flex space-x-2">
+ Añadir Worker <button @click="showGeneralModal = true" class="btn btn-secondary">
</button> Configuración General
</button>
<button @click="showAddModal = true" class="btn btn-primary">
+ Añadir Worker
</button>
</div>
</div> </div>
<div v-if="loading" class="text-center py-12"> <div v-if="loading" class="text-center py-12">
@@ -15,54 +20,103 @@
<div v-else class="space-y-4"> <div v-else class="space-y-4">
<!-- Workers activos --> <!-- Workers activos -->
<div v-if="activeWorkers.length > 0"> <div v-if="activeWorkers.length > 0">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Workers Activos</h2> <h2 class="text-xl font-semibold text-gray-900 mb-4">Workers Activos ({{ activeWorkers.length }})</h2>
<div class="grid grid-cols-1 gap-4"> <div class="grid grid-cols-1 gap-4">
<div <div
v-for="(worker, index) in activeWorkers" v-for="(worker, index) in activeWorkers"
:key="index" :key="index"
class="card" class="card hover:shadow-lg transition-shadow"
> >
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center space-x-2 mb-2"> <div class="flex items-center space-x-2 mb-3">
<h3 class="text-lg font-semibold text-gray-900">{{ worker.name }}</h3> <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"> <span class="px-2 py-1 text-xs font-semibold rounded bg-green-100 text-green-800">
Activo Activo
</span> </span>
<span class="px-2 py-1 text-xs font-semibold rounded" :class="worker.platform === 'wallapop' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'">
{{ (worker.platform || 'wallapop').toUpperCase() }}
</span>
</div> </div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4 text-sm">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 text-sm mb-3">
<div> <div>
<span class="text-gray-600">Plataforma:</span> <span class="text-gray-600 block mb-1">Búsqueda:</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> <p class="font-medium">{{ worker.search_query }}</p>
</div> </div>
<div> <div v-if="worker.min_price || worker.max_price">
<span class="text-gray-600">Precio:</span> <span class="text-gray-600 block mb-1">Precio:</span>
<p class="font-medium"> <p class="font-medium">
{{ worker.min_price || 'N/A' }} - {{ worker.max_price || 'N/A' }} {{ worker.min_price || '0' }} - {{ worker.max_price || '' }}
</p> </p>
</div> </div>
<div> <div v-if="worker.thread_id">
<span class="text-gray-600">Thread ID:</span> <span class="text-gray-600 block mb-1">Thread ID:</span>
<p class="font-medium">{{ worker.thread_id || 'General' }}</p> <p class="font-medium">{{ worker.thread_id }}</p>
</div> </div>
<div v-if="worker.latitude && worker.longitude">
<span class="text-gray-600 block mb-1">Ubicación:</span>
<p class="font-medium">{{ worker.latitude }}, {{ worker.longitude }}</p>
</div>
<div v-if="worker.max_distance">
<span class="text-gray-600 block mb-1">Distancia Máx:</span>
<p class="font-medium">{{ worker.max_distance }} km</p>
</div>
<div v-if="worker.check_every">
<span class="text-gray-600 block mb-1">Check cada:</span>
<p class="font-medium">{{ worker.check_every }}s</p>
</div>
</div>
<!-- Filtros aplicados -->
<div v-if="hasFilters(worker)" class="mt-3 pt-3 border-t border-gray-200">
<details class="text-xs">
<summary class="cursor-pointer text-gray-600 hover:text-gray-900 font-medium">
Ver filtros ({{ countFilters(worker) }})
</summary>
<div class="mt-2 space-y-1 text-gray-600">
<div v-if="worker.title_exclude?.length" class="flex items-start">
<span class="font-medium mr-2">Excluir título:</span>
<span>{{ worker.title_exclude.join(', ') }}</span>
</div>
<div v-if="worker.description_exclude?.length" class="flex items-start">
<span class="font-medium mr-2">Excluir descripción:</span>
<span>{{ worker.description_exclude.join(', ') }}</span>
</div>
<div v-if="worker.title_must_include?.length" class="flex items-start">
<span class="font-medium mr-2">Requerir título:</span>
<span>{{ worker.title_must_include.join(', ') }}</span>
</div>
<div v-if="worker.description_must_include?.length" class="flex items-start">
<span class="font-medium mr-2">Requerir descripción:</span>
<span>{{ worker.description_must_include.join(', ') }}</span>
</div>
<div v-if="worker.title_first_word_exclude?.length" class="flex items-start">
<span class="font-medium mr-2">Excluir primera palabra:</span>
<span>{{ worker.title_first_word_exclude.join(', ') }}</span>
</div>
</div>
</details>
</div> </div>
</div> </div>
<div class="flex space-x-2 ml-4"> <div class="flex space-x-2 ml-4">
<button <button
@click="editWorker(worker, index)" @click="editWorker(worker, activeWorkersIndex(index))"
class="btn btn-secondary text-sm" class="btn btn-secondary text-sm"
> >
Editar Editar
</button>
<button
@click="deleteWorker(worker.name)"
class="btn btn-danger text-sm"
>
🗑 Eliminar
</button> </button>
<button <button
@click="disableWorker(worker.name)" @click="disableWorker(worker.name)"
class="btn btn-danger text-sm" class="btn btn-secondary text-sm"
> >
Desactivar Desactivar
</button> </button>
</div> </div>
</div> </div>
@@ -72,12 +126,12 @@
<!-- Workers desactivados --> <!-- Workers desactivados -->
<div v-if="disabledWorkers.length > 0" class="mt-8"> <div v-if="disabledWorkers.length > 0" class="mt-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Workers Desactivados</h2> <h2 class="text-xl font-semibold text-gray-900 mb-4">Workers Desactivados ({{ disabledWorkers.length }})</h2>
<div class="grid grid-cols-1 gap-4"> <div class="grid grid-cols-1 gap-4">
<div <div
v-for="(worker, index) in disabledWorkers" v-for="(worker, index) in disabledWorkers"
:key="index" :key="index"
class="card opacity-60" class="card opacity-60 hover:opacity-80 transition-opacity"
> >
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
@@ -86,14 +140,32 @@
<span class="px-2 py-1 text-xs font-semibold rounded bg-red-100 text-red-800"> <span class="px-2 py-1 text-xs font-semibold rounded bg-red-100 text-red-800">
Desactivado Desactivado
</span> </span>
<span class="px-2 py-1 text-xs font-semibold rounded" :class="worker.platform === 'wallapop' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'">
{{ (worker.platform || 'wallapop').toUpperCase() }}
</span>
</div> </div>
<p class="text-sm text-gray-600">{{ worker.search_query }}</p>
</div>
<div class="flex space-x-2 ml-4">
<button
@click="editWorker(worker, disabledWorkersIndex(index))"
class="btn btn-secondary text-sm"
>
Editar
</button>
<button
@click="enableWorker(worker.name)"
class="btn btn-primary text-sm"
>
Activar
</button>
<button
@click="deleteWorker(worker.name)"
class="btn btn-danger text-sm"
>
🗑 Eliminar
</button>
</div> </div>
<button
@click="enableWorker(worker.name)"
class="btn btn-primary text-sm ml-4"
>
Activar
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -101,6 +173,9 @@
<div v-if="activeWorkers.length === 0 && disabledWorkers.length === 0" class="card text-center py-12"> <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> <p class="text-gray-600">No hay workers configurados</p>
<button @click="showAddModal = true" class="btn btn-primary mt-4">
+ Crear primer worker
</button>
</div> </div>
</div> </div>
@@ -110,40 +185,144 @@
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
@click.self="closeModal" @click.self="closeModal"
> >
<div class="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"> <div class="bg-white rounded-lg p-6 max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<h2 class="text-2xl font-bold text-gray-900 mb-4"> <h2 class="text-2xl font-bold text-gray-900 mb-4">
{{ editingWorker ? 'Editar Worker' : 'Añadir Worker' }} {{ editingWorker ? 'Editar Worker' : 'Añadir Worker' }}
</h2> </h2>
<form @submit.prevent="saveWorker" class="space-y-4"> <form @submit.prevent="saveWorker" class="space-y-6">
<div> <!-- Información básica -->
<label class="block text-sm font-medium text-gray-700 mb-1">Nombre</label> <div class="border-b border-gray-200 pb-4">
<input v-model="workerForm.name" type="text" class="input" required /> <h3 class="text-lg font-semibold text-gray-900 mb-4">Información Básica</h3>
</div> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Plataforma</label> <label class="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
<select v-model="workerForm.platform" class="input"> <input v-model="workerForm.name" type="text" class="input" required />
<option value="wallapop">Wallapop</option> </div>
<option value="vinted">Vinted</option> <div>
</select> <label class="block text-sm font-medium text-gray-700 mb-1">Plataforma</label>
</div> <select v-model="workerForm.platform" class="input">
<div> <option value="wallapop">Wallapop</option>
<label class="block text-sm font-medium text-gray-700 mb-1">Búsqueda</label> <option value="vinted">Vinted</option>
<input v-model="workerForm.search_query" type="text" class="input" required /> </select>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="md:col-span-2">
<div> <label class="block text-sm font-medium text-gray-700 mb-1">Búsqueda *</label>
<label class="block text-sm font-medium text-gray-700 mb-1">Precio Mínimo</label> <input v-model="workerForm.search_query" type="text" class="input" required placeholder="ej: playstation 1" />
<input v-model.number="workerForm.min_price" type="number" class="input" /> </div>
</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> </div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Thread ID (opcional)</label> <!-- Precios y Thread -->
<input v-model.number="workerForm.thread_id" type="number" class="input" /> <div class="border-b border-gray-200 pb-4">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Precios y Notificaciones</h3>
<div class="grid grid-cols-1 md:grid-cols-3 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" min="0" step="0.01" />
</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" min="0" step="0.01" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Thread ID (Telegram)</label>
<input v-model.number="workerForm.thread_id" type="number" class="input" />
<p class="text-xs text-gray-500 mt-1">Opcional: ID del hilo donde enviar notificaciones</p>
</div>
</div>
</div> </div>
<!-- Ubicación -->
<div class="border-b border-gray-200 pb-4">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Búsqueda Local (Opcional)</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Latitud</label>
<input v-model.number="workerForm.latitude" type="number" class="input" step="any" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Longitud</label>
<input v-model.number="workerForm.longitude" type="number" class="input" step="any" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Distancia Máxima (km)</label>
<input v-model.number="workerForm.max_distance" type="number" class="input" min="0" />
</div>
</div>
</div>
<!-- Filtros de exclusión -->
<div class="border-b border-gray-200 pb-4">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Filtros de Exclusión</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Excluir palabras del título</label>
<textarea
v-model="workerForm.title_exclude_text"
class="input"
rows="3"
placeholder="Una palabra por línea o separadas por comas"
></textarea>
<p class="text-xs text-gray-500 mt-1">Si aparece alguna palabra, se excluye el artículo</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Excluir palabras de la descripción</label>
<textarea
v-model="workerForm.description_exclude_text"
class="input"
rows="3"
placeholder="Una palabra por línea o separadas por comas"
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Excluir si primera palabra del título es</label>
<textarea
v-model="workerForm.title_first_word_exclude_text"
class="input"
rows="2"
placeholder="Una palabra por línea o separadas por comas"
></textarea>
<p class="text-xs text-gray-500 mt-1">Ej: "Reacondicionado", "Vendido", etc.</p>
</div>
</div>
</div>
<!-- Filtros de inclusión -->
<div class="border-b border-gray-200 pb-4">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Filtros de Inclusión (Requeridos)</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Palabras requeridas en el título</label>
<textarea
v-model="workerForm.title_must_include_text"
class="input"
rows="3"
placeholder="Una palabra por línea o separadas por comas"
></textarea>
<p class="text-xs text-gray-500 mt-1">TODAS las palabras deben aparecer</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Palabras requeridas en la descripción</label>
<textarea
v-model="workerForm.description_must_include_text"
class="input"
rows="3"
placeholder="Una palabra por línea o separadas por comas"
></textarea>
</div>
</div>
</div>
<!-- Configuración avanzada -->
<div class="border-b border-gray-200 pb-4">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Configuración Avanzada</h3>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Intervalo de verificación (segundos)</label>
<input v-model.number="workerForm.check_every" type="number" class="input" min="1" />
<p class="text-xs text-gray-500 mt-1">Cada cuántos segundos se actualiza la búsqueda (por defecto 30s)</p>
</div>
</div>
<div class="flex justify-end space-x-2 pt-4"> <div class="flex justify-end space-x-2 pt-4">
<button type="button" @click="closeModal" class="btn btn-secondary"> <button type="button" @click="closeModal" class="btn btn-secondary">
Cancelar Cancelar
@@ -155,6 +334,46 @@
</form> </form>
</div> </div>
</div> </div>
<!-- Modal para configuración general -->
<div
v-if="showGeneralModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
@click.self="closeGeneralModal"
>
<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">Configuración General</h2>
<p class="text-sm text-gray-600 mb-4">Estas configuraciones se aplican a todos los workers</p>
<form @submit.prevent="saveGeneralConfig" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Excluir palabras del título (global)</label>
<textarea
v-model="generalForm.title_exclude_text"
class="input"
rows="4"
placeholder="Una palabra por línea o separadas por comas"
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Excluir palabras de la descripción (global)</label>
<textarea
v-model="generalForm.description_exclude_text"
class="input"
rows="4"
placeholder="Una palabra por línea o separadas por comas"
></textarea>
</div>
<div class="flex justify-end space-x-2 pt-4 border-t border-gray-200">
<button type="button" @click="closeGeneralModal" class="btn btn-secondary">
Cancelar
</button>
<button type="submit" class="btn btn-primary">
Guardar
</button>
</div>
</form>
</div>
</div>
</div> </div>
</template> </template>
@@ -165,6 +384,7 @@ import api from '../services/api';
const workers = ref({ items: [], disabled: [], general: {} }); const workers = ref({ items: [], disabled: [], general: {} });
const loading = ref(true); const loading = ref(true);
const showAddModal = ref(false); const showAddModal = ref(false);
const showGeneralModal = ref(false);
const editingWorker = ref(null); const editingWorker = ref(null);
const activeWorkers = computed(() => { const activeWorkers = computed(() => {
@@ -179,6 +399,42 @@ const disabledWorkers = computed(() => {
) || []; ) || [];
}); });
function activeWorkersIndex(index) {
return workers.value.items?.findIndex(w => w.name === activeWorkers.value[index]?.name) ?? -1;
}
function disabledWorkersIndex(index) {
return workers.value.items?.findIndex(w => w.name === disabledWorkers.value[index]?.name) ?? -1;
}
function hasFilters(worker) {
return !!(worker.title_exclude?.length || worker.description_exclude?.length ||
worker.title_must_include?.length || worker.description_must_include?.length ||
worker.title_first_word_exclude?.length);
}
function countFilters(worker) {
let count = 0;
if (worker.title_exclude?.length) count++;
if (worker.description_exclude?.length) count++;
if (worker.title_must_include?.length) count++;
if (worker.description_must_include?.length) count++;
if (worker.title_first_word_exclude?.length) count++;
return count;
}
function textToArray(text) {
if (!text || !text.trim()) return [];
return text.split(/\n|,/)
.map(s => s.trim())
.filter(s => s.length > 0);
}
function arrayToText(arr) {
if (!arr || !Array.isArray(arr) || arr.length === 0) return '';
return arr.join('\n');
}
const workerForm = ref({ const workerForm = ref({
name: '', name: '',
platform: 'wallapop', platform: 'wallapop',
@@ -186,12 +442,31 @@ const workerForm = ref({
min_price: null, min_price: null,
max_price: null, max_price: null,
thread_id: null, thread_id: null,
latitude: null,
longitude: null,
max_distance: null,
title_exclude_text: '',
description_exclude_text: '',
title_must_include_text: '',
description_must_include_text: '',
title_first_word_exclude_text: '',
check_every: null,
});
const generalForm = ref({
title_exclude_text: '',
description_exclude_text: '',
}); });
async function loadWorkers() { async function loadWorkers() {
loading.value = true; loading.value = true;
try { try {
workers.value = await api.getWorkers(); workers.value = await api.getWorkers();
// Actualizar formulario general
generalForm.value = {
title_exclude_text: arrayToText(workers.value.general?.title_exclude || []),
description_exclude_text: arrayToText(workers.value.general?.description_exclude || []),
};
} catch (error) { } catch (error) {
console.error('Error cargando workers:', error); console.error('Error cargando workers:', error);
} finally { } finally {
@@ -201,7 +476,23 @@ async function loadWorkers() {
function editWorker(worker, index) { function editWorker(worker, index) {
editingWorker.value = { worker, index }; editingWorker.value = { worker, index };
workerForm.value = { ...worker }; workerForm.value = {
name: worker.name || '',
platform: worker.platform || 'wallapop',
search_query: worker.search_query || '',
min_price: worker.min_price || null,
max_price: worker.max_price || null,
thread_id: worker.thread_id || null,
latitude: worker.latitude || null,
longitude: worker.longitude || null,
max_distance: worker.max_distance || null,
title_exclude_text: arrayToText(worker.title_exclude || []),
description_exclude_text: arrayToText(worker.description_exclude || []),
title_must_include_text: arrayToText(worker.title_must_include || []),
description_must_include_text: arrayToText(worker.description_must_include || []),
title_first_word_exclude_text: arrayToText(worker.title_first_word_exclude || []),
check_every: worker.check_every || null,
};
showAddModal.value = true; showAddModal.value = true;
} }
@@ -215,23 +506,54 @@ function closeModal() {
min_price: null, min_price: null,
max_price: null, max_price: null,
thread_id: null, thread_id: null,
latitude: null,
longitude: null,
max_distance: null,
title_exclude_text: '',
description_exclude_text: '',
title_must_include_text: '',
description_must_include_text: '',
title_first_word_exclude_text: '',
check_every: null,
}; };
} }
function closeGeneralModal() {
showGeneralModal.value = false;
}
async function saveWorker() { async function saveWorker() {
try { try {
const updatedWorkers = { ...workers.value }; const updatedWorkers = { ...workers.value };
if (!updatedWorkers.items) {
updatedWorkers.items = [];
}
const workerData = {
name: workerForm.value.name,
platform: workerForm.value.platform,
search_query: workerForm.value.search_query,
...(workerForm.value.min_price !== null && { min_price: workerForm.value.min_price }),
...(workerForm.value.max_price !== null && { max_price: workerForm.value.max_price }),
...(workerForm.value.thread_id !== null && { thread_id: workerForm.value.thread_id }),
...(workerForm.value.latitude !== null && { latitude: workerForm.value.latitude }),
...(workerForm.value.longitude !== null && { longitude: workerForm.value.longitude }),
...(workerForm.value.max_distance !== null && { max_distance: String(workerForm.value.max_distance) }),
...(workerForm.value.check_every !== null && { check_every: workerForm.value.check_every }),
...(textToArray(workerForm.value.title_exclude_text).length > 0 && { title_exclude: textToArray(workerForm.value.title_exclude_text) }),
...(textToArray(workerForm.value.description_exclude_text).length > 0 && { description_exclude: textToArray(workerForm.value.description_exclude_text) }),
...(textToArray(workerForm.value.title_must_include_text).length > 0 && { title_must_include: textToArray(workerForm.value.title_must_include_text) }),
...(textToArray(workerForm.value.description_must_include_text).length > 0 && { description_must_include: textToArray(workerForm.value.description_must_include_text) }),
...(textToArray(workerForm.value.title_first_word_exclude_text).length > 0 && { title_first_word_exclude: textToArray(workerForm.value.title_first_word_exclude_text) }),
};
if (editingWorker.value) { if (editingWorker.value) {
// Editar worker existente // Editar worker existente
const index = editingWorker.value.index; const index = editingWorker.value.index;
updatedWorkers.items[index] = { ...workerForm.value }; updatedWorkers.items[index] = workerData;
} else { } else {
// Añadir nuevo worker // Añadir nuevo worker
if (!updatedWorkers.items) { updatedWorkers.items.push(workerData);
updatedWorkers.items = [];
}
updatedWorkers.items.push({ ...workerForm.value });
} }
await api.updateWorkers(updatedWorkers); await api.updateWorkers(updatedWorkers);
@@ -243,6 +565,23 @@ async function saveWorker() {
} }
} }
async function saveGeneralConfig() {
try {
const updatedWorkers = { ...workers.value };
updatedWorkers.general = {
...(textToArray(generalForm.value.title_exclude_text).length > 0 && { title_exclude: textToArray(generalForm.value.title_exclude_text) }),
...(textToArray(generalForm.value.description_exclude_text).length > 0 && { description_exclude: textToArray(generalForm.value.description_exclude_text) }),
};
await api.updateWorkers(updatedWorkers);
await loadWorkers();
closeGeneralModal();
} catch (error) {
console.error('Error guardando configuración general:', error);
alert('Error al guardar la configuración general');
}
}
async function disableWorker(name) { async function disableWorker(name) {
if (!confirm(`¿Desactivar el worker "${name}"?`)) { if (!confirm(`¿Desactivar el worker "${name}"?`)) {
return; return;
@@ -256,7 +595,7 @@ async function disableWorker(name) {
if (!updatedWorkers.disabled.includes(name)) { if (!updatedWorkers.disabled.includes(name)) {
updatedWorkers.disabled.push(name); updatedWorkers.disabled.push(name);
} }
await api.updateWorkers(updatedWorkers); await api.updateWshowGeneralModalorkers(updatedWorkers);
await loadWorkers(); await loadWorkers();
} catch (error) { } catch (error) {
console.error('Error desactivando worker:', error); console.error('Error desactivando worker:', error);
@@ -278,10 +617,34 @@ async function enableWorker(name) {
} }
} }
async function deleteWorker(name) {
if (!confirm(`¿Eliminar permanentemente el worker "${name}"? Esta acción no se puede deshacer.`)) {
return;
}
try {
const updatedWorkers = { ...workers.value };
updatedWorkers.items = updatedWorkers.items.filter(w => w.name !== name);
if (updatedWorkers.disabled) {
updatedWorkers.disabled = updatedWorkers.disabled.filter(n => n !== name);
}
await api.updateWorkers(updatedWorkers);
await loadWorkers();
} catch (error) {
console.error('Error eliminando worker:', error);
alert('Error al eliminar el worker');
}
}
function handleWSMessage(event) { function handleWSMessage(event) {
const data = event.detail; const data = event.detail;
if (data.type === 'workers_updated') { if (data.type === 'workers_updated') {
workers.value = data.data; workers.value = data.data;
// Actualizar formulario general
generalForm.value = {
title_exclude_text: arrayToText(workers.value.general?.title_exclude || []),
description_exclude_text: arrayToText(workers.value.general?.description_exclude || []),
};
} }
} }
@@ -294,4 +657,3 @@ onUnmounted(() => {
window.removeEventListener('ws-message', handleWSMessage); window.removeEventListener('ws-message', handleWSMessage);
}); });
</script> </script>