597 lines
23 KiB
HTML
597 lines
23 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Edit 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;">Edit 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">
|
|
<!-- Loading State -->
|
|
<div id="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 schedule...</p>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div id="error-state" style="display: none;" class="alert alert-danger">
|
|
<strong>Error:</strong> <span id="error-message"></span>
|
|
</div>
|
|
|
|
<!-- Edit Form -->
|
|
<form id="edit-schedule-form" style="display: none;">
|
|
<input type="hidden" id="schedule-id">
|
|
|
|
<!-- 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>
|
|
</div>
|
|
|
|
<!-- Config File (read-only) -->
|
|
<div class="mb-3">
|
|
<label for="config-file" class="form-label">Configuration File</label>
|
|
<input type="text" class="form-control" id="config-file" readonly>
|
|
<small class="form-text text-muted">Configuration file cannot be changed after creation</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">
|
|
<label class="form-check-label" for="schedule-enabled">
|
|
Schedule enabled
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Metadata -->
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<small class="text-muted">
|
|
<strong>Created:</strong> <span id="created-at">-</span>
|
|
</small>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<small class="text-muted">
|
|
<strong>Last Modified:</strong> <span id="updated-at">-</span>
|
|
</small>
|
|
</div>
|
|
</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 (local)</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 (local)</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> (local timezone)
|
|
</small>
|
|
</div>
|
|
|
|
<!-- Cron Validation Feedback -->
|
|
<div id="cron-feedback" class="alert" style="display: none;"></div>
|
|
|
|
<!-- Run Times Info -->
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<div class="alert alert-info">
|
|
<strong>Last Run:</strong><br>
|
|
<span id="last-run" style="white-space: pre-line;">Never</span>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="alert alert-info">
|
|
<strong>Next Run:</strong><br>
|
|
<span id="next-run" style="white-space: pre-line;">Not scheduled</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Execution History Card -->
|
|
<div class="card mb-4">
|
|
<div class="card-header">
|
|
<h5 class="mb-0" style="color: #60a5fa;">Execution History</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="history-loading" class="text-center py-3">
|
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
|
<span class="ms-2 text-muted">Loading history...</span>
|
|
</div>
|
|
<div id="history-content" style="display: none;">
|
|
<p class="text-muted">Last 10 scans triggered by this schedule:</p>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Scan ID</th>
|
|
<th>Started</th>
|
|
<th>Status</th>
|
|
<th>Duration</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="history-tbody">
|
|
<!-- Populated by JavaScript -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div id="history-empty" style="display: none;" class="text-center py-3 text-muted">
|
|
No executions yet
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Action Buttons -->
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between">
|
|
<div>
|
|
<button type="button" class="btn btn-danger" onclick="deleteSchedule()">
|
|
<i class="bi bi-trash"></i> Delete Schedule
|
|
</button>
|
|
<button type="button" class="btn btn-secondary" onclick="testRun()">
|
|
<i class="bi bi-play-fill"></i> Test Run Now
|
|
</button>
|
|
</div>
|
|
<div>
|
|
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary me-2">Cancel</a>
|
|
<button type="submit" class="btn btn-primary" id="submit-btn">
|
|
<i class="bi bi-check-circle"></i> Save Changes
|
|
</button>
|
|
</div>
|
|
</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>
|
|
|
|
<div class="alert alert-info mt-3">
|
|
<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>
|
|
|
|
<script>
|
|
let scheduleData = null;
|
|
|
|
// Get schedule ID from URL
|
|
const scheduleId = parseInt(window.location.pathname.split('/')[2]);
|
|
|
|
// Load schedule data
|
|
async function loadSchedule() {
|
|
try {
|
|
const response = await fetch(`/api/schedules/${scheduleId}`);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
scheduleData = await response.json();
|
|
|
|
// Populate form
|
|
populateForm(scheduleData);
|
|
|
|
// Load execution history
|
|
loadHistory();
|
|
|
|
// Hide loading, show form
|
|
document.getElementById('loading').style.display = 'none';
|
|
document.getElementById('edit-schedule-form').style.display = 'block';
|
|
|
|
} catch (error) {
|
|
console.error('Error loading schedule:', error);
|
|
document.getElementById('loading').style.display = 'none';
|
|
document.getElementById('error-state').style.display = 'block';
|
|
document.getElementById('error-message').textContent = error.message;
|
|
}
|
|
}
|
|
|
|
// Populate form with schedule data
|
|
function populateForm(schedule) {
|
|
document.getElementById('schedule-id').value = schedule.id;
|
|
document.getElementById('schedule-name').value = schedule.name;
|
|
document.getElementById('config-id').value = schedule.config_id;
|
|
document.getElementById('cron-expression').value = schedule.cron_expression;
|
|
document.getElementById('schedule-enabled').checked = schedule.enabled;
|
|
|
|
// Metadata
|
|
document.getElementById('created-at').textContent = new Date(schedule.created_at).toLocaleString();
|
|
document.getElementById('updated-at').textContent = new Date(schedule.updated_at).toLocaleString();
|
|
|
|
// Run times - show in local time
|
|
document.getElementById('last-run').textContent = schedule.last_run
|
|
? formatRelativeTime(schedule.last_run) + '\n' +
|
|
new Date(schedule.last_run).toLocaleString()
|
|
: 'Never';
|
|
|
|
document.getElementById('next-run').textContent = schedule.next_run && schedule.enabled
|
|
? formatRelativeTime(schedule.next_run) + '\n' +
|
|
new Date(schedule.next_run).toLocaleString()
|
|
: (schedule.enabled ? 'Calculating...' : 'Disabled');
|
|
|
|
// Validate cron
|
|
validateCron();
|
|
}
|
|
|
|
// Load execution history
|
|
async function loadHistory() {
|
|
try {
|
|
// Note: This would ideally be a separate API endpoint
|
|
// For now, we'll fetch scans filtered by schedule_id
|
|
const response = await fetch(`/api/scans?schedule_id=${scheduleId}&limit=10`);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const scans = data.scans || [];
|
|
|
|
renderHistory(scans);
|
|
|
|
document.getElementById('history-loading').style.display = 'none';
|
|
document.getElementById('history-content').style.display = 'block';
|
|
|
|
} catch (error) {
|
|
console.error('Error loading history:', error);
|
|
document.getElementById('history-loading').innerHTML = '<p class="text-danger">Failed to load history</p>';
|
|
}
|
|
}
|
|
|
|
// Render history table
|
|
function renderHistory(scans) {
|
|
const tbody = document.getElementById('history-tbody');
|
|
tbody.innerHTML = '';
|
|
|
|
if (scans.length === 0) {
|
|
document.querySelector('#history-content .table-responsive').style.display = 'none';
|
|
document.getElementById('history-empty').style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
document.querySelector('#history-content .table-responsive').style.display = 'block';
|
|
document.getElementById('history-empty').style.display = 'none';
|
|
|
|
scans.forEach(scan => {
|
|
const row = document.createElement('tr');
|
|
row.classList.add('schedule-row');
|
|
row.style.cursor = 'pointer';
|
|
row.onclick = () => window.location.href = `/scans/${scan.id}`;
|
|
|
|
const duration = scan.end_time
|
|
? Math.round((new Date(scan.end_time) - new Date(scan.timestamp)) / 1000) + 's'
|
|
: '-';
|
|
|
|
row.innerHTML = `
|
|
<td class="mono"><a href="/scans/${scan.id}">#${scan.id}</a></td>
|
|
<td>${new Date(scan.timestamp).toLocaleString()}</td>
|
|
<td>${getStatusBadge(scan.status)}</td>
|
|
<td>${duration}</td>
|
|
`;
|
|
|
|
tbody.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// Get status badge
|
|
function getStatusBadge(status) {
|
|
const badges = {
|
|
'running': '<span class="badge bg-primary">Running</span>',
|
|
'completed': '<span class="badge bg-success">Completed</span>',
|
|
'failed': '<span class="badge bg-danger">Failed</span>',
|
|
'pending': '<span class="badge bg-warning">Pending</span>'
|
|
};
|
|
return badges[status] || '<span class="badge bg-secondary">' + status + '</span>';
|
|
}
|
|
|
|
// Format relative time
|
|
function formatRelativeTime(timestamp) {
|
|
if (!timestamp) return 'Never';
|
|
|
|
const now = new Date();
|
|
const date = new Date(timestamp);
|
|
const diffMs = date - now;
|
|
const diffMinutes = Math.abs(Math.floor(diffMs / 60000));
|
|
const diffHours = Math.abs(Math.floor(diffMs / 3600000));
|
|
const diffDays = Math.abs(Math.floor(diffMs / 86400000));
|
|
|
|
if (diffMs < 0) {
|
|
// Past time
|
|
if (diffMinutes < 1) return 'Just now';
|
|
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''} ago`;
|
|
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
|
|
if (diffDays === 1) return 'Yesterday';
|
|
return `${diffDays} days ago`;
|
|
} else {
|
|
// Future time
|
|
if (diffMinutes < 1) return 'In less than a minute';
|
|
if (diffMinutes < 60) return `In ${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''}`;
|
|
if (diffHours < 24) return `In ${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
|
|
if (diffDays === 1) return 'Tomorrow';
|
|
return `In ${diffDays} days`;
|
|
}
|
|
}
|
|
|
|
// Set cron from template
|
|
function setCron(expression) {
|
|
document.getElementById('cron-expression').value = expression;
|
|
validateCron();
|
|
}
|
|
|
|
// Validate cron (basic client-side)
|
|
function validateCron() {
|
|
const expression = document.getElementById('cron-expression').value.trim();
|
|
const feedback = document.getElementById('cron-feedback');
|
|
|
|
if (!expression) {
|
|
feedback.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const parts = expression.split(/\s+/);
|
|
if (parts.length !== 5) {
|
|
feedback.className = 'alert alert-danger';
|
|
feedback.textContent = 'Invalid: Must have exactly 5 fields';
|
|
feedback.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
feedback.className = 'alert alert-success';
|
|
feedback.textContent = 'Valid cron expression';
|
|
feedback.style.display = 'block';
|
|
}
|
|
|
|
// Handle form submission
|
|
document.getElementById('edit-schedule-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const submitBtn = document.getElementById('submit-btn');
|
|
const originalText = submitBtn.innerHTML;
|
|
|
|
const formData = {
|
|
name: document.getElementById('schedule-name').value.trim(),
|
|
cron_expression: document.getElementById('cron-expression').value.trim(),
|
|
enabled: document.getElementById('schedule-enabled').checked
|
|
};
|
|
|
|
submitBtn.disabled = true;
|
|
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Saving...';
|
|
|
|
try {
|
|
const response = await fetch(`/api/schedules/${scheduleId}`, {
|
|
method: 'PUT',
|
|
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}`);
|
|
}
|
|
|
|
showNotification('Schedule updated successfully! Redirecting...', 'success');
|
|
|
|
setTimeout(() => {
|
|
window.location.href = '/schedules';
|
|
}, 1500);
|
|
|
|
} catch (error) {
|
|
console.error('Error updating schedule:', error);
|
|
showNotification(`Error: ${error.message}`, 'danger');
|
|
|
|
submitBtn.disabled = false;
|
|
submitBtn.innerHTML = originalText;
|
|
}
|
|
});
|
|
|
|
// Test run
|
|
async function testRun() {
|
|
if (!confirm('Trigger a test run of this schedule now?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/schedules/${scheduleId}/trigger`, {
|
|
method: 'POST'
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
showNotification(`Scan triggered! Redirecting to scan #${data.scan_id}...`, 'success');
|
|
|
|
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() {
|
|
const scheduleName = document.getElementById('schedule-name').value;
|
|
|
|
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(`HTTP ${response.status}`);
|
|
}
|
|
|
|
showNotification('Schedule deleted successfully! Redirecting...', 'success');
|
|
|
|
setTimeout(() => {
|
|
window.location.href = '/schedules';
|
|
}, 1500);
|
|
|
|
} catch (error) {
|
|
console.error('Error deleting schedule:', error);
|
|
showNotification(`Error: ${error.message}`, 'danger');
|
|
}
|
|
}
|
|
|
|
// Show notification
|
|
function showNotification(message, type = 'info') {
|
|
const notification = document.createElement('div');
|
|
notification.className = `alert alert-${type} alert-dismissible fade show`;
|
|
notification.style.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);
|
|
}
|
|
|
|
// 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
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadSchedule();
|
|
updateCurrentTime();
|
|
setInterval(updateCurrentTime, 1000);
|
|
});
|
|
</script>
|
|
{% endblock %}
|