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.
This commit is contained in:
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user