394 lines
14 KiB
HTML
394 lines
14 KiB
HTML
{% 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);
|
|
|
|
// Get local time string for tooltip/fallback
|
|
const localStr = date.toLocaleString();
|
|
|
|
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 `<span title="${localStr}">${absDiffDays} days ago</span>`;
|
|
} 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 `<span title="${localStr}">In ${diffDays} days</span>`;
|
|
}
|
|
}
|
|
|
|
// 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">Config ID: ${schedule.config_id || 'N/A'}</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`;
|
|
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);
|
|
|
|
// 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 %}
|