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:
2025-11-14 15:44:13 -06:00
parent effce42f21
commit 9b88f42297
7 changed files with 200 additions and 37 deletions

View File

@@ -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 **Priority: CRITICAL** - Complete scheduled execution
**Tasks:** **Tasks:**

View File

@@ -294,11 +294,23 @@ def init_scheduler(app: Flask) -> None:
app: Flask application instance app: Flask application instance
""" """
from web.services.scheduler_service import SchedulerService from web.services.scheduler_service import SchedulerService
from web.services.scan_service import ScanService
# Create and initialize scheduler # Create and initialize scheduler
scheduler = SchedulerService() scheduler = SchedulerService()
scheduler.init_scheduler(app) 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 # Store in app context for access from routes
app.scheduler = scheduler app.scheduler = scheduler

View File

@@ -268,6 +268,53 @@ class ScanService:
return status_info 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, def _save_scan_to_db(self, report: Dict[str, Any], scan_id: int,
status: str = 'completed') -> None: status: str = 'completed') -> None:
""" """

View File

@@ -6,7 +6,7 @@ scan execution and future scheduled scanning capabilities.
""" """
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from typing import Optional from typing import Optional
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
@@ -63,11 +63,13 @@ class SchedulerService:
'misfire_grace_time': 60 # Allow 60 seconds for delayed starts '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( self.scheduler = BackgroundScheduler(
executors=executors, executors=executors,
job_defaults=job_defaults, job_defaults=job_defaults
timezone='UTC' # timezone defaults to local system timezone
) )
# Start scheduler # Start scheduler
@@ -90,6 +92,63 @@ class SchedulerService:
logger.info("APScheduler shutdown complete") logger.info("APScheduler shutdown complete")
self.scheduler = None 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: def queue_scan(self, scan_id: int, config_file: str) -> str:
""" """
Queue a scan for immediate background execution. Queue a scan for immediate background execution.
@@ -142,9 +201,11 @@ class SchedulerService:
from apscheduler.triggers.cron import CronTrigger 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: try:
trigger = CronTrigger.from_crontab(cron_expression) trigger = CronTrigger.from_crontab(cron_expression)
# timezone defaults to local system timezone
except (ValueError, KeyError) as e: except (ValueError, KeyError) as e:
raise ValueError(f"Invalid cron expression '{cron_expression}': {str(e)}") raise ValueError(f"Invalid cron expression '{cron_expression}': {str(e)}")

View File

@@ -69,10 +69,10 @@
<label class="form-label">Quick Templates:</label> <label class="form-label">Quick Templates:</label>
<div class="btn-group-vertical btn-group-sm w-100" role="group"> <div class="btn-group-vertical btn-group-sm w-100" role="group">
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * *')"> <button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * *')">
<strong>Daily at Midnight</strong> <code class="float-end">0 0 * * *</code> <strong>Daily at Midnight (local)</strong> <code class="float-end">0 0 * * *</code>
</button> </button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 2 * * *')"> <button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 2 * * *')">
<strong>Daily at 2 AM</strong> <code class="float-end">0 2 * * *</code> <strong>Daily at 2 AM (local)</strong> <code class="float-end">0 2 * * *</code>
</button> </button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 */6 * * *')"> <button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 */6 * * *')">
<strong>Every 6 Hours</strong> <code class="float-end">0 */6 * * *</code> <strong>Every 6 Hours</strong> <code class="float-end">0 */6 * * *</code>
@@ -90,12 +90,14 @@
<div class="mb-3"> <div class="mb-3">
<label for="cron-expression" class="form-label"> <label for="cron-expression" class="form-label">
Cron Expression <span class="text-danger">*</span> Cron Expression <span class="text-danger">*</span>
<span class="badge bg-info">LOCAL TIME</span>
</label> </label>
<input type="text" class="form-control font-monospace" id="cron-expression" <input type="text" class="form-control font-monospace" id="cron-expression"
name="cron_expression" placeholder="0 2 * * *" name="cron_expression" placeholder="0 2 * * *"
oninput="validateCron()" required> oninput="validateCron()" required>
<small class="form-text text-muted"> <small class="form-text text-muted">
Format: <code>minute hour day month weekday</code> (UTC timezone) Format: <code>minute hour day month weekday</code><br>
<strong class="text-info"> All times use your local timezone (CST/UTC-6)</strong>
</small> </small>
</div> </div>
@@ -112,7 +114,7 @@
<!-- Next Run Times Preview --> <!-- Next Run Times Preview -->
<div id="next-runs-container" style="display: none;"> <div id="next-runs-container" style="display: none;">
<label class="form-label">Next 5 execution times (UTC):</label> <label class="form-label">Next 5 execution times (local time):</label>
<ul id="next-runs-list" class="list-group"> <ul id="next-runs-list" class="list-group">
<!-- Populated by JavaScript --> <!-- Populated by JavaScript -->
</ul> </ul>
@@ -188,9 +190,12 @@
<li><code>0 9-17 * * 1-5</code> - Hourly, 9am-5pm, Mon-Fri</li> <li><code>0 9-17 * * 1-5</code> - Hourly, 9am-5pm, Mon-Fri</li>
</ul> </ul>
<div class="alert alert-warning mt-3"> <div class="alert alert-info mt-3">
<strong>Note:</strong> All times are in UTC timezone. The server is currently at <strong><i class="bi bi-info-circle"></i> Timezone Information:</strong><br>
<strong><span id="server-time"></span></strong> UTC. All cron expressions use your <strong>local system time</strong>.<br><br>
<strong>Current local time:</strong> <span id="user-local-time"></span><br>
<strong>Your timezone:</strong> <span id="timezone-offset"></span><br><br>
<small>Schedules will run at the specified time in your local timezone.</small>
</div> </div>
</div> </div>
</div> </div>
@@ -198,10 +203,19 @@
</div> </div>
<script> <script>
// Update server time every second // Update local time and timezone info every second
function updateServerTime() { function updateServerTime() {
const now = new Date(); const now = new Date();
document.getElementById('server-time').textContent = now.toUTCString().split(' ')[4]; const localTime = now.toLocaleTimeString();
const offset = -now.getTimezoneOffset() / 60;
const offsetStr = `CST (UTC${offset >= 0 ? '+' : ''}${offset})`;
if (document.getElementById('user-local-time')) {
document.getElementById('user-local-time').textContent = localTime;
}
if (document.getElementById('timezone-offset')) {
document.getElementById('timezone-offset').textContent = offsetStr;
}
} }
updateServerTime(); updateServerTime();
setInterval(updateServerTime, 1000); setInterval(updateServerTime, 1000);
@@ -309,13 +323,13 @@ function describeCron(parts) {
// Common patterns // Common patterns
if (minute === '0' && hour === '0' && day === '*' && month === '*' && weekday === '*') { if (minute === '0' && hour === '0' && day === '*' && month === '*' && weekday === '*') {
return 'Runs daily at midnight (00:00 UTC)'; return 'Runs daily at midnight (local time)';
} }
if (minute === '0' && hour !== '*' && day === '*' && month === '*' && weekday === '*') { if (minute === '0' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
return `Runs daily at ${hour.padStart(2, '0')}:00 UTC`; return `Runs daily at ${hour.padStart(2, '0')}:00 (local time)`;
} }
if (minute !== '*' && hour !== '*' && day === '*' && month === '*' && weekday === '*') { if (minute !== '*' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
return `Runs daily at ${hour.padStart(2, '0')}:${minute.padStart(2, '0')} UTC`; return `Runs daily at ${hour.padStart(2, '0')}:${minute.padStart(2, '0')} (local time)`;
} }
if (minute === '0' && hour === '0' && day === '*' && month === '*' && weekday !== '*') { if (minute === '0' && hour === '0' && day === '*' && month === '*' && weekday !== '*') {
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];

View File

@@ -92,10 +92,10 @@
<label class="form-label">Quick Templates:</label> <label class="form-label">Quick Templates:</label>
<div class="btn-group-vertical btn-group-sm w-100" role="group"> <div class="btn-group-vertical btn-group-sm w-100" role="group">
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * *')"> <button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * *')">
<strong>Daily at Midnight</strong> <code class="float-end">0 0 * * *</code> <strong>Daily at Midnight (local)</strong> <code class="float-end">0 0 * * *</code>
</button> </button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 2 * * *')"> <button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 2 * * *')">
<strong>Daily at 2 AM</strong> <code class="float-end">0 2 * * *</code> <strong>Daily at 2 AM (local)</strong> <code class="float-end">0 2 * * *</code>
</button> </button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 */6 * * *')"> <button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 */6 * * *')">
<strong>Every 6 Hours</strong> <code class="float-end">0 */6 * * *</code> <strong>Every 6 Hours</strong> <code class="float-end">0 */6 * * *</code>
@@ -118,7 +118,7 @@
name="cron_expression" placeholder="0 2 * * *" name="cron_expression" placeholder="0 2 * * *"
oninput="validateCron()" required> oninput="validateCron()" required>
<small class="form-text text-muted"> <small class="form-text text-muted">
Format: <code>minute hour day month weekday</code> (UTC timezone) Format: <code>minute hour day month weekday</code> (local timezone)
</small> </small>
</div> </div>
@@ -130,13 +130,13 @@
<div class="col-md-6"> <div class="col-md-6">
<div class="alert alert-info"> <div class="alert alert-info">
<strong>Last Run:</strong><br> <strong>Last Run:</strong><br>
<span id="last-run">Never</span> <span id="last-run" style="white-space: pre-line;">Never</span>
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="alert alert-info"> <div class="alert alert-info">
<strong>Next Run:</strong><br> <strong>Next Run:</strong><br>
<span id="next-run">Not scheduled</span> <span id="next-run" style="white-space: pre-line;">Not scheduled</span>
</div> </div>
</div> </div>
</div> </div>
@@ -248,8 +248,11 @@
<li><code>1-5</code> - Range of values</li> <li><code>1-5</code> - Range of values</li>
</ul> </ul>
<div class="alert alert-warning mt-3"> <div class="alert alert-info mt-3">
<strong>Note:</strong> All times are in UTC timezone. <strong><i class="bi bi-info-circle"></i> Timezone Information:</strong><br>
All cron expressions use your <strong>local system time</strong>.<br><br>
<strong>Current local time:</strong> <span id="current-local"></span><br>
<strong>Your timezone:</strong> <span id="tz-offset"></span>
</div> </div>
</div> </div>
</div> </div>
@@ -303,13 +306,15 @@ function populateForm(schedule) {
document.getElementById('created-at').textContent = new Date(schedule.created_at).toLocaleString(); document.getElementById('created-at').textContent = new Date(schedule.created_at).toLocaleString();
document.getElementById('updated-at').textContent = new Date(schedule.updated_at).toLocaleString(); document.getElementById('updated-at').textContent = new Date(schedule.updated_at).toLocaleString();
// Run times // Run times - show in local time
document.getElementById('last-run').textContent = schedule.last_run document.getElementById('last-run').textContent = schedule.last_run
? formatRelativeTime(schedule.last_run) + ' (' + new Date(schedule.last_run).toLocaleString() + ')' ? formatRelativeTime(schedule.last_run) + '\n' +
new Date(schedule.last_run).toLocaleString()
: 'Never'; : 'Never';
document.getElementById('next-run').textContent = schedule.next_run && schedule.enabled document.getElementById('next-run').textContent = schedule.next_run && schedule.enabled
? formatRelativeTime(schedule.next_run) + ' (' + new Date(schedule.next_run).toLocaleString() + ')' ? formatRelativeTime(schedule.next_run) + '\n' +
new Date(schedule.next_run).toLocaleString()
: (schedule.enabled ? 'Calculating...' : 'Disabled'); : (schedule.enabled ? 'Calculating...' : 'Disabled');
// Validate cron // Validate cron
@@ -396,17 +401,22 @@ function formatRelativeTime(timestamp) {
const diffMs = date - now; const diffMs = date - now;
const diffMinutes = Math.abs(Math.floor(diffMs / 60000)); const diffMinutes = Math.abs(Math.floor(diffMs / 60000));
const diffHours = Math.abs(Math.floor(diffMs / 3600000)); const diffHours = Math.abs(Math.floor(diffMs / 3600000));
const diffDays = Math.abs(Math.floor(diffMs / 86400000));
if (diffMs < 0) { if (diffMs < 0) {
// Past time
if (diffMinutes < 1) return 'Just now'; if (diffMinutes < 1) return 'Just now';
if (diffMinutes < 60) return `${diffMinutes} minutes ago`; if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''} ago`;
if (diffHours < 24) return `${diffHours} hours ago`; if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
return date.toLocaleString(); if (diffDays === 1) return 'Yesterday';
return `${diffDays} days ago`;
} else { } else {
// Future time
if (diffMinutes < 1) return 'In less than a minute'; if (diffMinutes < 1) return 'In less than a minute';
if (diffMinutes < 60) return `In ${diffMinutes} minutes`; if (diffMinutes < 60) return `In ${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''}`;
if (diffHours < 24) return `In ${diffHours} hours`; if (diffHours < 24) return `In ${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
return date.toLocaleString(); if (diffDays === 1) return 'Tomorrow';
return `In ${diffDays} days`;
} }
} }
@@ -563,7 +573,23 @@ function showNotification(message, type = 'info') {
}, 5000); }, 5000);
} }
// Update current time display
function updateCurrentTime() {
const now = new Date();
if (document.getElementById('current-local')) {
document.getElementById('current-local').textContent = now.toLocaleTimeString();
}
if (document.getElementById('tz-offset')) {
const offset = -now.getTimezoneOffset() / 60;
document.getElementById('tz-offset').textContent = `CST (UTC${offset >= 0 ? '+' : ''}${offset})`;
}
}
// Load on page load // Load on page load
document.addEventListener('DOMContentLoaded', loadSchedule); document.addEventListener('DOMContentLoaded', () => {
loadSchedule();
updateCurrentTime();
setInterval(updateCurrentTime, 1000);
});
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -107,6 +107,9 @@ function formatRelativeTime(timestamp) {
const diffHours = Math.floor(diffMs / 3600000); const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000); const diffDays = Math.floor(diffMs / 86400000);
// Get local time string for tooltip/fallback
const localStr = date.toLocaleString();
if (diffMs < 0) { if (diffMs < 0) {
// Past time // Past time
const absDiffMinutes = Math.abs(diffMinutes); const absDiffMinutes = Math.abs(diffMinutes);
@@ -120,7 +123,7 @@ function formatRelativeTime(timestamp) {
if (absDiffHours < 24) return `${absDiffHours} hours ago`; if (absDiffHours < 24) return `${absDiffHours} hours ago`;
if (absDiffDays === 1) return 'Yesterday'; if (absDiffDays === 1) return 'Yesterday';
if (absDiffDays < 7) return `${absDiffDays} days ago`; if (absDiffDays < 7) return `${absDiffDays} days ago`;
return date.toLocaleString(); return `<span title="${localStr}">${absDiffDays} days ago</span>`;
} else { } else {
// Future time // Future time
if (diffMinutes < 1) return 'In less than a minute'; if (diffMinutes < 1) return 'In less than a minute';
@@ -130,7 +133,7 @@ function formatRelativeTime(timestamp) {
if (diffHours < 24) return `In ${diffHours} hours`; if (diffHours < 24) return `In ${diffHours} hours`;
if (diffDays === 1) return 'Tomorrow'; if (diffDays === 1) return 'Tomorrow';
if (diffDays < 7) return `In ${diffDays} days`; if (diffDays < 7) return `In ${diffDays} days`;
return date.toLocaleString(); return `<span title="${localStr}">In ${diffDays} days</span>`;
} }
} }