Phase 3 Steps 3 & 4: Complete Schedules API & Management UI
Implemented full schedule management system with API endpoints and user interface for creating, editing, and managing scheduled scans. API Implementation: - Implemented all 6 schedules API endpoints (list, get, create, update, delete, trigger) - Added comprehensive error handling and validation - Integrated with ScheduleService and SchedulerService - Added manual trigger endpoint for on-demand execution Schedule Management UI: - Created schedules list page with stats cards and enable/disable toggles - Built schedule creation form with cron expression builder and quick templates - Implemented schedule edit page with execution history - Added "Schedules" navigation link to main menu - Real-time validation and human-readable cron descriptions Config File Path Resolution: - Fixed config file path handling to support relative filenames - Updated validators.py to resolve relative paths to /app/configs/ - Modified schedule_service.py, scan_service.py, and scan_job.py for consistency - Ensures UI can use simple filenames while backend uses absolute paths Scheduler Integration: - Completed scheduled scan execution in scheduler_service.py - Added cron job management with APScheduler - Implemented automatic schedule loading on startup - Updated run times after each execution Testing: - Added comprehensive API integration tests (test_schedule_api.py) - 22+ test cases covering all endpoints and workflows Progress: Phase 3 Steps 1-4 complete (36% - 5/14 days) Next: Step 5 - Enhanced Dashboard with Charts
This commit is contained in:
389
web/templates/schedules.html
Normal file
389
web/templates/schedules.html
Normal file
@@ -0,0 +1,389 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Scheduled Scans - SneakyScanner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 style="color: #60a5fa;">Scheduled Scans</h1>
|
||||
<a href="{{ url_for('main.create_schedule') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> New Schedule
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="total-schedules">-</div>
|
||||
<div class="stat-label">Total Schedules</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="enabled-schedules">-</div>
|
||||
<div class="stat-label">Enabled</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="next-run-time">-</div>
|
||||
<div class="stat-label">Next Run</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="recent-executions">-</div>
|
||||
<div class="stat-label">Executions (24h)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedules Table -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">All Schedules</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="schedules-loading" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p class="mt-3 text-muted">Loading schedules...</p>
|
||||
</div>
|
||||
<div id="schedules-error" style="display: none;" class="alert alert-danger">
|
||||
<strong>Error:</strong> <span id="error-message"></span>
|
||||
</div>
|
||||
<div id="schedules-content" style="display: none;">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Schedule (Cron)</th>
|
||||
<th>Next Run</th>
|
||||
<th>Last Run</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="schedules-tbody">
|
||||
<!-- Populated by JavaScript -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="empty-state" style="display: none;" class="text-center py-5">
|
||||
<i class="bi bi-calendar-x" style="font-size: 3rem; color: #64748b;"></i>
|
||||
<h5 class="mt-3 text-muted">No schedules configured</h5>
|
||||
<p class="text-muted">Create your first schedule to automate scans</p>
|
||||
<a href="{{ url_for('main.create_schedule') }}" class="btn btn-primary mt-2">
|
||||
<i class="bi bi-plus-circle"></i> Create Schedule
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Global variables
|
||||
let schedulesData = [];
|
||||
|
||||
// Format relative time (e.g., "in 2 hours", "5 minutes ago")
|
||||
function formatRelativeTime(timestamp) {
|
||||
if (!timestamp) return 'Never';
|
||||
|
||||
const now = new Date();
|
||||
const date = new Date(timestamp);
|
||||
const diffMs = date - now;
|
||||
const diffMinutes = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMs < 0) {
|
||||
// Past time
|
||||
const absDiffMinutes = Math.abs(diffMinutes);
|
||||
const absDiffHours = Math.abs(diffHours);
|
||||
const absDiffDays = Math.abs(diffDays);
|
||||
|
||||
if (absDiffMinutes < 1) return 'Just now';
|
||||
if (absDiffMinutes === 1) return '1 minute ago';
|
||||
if (absDiffMinutes < 60) return `${absDiffMinutes} minutes ago`;
|
||||
if (absDiffHours === 1) return '1 hour ago';
|
||||
if (absDiffHours < 24) return `${absDiffHours} hours ago`;
|
||||
if (absDiffDays === 1) return 'Yesterday';
|
||||
if (absDiffDays < 7) return `${absDiffDays} days ago`;
|
||||
return date.toLocaleString();
|
||||
} else {
|
||||
// Future time
|
||||
if (diffMinutes < 1) return 'In less than a minute';
|
||||
if (diffMinutes === 1) return 'In 1 minute';
|
||||
if (diffMinutes < 60) return `In ${diffMinutes} minutes`;
|
||||
if (diffHours === 1) return 'In 1 hour';
|
||||
if (diffHours < 24) return `In ${diffHours} hours`;
|
||||
if (diffDays === 1) return 'Tomorrow';
|
||||
if (diffDays < 7) return `In ${diffDays} days`;
|
||||
return date.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
// Get status badge HTML
|
||||
function getStatusBadge(enabled) {
|
||||
if (enabled) {
|
||||
return '<span class="badge bg-success">Enabled</span>';
|
||||
} else {
|
||||
return '<span class="badge bg-secondary">Disabled</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// Load schedules from API
|
||||
async function loadSchedules() {
|
||||
try {
|
||||
const response = await fetch('/api/schedules');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
schedulesData = data.schedules || [];
|
||||
|
||||
renderSchedules();
|
||||
updateStats(data);
|
||||
|
||||
// Hide loading, show content
|
||||
document.getElementById('schedules-loading').style.display = 'none';
|
||||
document.getElementById('schedules-error').style.display = 'none';
|
||||
document.getElementById('schedules-content').style.display = 'block';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading schedules:', error);
|
||||
document.getElementById('schedules-loading').style.display = 'none';
|
||||
document.getElementById('schedules-content').style.display = 'none';
|
||||
document.getElementById('schedules-error').style.display = 'block';
|
||||
document.getElementById('error-message').textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Render schedules table
|
||||
function renderSchedules() {
|
||||
const tbody = document.getElementById('schedules-tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (schedulesData.length === 0) {
|
||||
document.querySelector('.table-responsive').style.display = 'none';
|
||||
document.getElementById('empty-state').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelector('.table-responsive').style.display = 'block';
|
||||
document.getElementById('empty-state').style.display = 'none';
|
||||
|
||||
schedulesData.forEach(schedule => {
|
||||
const row = document.createElement('tr');
|
||||
row.classList.add('schedule-row');
|
||||
|
||||
row.innerHTML = `
|
||||
<td class="mono">#${schedule.id}</td>
|
||||
<td>
|
||||
<strong>${escapeHtml(schedule.name)}</strong>
|
||||
<br>
|
||||
<small class="text-muted">${escapeHtml(schedule.config_file)}</small>
|
||||
</td>
|
||||
<td class="mono"><code>${escapeHtml(schedule.cron_expression)}</code></td>
|
||||
<td>${formatRelativeTime(schedule.next_run)}</td>
|
||||
<td>${formatRelativeTime(schedule.last_run)}</td>
|
||||
<td>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
id="enable-${schedule.id}"
|
||||
${schedule.enabled ? 'checked' : ''}
|
||||
onchange="toggleSchedule(${schedule.id}, this.checked)">
|
||||
<label class="form-check-label" for="enable-${schedule.id}">
|
||||
${schedule.enabled ? 'Enabled' : 'Disabled'}
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-secondary" onclick="triggerSchedule(${schedule.id})"
|
||||
title="Run Now">
|
||||
<i class="bi bi-play-fill"></i>
|
||||
</button>
|
||||
<a href="/schedules/${schedule.id}/edit" class="btn btn-secondary"
|
||||
title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<button class="btn btn-danger" onclick="deleteSchedule(${schedule.id})"
|
||||
title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Update stats
|
||||
function updateStats(data) {
|
||||
const totalSchedules = data.total || schedulesData.length;
|
||||
const enabledSchedules = schedulesData.filter(s => s.enabled).length;
|
||||
|
||||
// Find next run time
|
||||
let nextRun = null;
|
||||
schedulesData.filter(s => s.enabled && s.next_run).forEach(s => {
|
||||
const scheduleNext = new Date(s.next_run);
|
||||
if (!nextRun || scheduleNext < nextRun) {
|
||||
nextRun = scheduleNext;
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate executions in last 24h (would need API support)
|
||||
const recentExecutions = data.recent_executions || 0;
|
||||
|
||||
document.getElementById('total-schedules').textContent = totalSchedules;
|
||||
document.getElementById('enabled-schedules').textContent = enabledSchedules;
|
||||
document.getElementById('next-run-time').innerHTML = nextRun
|
||||
? `<small>${formatRelativeTime(nextRun)}</small>`
|
||||
: '<small>None</small>';
|
||||
document.getElementById('recent-executions').textContent = recentExecutions;
|
||||
}
|
||||
|
||||
// Toggle schedule enabled/disabled
|
||||
async function toggleSchedule(scheduleId, enabled) {
|
||||
try {
|
||||
const response = await fetch(`/api/schedules/${scheduleId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ enabled: enabled })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update schedule: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Reload schedules
|
||||
await loadSchedules();
|
||||
|
||||
// Show success notification
|
||||
showNotification(`Schedule ${enabled ? 'enabled' : 'disabled'} successfully`, 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error toggling schedule:', error);
|
||||
showNotification(`Error: ${error.message}`, 'danger');
|
||||
|
||||
// Revert checkbox
|
||||
document.getElementById(`enable-${scheduleId}`).checked = !enabled;
|
||||
}
|
||||
}
|
||||
|
||||
// Manually trigger schedule
|
||||
async function triggerSchedule(scheduleId) {
|
||||
if (!confirm('Run this schedule now?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/schedules/${scheduleId}/trigger`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to trigger schedule: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
showNotification(`Scan triggered! Redirecting to scan #${data.scan_id}...`, 'success');
|
||||
|
||||
// Redirect to scan detail page
|
||||
setTimeout(() => {
|
||||
window.location.href = `/scans/${data.scan_id}`;
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error triggering schedule:', error);
|
||||
showNotification(`Error: ${error.message}`, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete schedule
|
||||
async function deleteSchedule(scheduleId) {
|
||||
const schedule = schedulesData.find(s => s.id === scheduleId);
|
||||
const scheduleName = schedule ? schedule.name : `#${scheduleId}`;
|
||||
|
||||
if (!confirm(`Delete schedule "${scheduleName}"?\n\nThis action cannot be undone. Associated scan history will be preserved.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/schedules/${scheduleId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete schedule: ${response.statusText}`);
|
||||
}
|
||||
|
||||
showNotification('Schedule deleted successfully', 'success');
|
||||
|
||||
// Reload schedules
|
||||
await loadSchedules();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting schedule:', error);
|
||||
showNotification(`Error: ${error.message}`, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// Show notification
|
||||
function showNotification(message, type = 'info') {
|
||||
// Create notification element
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
|
||||
notification.style.top = '20px';
|
||||
notification.style.right = '20px';
|
||||
notification.style.zIndex = '9999';
|
||||
notification.style.minWidth = '300px';
|
||||
|
||||
notification.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
notification.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Escape HTML to prevent XSS
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Load schedules on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadSchedules();
|
||||
|
||||
// Refresh every 30 seconds
|
||||
setInterval(loadSchedules, 30000);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user