first commit
This commit is contained in:
commit
dc1245f164
22 changed files with 1666 additions and 0 deletions
154
monitor/scheduler.py
Normal file
154
monitor/scheduler.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"""Round-robin monitoring scheduler."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from monitor.browser import BrowserManager
|
||||
from monitor.models import AppConfig, WebsiteConfig
|
||||
from monitor.processor import handle_processing_result, process_with_openai
|
||||
from notifications.base import NotificationService
|
||||
from notifications.email import EmailNotificationService
|
||||
from notifications.sms import SmsNotificationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MonitorScheduler:
|
||||
"""Runs continuous round-robin website monitoring."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AppConfig,
|
||||
browser_manager: BrowserManager,
|
||||
notification_service: Optional[NotificationService] = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.browser_manager = browser_manager
|
||||
self.notification_service = notification_service or _build_notification_service()
|
||||
self._running = False
|
||||
self._current_index = 0
|
||||
|
||||
@property
|
||||
def enabled_websites(self) -> list[WebsiteConfig]:
|
||||
return [site for site in self.config.websites if site.enabled]
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the round-robin monitoring loop."""
|
||||
sites = self.enabled_websites
|
||||
if not sites:
|
||||
logger.error("No enabled websites configured. Exiting.")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
logger.info("Starting monitor with %d website(s)", len(sites))
|
||||
|
||||
try:
|
||||
self.browser_manager.start()
|
||||
except Exception as exc:
|
||||
logger.error("Failed to start browser: %s", exc)
|
||||
return
|
||||
|
||||
while self._running:
|
||||
website = sites[self._current_index % len(sites)]
|
||||
self._current_index += 1
|
||||
|
||||
if not self._running:
|
||||
break
|
||||
|
||||
self._process_website(website)
|
||||
|
||||
if not self._running:
|
||||
break
|
||||
|
||||
delay = self.config.monitor.round_robin_delay_seconds
|
||||
if delay > 0:
|
||||
self._interruptible_sleep(delay)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Signal the scheduler to stop."""
|
||||
logger.info("Stopping monitor...")
|
||||
self._running = False
|
||||
|
||||
def _interruptible_sleep(self, seconds: float) -> None:
|
||||
"""Sleep in small increments so shutdown is responsive."""
|
||||
end = time.monotonic() + seconds
|
||||
while self._running and time.monotonic() < end:
|
||||
time.sleep(min(0.5, end - time.monotonic()))
|
||||
|
||||
def _process_website(self, website: WebsiteConfig) -> None:
|
||||
"""Visit, snapshot, process, and notify for a single website."""
|
||||
retries = 0
|
||||
max_retries = self.config.monitor.browser_restart_max_retries
|
||||
backoff = self.config.monitor.browser_restart_backoff_seconds
|
||||
|
||||
snapshot = None
|
||||
while retries <= max_retries:
|
||||
try:
|
||||
snapshot = self.browser_manager.visit_and_snapshot(website)
|
||||
break
|
||||
except Exception as exc:
|
||||
retries += 1
|
||||
logger.error(
|
||||
"Browser error on %s (attempt %d/%d): %s",
|
||||
website.id,
|
||||
retries,
|
||||
max_retries,
|
||||
exc,
|
||||
)
|
||||
if retries > max_retries:
|
||||
logger.error("Max browser restart retries reached for %s", website.id)
|
||||
return
|
||||
try:
|
||||
self.browser_manager.restart()
|
||||
except Exception as restart_exc:
|
||||
logger.error("Browser restart failed: %s", restart_exc)
|
||||
time.sleep(backoff * retries)
|
||||
continue
|
||||
time.sleep(backoff)
|
||||
|
||||
if snapshot is None:
|
||||
return
|
||||
|
||||
try:
|
||||
result = process_with_openai(snapshot, website)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"OpenAI processing failed for %s: %s",
|
||||
website.id,
|
||||
exc,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
handle_processing_result(
|
||||
result,
|
||||
self.notification_service,
|
||||
self.config.notifications,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Notification failed for %s: %s",
|
||||
website.id,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
class CompositeNotificationService(NotificationService):
|
||||
"""Delegates to SMS and email notification services."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._sms = SmsNotificationService()
|
||||
self._email = EmailNotificationService()
|
||||
|
||||
def send_sms(self, message: str) -> bool:
|
||||
return self._sms.send_sms(message)
|
||||
|
||||
def send_email(self, subject: str, body: str) -> bool:
|
||||
return self._email.send_email(subject, body)
|
||||
|
||||
|
||||
def _build_notification_service() -> NotificationService:
|
||||
return CompositeNotificationService()
|
||||
Loading…
Add table
Add a link
Reference in a new issue