From 9b88f4229739728ee66f2cd83392d25d8ed62678 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 14 Nov 2025 15:44:13 -0600 Subject: [PATCH] Phase 3 Step 6: Complete Scheduler Integration with Bug Fixes Implemented complete scheduler integration with automatic schedule loading, orphaned scan cleanup, and conversion to local timezone for better UX. Backend Changes: - Added load_schedules_on_startup() to load enabled schedules on app start - Implemented cleanup_orphaned_scans() to handle crashed/interrupted scans - Converted scheduler from UTC to local system timezone throughout - Enhanced scheduler service with robust error handling and logging Frontend Changes: - Updated all schedule UI templates to display local time instead of UTC - Improved timezone indicators and user messaging - Removed confusing timezone converter (no longer needed) - Updated quick templates and help text for local time Bug Fixes: - Fixed critical timezone bug causing cron expressions to run at wrong times - Fixed orphaned scans stuck in 'running' status after system crashes - Improved time display clarity across all schedule pages All schedules now use local system time for intuitive scheduling. --- docs/ai/PHASE3.md | 2 +- web/app.py | 12 +++++ web/services/scan_service.py | 47 ++++++++++++++++++++ web/services/scheduler_service.py | 71 +++++++++++++++++++++++++++--- web/templates/schedule_create.html | 38 +++++++++++----- web/templates/schedule_edit.html | 60 ++++++++++++++++++------- web/templates/schedules.html | 7 ++- 7 files changed, 200 insertions(+), 37 deletions(-) diff --git a/docs/ai/PHASE3.md b/docs/ai/PHASE3.md index 871da20..32f87ae 100644 --- a/docs/ai/PHASE3.md +++ b/docs/ai/PHASE3.md @@ -1368,7 +1368,7 @@ async function loadScanTrend() { --- -### Step 6: Scheduler Integration ✅ (Day 10) +### Step 6: Scheduler Integration ✅ COMPLETE (Day 10) **Priority: CRITICAL** - Complete scheduled execution **Tasks:** diff --git a/web/app.py b/web/app.py index ae2d4ac..fc32520 100644 --- a/web/app.py +++ b/web/app.py @@ -294,11 +294,23 @@ def init_scheduler(app: Flask) -> None: app: Flask application instance """ from web.services.scheduler_service import SchedulerService + from web.services.scan_service import ScanService # Create and initialize scheduler scheduler = SchedulerService() scheduler.init_scheduler(app) + # Perform startup tasks with app context for database access + with app.app_context(): + # Clean up any orphaned scans from previous crashes/restarts + scan_service = ScanService(app.db_session) + orphaned_count = scan_service.cleanup_orphaned_scans() + if orphaned_count > 0: + app.logger.warning(f"Cleaned up {orphaned_count} orphaned scan(s) on startup") + + # Load all enabled schedules from database + scheduler.load_schedules_on_startup() + # Store in app context for access from routes app.scheduler = scheduler diff --git a/web/services/scan_service.py b/web/services/scan_service.py index 85a82ce..0ec31a6 100644 --- a/web/services/scan_service.py +++ b/web/services/scan_service.py @@ -268,6 +268,53 @@ class ScanService: return status_info + def cleanup_orphaned_scans(self) -> int: + """ + Clean up orphaned scans that are stuck in 'running' status. + + This should be called on application startup to handle scans that + were running when the system crashed or was restarted. + + Scans in 'running' status are marked as 'failed' with an appropriate + error message indicating they were orphaned. + + Returns: + Number of orphaned scans cleaned up + """ + # Find all scans with status='running' + orphaned_scans = self.db.query(Scan).filter(Scan.status == 'running').all() + + if not orphaned_scans: + logger.info("No orphaned scans found") + return 0 + + count = len(orphaned_scans) + logger.warning(f"Found {count} orphaned scan(s) in 'running' status, marking as failed") + + # Mark each orphaned scan as failed + for scan in orphaned_scans: + scan.status = 'failed' + scan.completed_at = datetime.utcnow() + scan.error_message = ( + "Scan was interrupted by system shutdown or crash. " + "The scan was running but did not complete normally." + ) + + # Calculate duration if we have a started_at time + if scan.started_at: + duration = (datetime.utcnow() - scan.started_at).total_seconds() + scan.duration = duration + + logger.info( + f"Marked orphaned scan {scan.id} as failed " + f"(started: {scan.started_at.isoformat() if scan.started_at else 'unknown'})" + ) + + self.db.commit() + logger.info(f"Cleaned up {count} orphaned scan(s)") + + return count + def _save_scan_to_db(self, report: Dict[str, Any], scan_id: int, status: str = 'completed') -> None: """ diff --git a/web/services/scheduler_service.py b/web/services/scheduler_service.py index e336270..0ef408e 100644 --- a/web/services/scheduler_service.py +++ b/web/services/scheduler_service.py @@ -6,7 +6,7 @@ scan execution and future scheduled scanning capabilities. """ import logging -from datetime import datetime +from datetime import datetime, timezone from typing import Optional from apscheduler.schedulers.background import BackgroundScheduler @@ -63,11 +63,13 @@ class SchedulerService: 'misfire_grace_time': 60 # Allow 60 seconds for delayed starts } - # Create scheduler + # Create scheduler with local system timezone + # This allows users to schedule jobs using their local time + # APScheduler will automatically use the system's local timezone self.scheduler = BackgroundScheduler( executors=executors, - job_defaults=job_defaults, - timezone='UTC' + job_defaults=job_defaults + # timezone defaults to local system timezone ) # Start scheduler @@ -90,6 +92,63 @@ class SchedulerService: logger.info("APScheduler shutdown complete") self.scheduler = None + def load_schedules_on_startup(self): + """ + Load all enabled schedules from database and register with APScheduler. + + Should be called after init_scheduler() to restore scheduled jobs + that were active when the application last shutdown. + + Raises: + RuntimeError: If scheduler not initialized + """ + if not self.scheduler: + raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.") + + # Import here to avoid circular imports + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from web.models import Schedule + + try: + # Create database session + engine = create_engine(self.db_url) + Session = sessionmaker(bind=engine) + session = Session() + + try: + # Query all enabled schedules + enabled_schedules = ( + session.query(Schedule) + .filter(Schedule.enabled == True) + .all() + ) + + logger.info(f"Loading {len(enabled_schedules)} enabled schedules on startup") + + # Register each schedule with APScheduler + for schedule in enabled_schedules: + try: + self.add_scheduled_scan( + schedule_id=schedule.id, + config_file=schedule.config_file, + cron_expression=schedule.cron_expression + ) + logger.info(f"Loaded schedule {schedule.id}: '{schedule.name}'") + except Exception as e: + logger.error( + f"Failed to load schedule {schedule.id} ('{schedule.name}'): {str(e)}", + exc_info=True + ) + + logger.info("Schedule loading complete") + + finally: + session.close() + + except Exception as e: + logger.error(f"Error loading schedules on startup: {str(e)}", exc_info=True) + def queue_scan(self, scan_id: int, config_file: str) -> str: """ Queue a scan for immediate background execution. @@ -142,9 +201,11 @@ class SchedulerService: from apscheduler.triggers.cron import CronTrigger - # Create cron trigger from expression + # Create cron trigger from expression using local timezone + # This allows users to specify times in their local timezone try: trigger = CronTrigger.from_crontab(cron_expression) + # timezone defaults to local system timezone except (ValueError, KeyError) as e: raise ValueError(f"Invalid cron expression '{cron_expression}': {str(e)}") diff --git a/web/templates/schedule_create.html b/web/templates/schedule_create.html index 4c3b603..212cbf7 100644 --- a/web/templates/schedule_create.html +++ b/web/templates/schedule_create.html @@ -69,10 +69,10 @@
{% endblock %} diff --git a/web/templates/schedules.html b/web/templates/schedules.html index 434829a..ce3122b 100644 --- a/web/templates/schedules.html +++ b/web/templates/schedules.html @@ -107,6 +107,9 @@ function formatRelativeTime(timestamp) { const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); + // Get local time string for tooltip/fallback + const localStr = date.toLocaleString(); + if (diffMs < 0) { // Past time const absDiffMinutes = Math.abs(diffMinutes); @@ -120,7 +123,7 @@ function formatRelativeTime(timestamp) { if (absDiffHours < 24) return `${absDiffHours} hours ago`; if (absDiffDays === 1) return 'Yesterday'; if (absDiffDays < 7) return `${absDiffDays} days ago`; - return date.toLocaleString(); + return `${absDiffDays} days ago`; } else { // Future time if (diffMinutes < 1) return 'In less than a minute'; @@ -130,7 +133,7 @@ function formatRelativeTime(timestamp) { if (diffHours < 24) return `In ${diffHours} hours`; if (diffDays === 1) return 'Tomorrow'; if (diffDays < 7) return `In ${diffDays} days`; - return date.toLocaleString(); + return `In ${diffDays} days`; } }