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
428 lines
17 KiB
HTML
428 lines
17 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Create Schedule - 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;">Create Schedule</h1>
|
|
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary">
|
|
<i class="bi bi-arrow-left"></i> Back to Schedules
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-lg-8">
|
|
<form id="create-schedule-form">
|
|
<!-- Basic Information Card -->
|
|
<div class="card mb-4">
|
|
<div class="card-header">
|
|
<h5 class="mb-0" style="color: #60a5fa;">Basic Information</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<!-- Schedule Name -->
|
|
<div class="mb-3">
|
|
<label for="schedule-name" class="form-label">Schedule Name <span class="text-danger">*</span></label>
|
|
<input type="text" class="form-control" id="schedule-name" name="name"
|
|
placeholder="e.g., Daily Infrastructure Scan"
|
|
required>
|
|
<small class="form-text text-muted">A descriptive name for this schedule</small>
|
|
</div>
|
|
|
|
<!-- Config File -->
|
|
<div class="mb-3">
|
|
<label for="config-file" class="form-label">Configuration File <span class="text-danger">*</span></label>
|
|
<select class="form-select" id="config-file" name="config_file" required>
|
|
<option value="">Select a configuration file...</option>
|
|
{% for config in config_files %}
|
|
<option value="{{ config }}">{{ config }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<small class="form-text text-muted">The scan configuration to use for this schedule</small>
|
|
</div>
|
|
|
|
<!-- Enable/Disable -->
|
|
<div class="mb-3">
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" id="schedule-enabled"
|
|
name="enabled" checked>
|
|
<label class="form-check-label" for="schedule-enabled">
|
|
Enable schedule immediately
|
|
</label>
|
|
</div>
|
|
<small class="form-text text-muted">If disabled, the schedule will be created but not executed</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cron Expression Card -->
|
|
<div class="card mb-4">
|
|
<div class="card-header">
|
|
<h5 class="mb-0" style="color: #60a5fa;">Schedule Configuration</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<!-- Quick Templates -->
|
|
<div class="mb-3">
|
|
<label class="form-label">Quick Templates:</label>
|
|
<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 * * *')">
|
|
<strong>Daily at Midnight</strong> <code class="float-end">0 0 * * *</code>
|
|
</button>
|
|
<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>
|
|
</button>
|
|
<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>
|
|
</button>
|
|
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * 0')">
|
|
<strong>Weekly (Sunday at Midnight)</strong> <code class="float-end">0 0 * * 0</code>
|
|
</button>
|
|
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 1 * *')">
|
|
<strong>Monthly (1st at Midnight)</strong> <code class="float-end">0 0 1 * *</code>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Manual Cron Entry -->
|
|
<div class="mb-3">
|
|
<label for="cron-expression" class="form-label">
|
|
Cron Expression <span class="text-danger">*</span>
|
|
</label>
|
|
<input type="text" class="form-control font-monospace" id="cron-expression"
|
|
name="cron_expression" placeholder="0 2 * * *"
|
|
oninput="validateCron()" required>
|
|
<small class="form-text text-muted">
|
|
Format: <code>minute hour day month weekday</code> (UTC timezone)
|
|
</small>
|
|
</div>
|
|
|
|
<!-- Cron Validation Feedback -->
|
|
<div id="cron-feedback" class="alert" style="display: none;"></div>
|
|
|
|
<!-- Human-Readable Description -->
|
|
<div id="cron-description-container" style="display: none;">
|
|
<div class="alert alert-info">
|
|
<strong>Description:</strong>
|
|
<div id="cron-description" class="mt-1"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Next Run Times Preview -->
|
|
<div id="next-runs-container" style="display: none;">
|
|
<label class="form-label">Next 5 execution times (UTC):</label>
|
|
<ul id="next-runs-list" class="list-group">
|
|
<!-- Populated by JavaScript -->
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Submit Buttons -->
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between">
|
|
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary">Cancel</a>
|
|
<button type="submit" class="btn btn-primary" id="submit-btn">
|
|
<i class="bi bi-plus-circle"></i> Create Schedule
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Help Sidebar -->
|
|
<div class="col-lg-4">
|
|
<div class="card sticky-top" style="top: 20px;">
|
|
<div class="card-header">
|
|
<h5 class="mb-0" style="color: #60a5fa;">Cron Expression Help</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<h6>Field Format:</h6>
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Field</th>
|
|
<th>Values</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>Minute</td>
|
|
<td>0-59</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Hour</td>
|
|
<td>0-23</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Day</td>
|
|
<td>1-31</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Month</td>
|
|
<td>1-12</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Weekday</td>
|
|
<td>0-6 (0=Sunday)</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<h6 class="mt-3">Special Characters:</h6>
|
|
<ul class="list-unstyled">
|
|
<li><code>*</code> - Any value</li>
|
|
<li><code>*/n</code> - Every n units</li>
|
|
<li><code>1,2,3</code> - Specific values</li>
|
|
<li><code>1-5</code> - Range of values</li>
|
|
</ul>
|
|
|
|
<h6 class="mt-3">Examples:</h6>
|
|
<ul class="list-unstyled">
|
|
<li><code>0 0 * * *</code> - Daily at midnight</li>
|
|
<li><code>*/15 * * * *</code> - Every 15 minutes</li>
|
|
<li><code>0 9-17 * * 1-5</code> - Hourly, 9am-5pm, Mon-Fri</li>
|
|
</ul>
|
|
|
|
<div class="alert alert-warning mt-3">
|
|
<strong>Note:</strong> All times are in UTC timezone. The server is currently at
|
|
<strong><span id="server-time"></span></strong> UTC.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Update server time every second
|
|
function updateServerTime() {
|
|
const now = new Date();
|
|
document.getElementById('server-time').textContent = now.toUTCString().split(' ')[4];
|
|
}
|
|
updateServerTime();
|
|
setInterval(updateServerTime, 1000);
|
|
|
|
// Set cron expression from template button
|
|
function setCron(expression) {
|
|
document.getElementById('cron-expression').value = expression;
|
|
validateCron();
|
|
}
|
|
|
|
// Validate cron expression (client-side basic validation)
|
|
function validateCron() {
|
|
const input = document.getElementById('cron-expression');
|
|
const expression = input.value.trim();
|
|
const feedback = document.getElementById('cron-feedback');
|
|
const descContainer = document.getElementById('cron-description-container');
|
|
const description = document.getElementById('cron-description');
|
|
const nextRunsContainer = document.getElementById('next-runs-container');
|
|
|
|
if (!expression) {
|
|
feedback.style.display = 'none';
|
|
descContainer.style.display = 'none';
|
|
nextRunsContainer.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
// Basic validation: should have 5 fields
|
|
const parts = expression.split(/\s+/);
|
|
if (parts.length !== 5) {
|
|
feedback.className = 'alert alert-danger';
|
|
feedback.textContent = 'Invalid format: Cron expression must have exactly 5 fields (minute hour day month weekday)';
|
|
feedback.style.display = 'block';
|
|
descContainer.style.display = 'none';
|
|
nextRunsContainer.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
// Basic field validation
|
|
const [minute, hour, day, month, weekday] = parts;
|
|
|
|
const errors = [];
|
|
|
|
if (!isValidCronField(minute, 0, 59)) errors.push('minute (0-59)');
|
|
if (!isValidCronField(hour, 0, 23)) errors.push('hour (0-23)');
|
|
if (!isValidCronField(day, 1, 31)) errors.push('day (1-31)');
|
|
if (!isValidCronField(month, 1, 12)) errors.push('month (1-12)');
|
|
if (!isValidCronField(weekday, 0, 6)) errors.push('weekday (0-6)');
|
|
|
|
if (errors.length > 0) {
|
|
feedback.className = 'alert alert-danger';
|
|
feedback.textContent = 'Invalid fields: ' + errors.join(', ');
|
|
feedback.style.display = 'block';
|
|
descContainer.style.display = 'none';
|
|
nextRunsContainer.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
// Valid expression
|
|
feedback.className = 'alert alert-success';
|
|
feedback.textContent = 'Valid cron expression';
|
|
feedback.style.display = 'block';
|
|
|
|
// Show human-readable description
|
|
description.textContent = describeCron(parts);
|
|
descContainer.style.display = 'block';
|
|
|
|
// Calculate and show next run times
|
|
calculateNextRuns(expression);
|
|
nextRunsContainer.style.display = 'block';
|
|
}
|
|
|
|
// Validate individual cron field
|
|
function isValidCronField(field, min, max) {
|
|
if (field === '*') return true;
|
|
|
|
// Handle ranges: 1-5
|
|
if (field.includes('-')) {
|
|
const [start, end] = field.split('-').map(Number);
|
|
return start >= min && end <= max && start <= end;
|
|
}
|
|
|
|
// Handle steps: */5 or 1-10/2
|
|
if (field.includes('/')) {
|
|
const [range, step] = field.split('/');
|
|
if (range === '*') return Number(step) > 0;
|
|
return isValidCronField(range, min, max) && Number(step) > 0;
|
|
}
|
|
|
|
// Handle lists: 1,2,3
|
|
if (field.includes(',')) {
|
|
return field.split(',').every(v => {
|
|
const num = Number(v);
|
|
return !isNaN(num) && num >= min && num <= max;
|
|
});
|
|
}
|
|
|
|
// Single number
|
|
const num = Number(field);
|
|
return !isNaN(num) && num >= min && num <= max;
|
|
}
|
|
|
|
// Generate human-readable description
|
|
function describeCron(parts) {
|
|
const [minute, hour, day, month, weekday] = parts;
|
|
|
|
// Common patterns
|
|
if (minute === '0' && hour === '0' && day === '*' && month === '*' && weekday === '*') {
|
|
return 'Runs daily at midnight (00:00 UTC)';
|
|
}
|
|
if (minute === '0' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
|
|
return `Runs daily at ${hour.padStart(2, '0')}:00 UTC`;
|
|
}
|
|
if (minute !== '*' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
|
|
return `Runs daily at ${hour.padStart(2, '0')}:${minute.padStart(2, '0')} UTC`;
|
|
}
|
|
if (minute === '0' && hour === '0' && day === '*' && month === '*' && weekday !== '*') {
|
|
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
return `Runs weekly on ${days[Number(weekday)]} at midnight`;
|
|
}
|
|
if (minute === '0' && hour === '0' && day !== '*' && month === '*' && weekday === '*') {
|
|
return `Runs monthly on day ${day} at midnight`;
|
|
}
|
|
if (minute.startsWith('*/')) {
|
|
const interval = minute.split('/')[1];
|
|
return `Runs every ${interval} minutes`;
|
|
}
|
|
if (hour.startsWith('*/') && minute === '0') {
|
|
const interval = hour.split('/')[1];
|
|
return `Runs every ${interval} hours`;
|
|
}
|
|
|
|
return `Runs at ${minute} ${hour} ${day} ${month} ${weekday} (cron format)`;
|
|
}
|
|
|
|
// Calculate next 5 run times (simplified - server will do actual calculation)
|
|
function calculateNextRuns(expression) {
|
|
const list = document.getElementById('next-runs-list');
|
|
list.innerHTML = '<li class="list-group-item"><em>Will be calculated by server...</em></li>';
|
|
|
|
// In production, this would call an API endpoint to get accurate next runs
|
|
// For now, just show placeholder
|
|
}
|
|
|
|
// Handle form submission
|
|
document.getElementById('create-schedule-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const submitBtn = document.getElementById('submit-btn');
|
|
const originalText = submitBtn.innerHTML;
|
|
|
|
// Get form data
|
|
const formData = {
|
|
name: document.getElementById('schedule-name').value.trim(),
|
|
config_file: document.getElementById('config-file').value,
|
|
cron_expression: document.getElementById('cron-expression').value.trim(),
|
|
enabled: document.getElementById('schedule-enabled').checked
|
|
};
|
|
|
|
// Validate
|
|
if (!formData.name || !formData.config_file || !formData.cron_expression) {
|
|
showNotification('Please fill in all required fields', 'warning');
|
|
return;
|
|
}
|
|
|
|
// Disable submit button
|
|
submitBtn.disabled = true;
|
|
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
|
|
|
|
try {
|
|
const response = await fetch('/api/schedules', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(formData)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || `HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
showNotification('Schedule created successfully! Redirecting...', 'success');
|
|
|
|
// Redirect to schedules list
|
|
setTimeout(() => {
|
|
window.location.href = '/schedules';
|
|
}, 1500);
|
|
|
|
} catch (error) {
|
|
console.error('Error creating schedule:', error);
|
|
showNotification(`Error: ${error.message}`, 'danger');
|
|
|
|
// Re-enable submit button
|
|
submitBtn.disabled = false;
|
|
submitBtn.innerHTML = originalText;
|
|
}
|
|
});
|
|
|
|
// Show notification
|
|
function showNotification(message, type = 'info') {
|
|
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);
|
|
|
|
setTimeout(() => {
|
|
notification.remove();
|
|
}, 5000);
|
|
}
|
|
</script>
|
|
{% endblock %}
|