restructure of dirs, huge docs update

This commit is contained in:
2025-11-17 16:29:14 -06:00
parent 456e052389
commit cd840cb8ca
87 changed files with 2827 additions and 1094 deletions

View File

@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}SneakyScanner{% endblock %}</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<!-- Custom CSS (extracted from inline) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<!-- Chart.js for visualizations -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<!-- Chart.js Dark Theme Configuration -->
<script>
// Configure Chart.js defaults for dark theme
if (typeof Chart !== 'undefined') {
Chart.defaults.color = '#e2e8f0';
Chart.defaults.borderColor = '#334155';
Chart.defaults.backgroundColor = '#1e293b';
}
</script>
{% block extra_styles %}{% endblock %}
</head>
<body>
{% if not hide_nav %}
<nav class="navbar navbar-expand-lg navbar-custom">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">
SneakyScanner
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.dashboard' %}active{% endif %}"
href="{{ url_for('main.dashboard') }}">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.scans' %}active{% endif %}"
href="{{ url_for('main.scans') }}">Scans</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'schedule' in request.endpoint %}active{% endif %}"
href="{{ url_for('main.schedules') }}">Schedules</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'config' in request.endpoint %}active{% endif %}"
href="{{ url_for('main.configs') }}">Configs</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.logout') }}">Logout</a>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<div class="container-fluid">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show mt-3" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<div class="footer">
<div class="container-fluid">
SneakyScanner v1.0 - Phase 3 In Progress
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,263 @@
{% extends "base.html" %}
{% block title %}Edit Config - SneakyScanner{% endblock %}
{% block extra_styles %}
<!-- CodeMirror CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/dracula.min.css">
<style>
.config-editor-container {
background: #1e293b;
border-radius: 8px;
padding: 1.5rem;
margin-top: 2rem;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.CodeMirror {
height: 600px;
border: 1px solid #334155;
border-radius: 4px;
font-size: 14px;
background: #0f172a;
}
.editor-actions {
margin-top: 1.5rem;
display: flex;
gap: 1rem;
}
.validation-feedback {
margin-top: 1rem;
padding: 1rem;
border-radius: 4px;
display: none;
}
.validation-feedback.success {
background: #065f46;
border: 1px solid #10b981;
color: #d1fae5;
}
.validation-feedback.error {
background: #7f1d1d;
border: 1px solid #ef4444;
color: #fee2e2;
}
.back-link {
color: #94a3b8;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.back-link:hover {
color: #cbd5e1;
}
</style>
{% endblock %}
{% block content %}
<div class="container-lg mt-4">
<a href="{{ url_for('main.configs') }}" class="back-link">
<i class="bi bi-arrow-left"></i> Back to Configs
</a>
<h2>Edit Configuration</h2>
<p class="text-muted">Edit the YAML configuration for <strong>{{ filename }}</strong></p>
<div class="config-editor-container">
<div class="editor-header">
<h5 class="mb-0">
<i class="bi bi-file-earmark-code"></i> YAML Editor
</h5>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="validateConfig()">
<i class="bi bi-check-circle"></i> Validate
</button>
</div>
<textarea id="yaml-editor">{{ content }}</textarea>
<div class="validation-feedback" id="validation-feedback"></div>
<div class="editor-actions">
<button type="button" class="btn btn-primary" onclick="saveConfig()">
<i class="bi bi-save"></i> Save Changes
</button>
<button type="button" class="btn btn-secondary" onclick="resetEditor()">
<i class="bi bi-arrow-counterclockwise"></i> Reset
</button>
<a href="{{ url_for('main.configs') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> Cancel
</a>
</div>
</div>
</div>
<!-- Success Modal -->
<div class="modal fade" id="successModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title">
<i class="bi bi-check-circle-fill"></i> Success
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
Configuration updated successfully!
</div>
<div class="modal-footer">
<a href="{{ url_for('main.configs') }}" class="btn btn-success">
Back to Configs
</a>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Continue Editing
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<!-- CodeMirror JS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/mode/yaml/yaml.min.js"></script>
<script>
// Initialize CodeMirror editor
const editor = CodeMirror.fromTextArea(document.getElementById('yaml-editor'), {
mode: 'yaml',
theme: 'dracula',
lineNumbers: true,
lineWrapping: true,
indentUnit: 2,
tabSize: 2,
indentWithTabs: false,
extraKeys: {
"Tab": function(cm) {
cm.replaceSelection(" ", "end");
}
}
});
// Store original content for reset
const originalContent = editor.getValue();
// Validation function
async function validateConfig() {
const feedback = document.getElementById('validation-feedback');
const content = editor.getValue();
try {
// Basic YAML syntax check (client-side)
// Just check for common YAML issues
if (content.trim() === '') {
showFeedback('error', 'Configuration cannot be empty');
return false;
}
// Check for basic structure
if (!content.includes('title:')) {
showFeedback('error', 'Missing required field: title');
return false;
}
if (!content.includes('sites:')) {
showFeedback('error', 'Missing required field: sites');
return false;
}
showFeedback('success', 'Configuration appears valid. Click "Save Changes" to save.');
return true;
} catch (error) {
showFeedback('error', 'Validation error: ' + error.message);
return false;
}
}
// Save configuration
async function saveConfig() {
const content = editor.getValue();
const filename = '{{ filename }}';
// Show loading state
const saveBtn = event.target;
const originalText = saveBtn.innerHTML;
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Saving...';
try {
const response = await fetch(`/api/configs/${filename}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content: content })
});
const data = await response.json();
if (response.ok) {
// Show success modal
const modal = new bootstrap.Modal(document.getElementById('successModal'));
modal.show();
} else {
// Show error feedback
showFeedback('error', data.message || 'Failed to save configuration');
}
} catch (error) {
showFeedback('error', 'Network error: ' + error.message);
} finally {
// Restore button state
saveBtn.disabled = false;
saveBtn.innerHTML = originalText;
}
}
// Reset editor to original content
function resetEditor() {
if (confirm('Are you sure you want to reset all changes?')) {
editor.setValue(originalContent);
hideFeedback();
}
}
// Show validation feedback
function showFeedback(type, message) {
const feedback = document.getElementById('validation-feedback');
feedback.className = `validation-feedback ${type}`;
feedback.innerHTML = `
<i class="bi bi-${type === 'success' ? 'check-circle-fill' : 'exclamation-triangle-fill'}"></i>
${message}
`;
feedback.style.display = 'block';
}
// Hide validation feedback
function hideFeedback() {
const feedback = document.getElementById('validation-feedback');
feedback.style.display = 'none';
}
// Auto-validate on content change (debounced)
let validationTimeout;
editor.on('change', function() {
clearTimeout(validationTimeout);
hideFeedback();
});
</script>
{% endblock %}

View File

@@ -0,0 +1,415 @@
{% extends "base.html" %}
{% block title %}Create Configuration - SneakyScanner{% endblock %}
{% block extra_styles %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/config-manager.css') }}">
<style>
.file-info {
background-color: #1e293b;
border: 1px solid #334155;
padding: 10px 15px;
border-radius: 5px;
margin-top: 15px;
display: none;
}
.file-info-name {
color: #60a5fa;
font-weight: bold;
}
.file-info-size {
color: #94a3b8;
font-size: 0.9em;
}
</style>
{% 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 New Configuration</h1>
<a href="{{ url_for('main.configs') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to Configs
</a>
</div>
</div>
</div>
<!-- Upload Tabs -->
<div class="row">
<div class="col-12">
<ul class="nav nav-tabs mb-4" id="uploadTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="cidr-tab" data-bs-toggle="tab" data-bs-target="#cidr"
type="button" role="tab" style="color: #60a5fa;">
<i class="bi bi-diagram-3"></i> Create from CIDR
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="yaml-tab" data-bs-toggle="tab" data-bs-target="#yaml"
type="button" role="tab" style="color: #60a5fa;">
<i class="bi bi-filetype-yml"></i> Upload YAML
</button>
</li>
</ul>
<div class="tab-content" id="uploadTabsContent">
<!-- CIDR Form Tab -->
<div class="tab-pane fade show active" id="cidr" role="tabpanel">
<div class="row">
<div class="col-lg-8 offset-lg-2">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">
<i class="bi bi-diagram-3"></i> Create Configuration from CIDR Range
</h5>
</div>
<div class="card-body">
<p class="text-muted">
<i class="bi bi-info-circle"></i>
Specify a CIDR range to automatically generate a configuration for all IPs in that range.
You can edit the configuration afterwards to add expected ports and services.
</p>
<form id="cidr-form">
<div class="mb-3">
<label for="config-title" class="form-label" style="color: #94a3b8;">
Config Title <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="config-title"
placeholder="e.g., Production Infrastructure Scan" required>
<div class="form-text">A descriptive title for your scan configuration</div>
</div>
<div class="mb-3">
<label for="cidr-range" class="form-label" style="color: #94a3b8;">
CIDR Range <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="cidr-range"
placeholder="e.g., 10.0.0.0/24 or 192.168.1.0/28" required>
<div class="form-text">
Enter a CIDR range (e.g., 10.0.0.0/24 for 254 hosts).
Maximum 10,000 addresses per range.
</div>
</div>
<div class="mb-3">
<label for="site-name" class="form-label" style="color: #94a3b8;">
Site Name (optional)
</label>
<input type="text" class="form-control" id="site-name"
placeholder="e.g., Production Servers">
<div class="form-text">
Logical grouping name for these IPs (default: "Site 1")
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="ping-default">
<label class="form-check-label" for="ping-default" style="color: #94a3b8;">
Expect ping response by default
</label>
</div>
<div class="form-text">
Sets the default expectation for ICMP ping responses from these IPs
</div>
</div>
<div id="cidr-errors" class="alert alert-danger" style="display:none;">
<strong>Error:</strong> <span id="cidr-error-message"></span>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-plus-circle"></i> Create Configuration
</button>
</div>
</form>
<div id="cidr-success" class="alert alert-success mt-3" style="display:none;">
<i class="bi bi-check-circle-fill"></i>
<strong>Success!</strong> Configuration created: <span id="cidr-created-filename"></span>
<div class="mt-2">
<a href="#" id="edit-new-config-link" class="btn btn-sm btn-outline-success">
<i class="bi bi-pencil"></i> Edit Now
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- YAML Upload Tab -->
<div class="tab-pane fade" id="yaml" role="tabpanel">
<div class="row">
<div class="col-lg-8 offset-lg-2">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">
<i class="bi bi-cloud-upload"></i> Upload YAML Configuration
</h5>
</div>
<div class="card-body">
<p class="text-muted">
<i class="bi bi-info-circle"></i>
For advanced users: upload a YAML config file directly.
</p>
<div id="yaml-dropzone" class="dropzone">
<i class="bi bi-cloud-upload"></i>
<p>Drag & drop YAML file here or click to browse</p>
<input type="file" id="yaml-file-input" accept=".yaml,.yml" hidden>
</div>
<div id="yaml-file-info" class="file-info">
<div class="file-info-name" id="yaml-filename"></div>
<div class="file-info-size" id="yaml-filesize"></div>
</div>
<div class="mt-3">
<label for="yaml-custom-filename" class="form-label" style="color: #94a3b8;">
Custom Filename (optional):
</label>
<input type="text" id="yaml-custom-filename" class="form-control"
placeholder="Leave empty to use uploaded filename">
</div>
<button id="upload-yaml-btn" class="btn btn-primary mt-3" disabled>
<i class="bi bi-upload"></i> Upload YAML
</button>
<div id="yaml-errors" class="alert alert-danger mt-3" style="display:none;">
<strong>Error:</strong> <span id="yaml-error-message"></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Success Modal -->
<div class="modal fade" id="successModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
<div class="modal-header" style="border-bottom: 1px solid #334155;">
<h5 class="modal-title" style="color: #10b981;">
<i class="bi bi-check-circle"></i> Success
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p style="color: #e2e8f0;">Configuration saved successfully!</p>
<p style="color: #60a5fa; font-weight: bold;" id="success-filename"></p>
</div>
<div class="modal-footer" style="border-top: 1px solid #334155;">
<a href="{{ url_for('main.configs') }}" class="btn btn-primary">
<i class="bi bi-list"></i> View All Configs
</a>
<button type="button" class="btn btn-success" onclick="location.reload()">
<i class="bi bi-plus-circle"></i> Create Another
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Global variables
let yamlFile = null;
// ============== CIDR Form Submission ==============
document.getElementById('cidr-form').addEventListener('submit', async function(e) {
e.preventDefault();
const title = document.getElementById('config-title').value.trim();
const cidr = document.getElementById('cidr-range').value.trim();
const siteName = document.getElementById('site-name').value.trim();
const pingDefault = document.getElementById('ping-default').checked;
// Validate inputs
if (!title) {
showError('cidr', 'Config title is required');
return;
}
if (!cidr) {
showError('cidr', 'CIDR range is required');
return;
}
// Show loading state
const submitBtn = e.target.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
try {
const response = await fetch('/api/configs/create-from-cidr', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: title,
cidr: cidr,
site_name: siteName || null,
ping_default: pingDefault
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
const data = await response.json();
// Hide error, show success
document.getElementById('cidr-errors').style.display = 'none';
document.getElementById('cidr-created-filename').textContent = data.filename;
// Set edit link
document.getElementById('edit-new-config-link').href = `/configs/edit/${data.filename}`;
document.getElementById('cidr-success').style.display = 'block';
// Reset form
e.target.reset();
// Show success modal
showSuccess(data.filename);
} catch (error) {
console.error('Error creating config from CIDR:', error);
showError('cidr', error.message);
} finally {
// Restore button state
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
// ============== YAML Upload ==============
// Setup YAML dropzone
const yamlDropzone = document.getElementById('yaml-dropzone');
const yamlFileInput = document.getElementById('yaml-file-input');
yamlDropzone.addEventListener('click', () => yamlFileInput.click());
yamlDropzone.addEventListener('dragover', (e) => {
e.preventDefault();
yamlDropzone.classList.add('dragover');
});
yamlDropzone.addEventListener('dragleave', () => {
yamlDropzone.classList.remove('dragover');
});
yamlDropzone.addEventListener('drop', (e) => {
e.preventDefault();
yamlDropzone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
handleYAMLFile(file);
});
yamlFileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
handleYAMLFile(file);
});
// Handle YAML file selection
function handleYAMLFile(file) {
if (!file) return;
// Check file extension
if (!file.name.endsWith('.yaml') && !file.name.endsWith('.yml')) {
showError('yaml', 'Please select a YAML file (.yaml or .yml)');
return;
}
yamlFile = file;
// Show file info
document.getElementById('yaml-filename').textContent = file.name;
document.getElementById('yaml-filesize').textContent = formatFileSize(file.size);
document.getElementById('yaml-file-info').style.display = 'block';
// Enable upload button
document.getElementById('upload-yaml-btn').disabled = false;
document.getElementById('yaml-errors').style.display = 'none';
}
// Upload YAML file
async function uploadYAMLFile() {
if (!yamlFile) return;
try {
const formData = new FormData();
formData.append('file', yamlFile);
const customFilename = document.getElementById('yaml-custom-filename').value.trim();
if (customFilename) {
formData.append('filename', customFilename);
}
const response = await fetch('/api/configs/upload-yaml', {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
const data = await response.json();
showSuccess(data.filename);
} catch (error) {
console.error('Error uploading YAML:', error);
showError('yaml', error.message);
}
}
document.getElementById('upload-yaml-btn').addEventListener('click', uploadYAMLFile);
// ============== Helper Functions ==============
// Format file size
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
// Show error
function showError(type, message) {
const errorDiv = document.getElementById(`${type}-errors`);
const errorMsg = document.getElementById(`${type}-error-message`);
errorMsg.textContent = message;
errorDiv.style.display = 'block';
}
// Show success
function showSuccess(filename) {
document.getElementById('success-filename').textContent = filename;
new bootstrap.Modal(document.getElementById('successModal')).show();
}
</script>
{% endblock %}

View File

@@ -0,0 +1,377 @@
{% extends "base.html" %}
{% block title %}Configuration Files - SneakyScanner{% endblock %}
{% block extra_styles %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/config-manager.css') }}">
{% 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;">Configuration Files</h1>
<div>
<a href="{{ url_for('main.upload_config') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create New Config
</a>
</div>
</div>
</div>
</div>
<!-- Summary Stats -->
<div class="row mb-4">
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="total-configs">-</div>
<div class="stat-label">Total Configs</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="configs-in-use">-</div>
<div class="stat-label">In Use by Schedules</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="total-size">-</div>
<div class="stat-label">Total Size</div>
</div>
</div>
</div>
<!-- Configs Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0" style="color: #60a5fa;">All Configurations</h5>
<input type="text" id="search-input" class="form-control" style="max-width: 300px;"
placeholder="Search configs...">
</div>
</div>
<div class="card-body">
<div id="configs-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 configurations...</p>
</div>
<div id="configs-error" style="display: none;" class="alert alert-danger">
<strong>Error:</strong> <span id="error-message"></span>
</div>
<div id="configs-content" style="display: none;">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Filename</th>
<th>Title</th>
<th>Created</th>
<th>Size</th>
<th>Used By</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="configs-tbody">
<!-- Populated by JavaScript -->
</tbody>
</table>
</div>
<div id="empty-state" style="display: none;" class="text-center py-5">
<i class="bi bi-file-earmark-text" style="font-size: 3rem; color: #64748b;"></i>
<h5 class="mt-3 text-muted">No configuration files</h5>
<p class="text-muted">Create your first config to define scan targets</p>
<a href="{{ url_for('main.upload_config') }}" class="btn btn-primary mt-2">
<i class="bi bi-plus-circle"></i> Create Config
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
<div class="modal-header" style="border-bottom: 1px solid #334155;">
<h5 class="modal-title" style="color: #f87171;">
<i class="bi bi-exclamation-triangle"></i> Confirm Deletion
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p style="color: #e2e8f0;">Are you sure you want to delete the config file:</p>
<p style="color: #60a5fa; font-weight: bold;" id="delete-config-name"></p>
<p style="color: #fbbf24;" id="delete-warning-schedules" style="display: none;">
<i class="bi bi-exclamation-circle"></i>
This config is used by schedules and cannot be deleted.
</p>
</div>
<div class="modal-footer" style="border-top: 1px solid #334155;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirm-delete-btn">
<i class="bi bi-trash"></i> Delete
</button>
</div>
</div>
</div>
</div>
<!-- View Config Modal -->
<div class="modal fade" id="viewModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
<div class="modal-header" style="border-bottom: 1px solid #334155;">
<h5 class="modal-title" style="color: #60a5fa;">
<i class="bi bi-file-earmark-code"></i> Config File Details
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<h6 style="color: #94a3b8;">Filename: <span id="view-filename" style="color: #e2e8f0;"></span></h6>
<h6 class="mt-3" style="color: #94a3b8;">Content:</h6>
<pre style="background-color: #0f172a; border: 1px solid #334155; padding: 15px; border-radius: 5px; max-height: 400px; overflow-y: auto;"><code id="view-content" style="color: #e2e8f0;"></code></pre>
</div>
<div class="modal-footer" style="border-top: 1px solid #334155;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<a id="download-link" href="#" class="btn btn-primary">
<i class="bi bi-download"></i> Download
</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Global variables
let configsData = [];
let selectedConfigForDeletion = null;
// Format file size
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
// Format date
function formatDate(timestamp) {
if (!timestamp) return 'Unknown';
const date = new Date(timestamp);
return date.toLocaleString();
}
// Load configs from API
async function loadConfigs() {
try {
document.getElementById('configs-loading').style.display = 'block';
document.getElementById('configs-error').style.display = 'none';
document.getElementById('configs-content').style.display = 'none';
const response = await fetch('/api/configs');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
configsData = data.configs || [];
updateStats();
renderConfigs(configsData);
document.getElementById('configs-loading').style.display = 'none';
document.getElementById('configs-content').style.display = 'block';
} catch (error) {
console.error('Error loading configs:', error);
document.getElementById('configs-loading').style.display = 'none';
document.getElementById('configs-error').style.display = 'block';
document.getElementById('error-message').textContent = error.message;
}
}
// Update summary stats
function updateStats() {
const totalConfigs = configsData.length;
const configsInUse = configsData.filter(c => c.used_by_schedules && c.used_by_schedules.length > 0).length;
const totalSize = configsData.reduce((sum, c) => sum + (c.size_bytes || 0), 0);
document.getElementById('total-configs').textContent = totalConfigs;
document.getElementById('configs-in-use').textContent = configsInUse;
document.getElementById('total-size').textContent = formatFileSize(totalSize);
}
// Render configs table
function renderConfigs(configs) {
const tbody = document.getElementById('configs-tbody');
const emptyState = document.getElementById('empty-state');
if (configs.length === 0) {
tbody.innerHTML = '';
emptyState.style.display = 'block';
return;
}
emptyState.style.display = 'none';
tbody.innerHTML = configs.map(config => {
const usedByBadge = config.used_by_schedules && config.used_by_schedules.length > 0
? `<span class="badge bg-info" title="${config.used_by_schedules.join(', ')}">${config.used_by_schedules.length} schedule(s)</span>`
: '<span class="badge bg-secondary">Not used</span>';
return `
<tr>
<td><code>${config.filename}</code></td>
<td>${config.title || config.filename}</td>
<td>${formatDate(config.created_at)}</td>
<td>${formatFileSize(config.size_bytes || 0)}</td>
<td>${usedByBadge}</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-primary" onclick="viewConfig('${config.filename}')" title="View">
<i class="bi bi-eye"></i>
</button>
<a href="/configs/edit/${config.filename}" class="btn btn-outline-info" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a href="/api/configs/${config.filename}/download" class="btn btn-outline-success" title="Download">
<i class="bi bi-download"></i>
</a>
<button class="btn btn-outline-danger" onclick="confirmDelete('${config.filename}', ${config.used_by_schedules.length > 0})" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
`;
}).join('');
}
// View config details
async function viewConfig(filename) {
try {
const response = await fetch(`/api/configs/${filename}`);
if (!response.ok) {
throw new Error(`Failed to load config: ${response.statusText}`);
}
const data = await response.json();
document.getElementById('view-filename').textContent = data.filename;
document.getElementById('view-content').textContent = data.content;
document.getElementById('download-link').href = `/api/configs/${filename}/download`;
new bootstrap.Modal(document.getElementById('viewModal')).show();
} catch (error) {
console.error('Error viewing config:', error);
alert(`Error: ${error.message}`);
}
}
// Confirm delete
function confirmDelete(filename, isInUse) {
selectedConfigForDeletion = filename;
document.getElementById('delete-config-name').textContent = filename;
const warningDiv = document.getElementById('delete-warning-schedules');
const deleteBtn = document.getElementById('confirm-delete-btn');
if (isInUse) {
warningDiv.style.display = 'block';
deleteBtn.disabled = true;
deleteBtn.classList.add('disabled');
} else {
warningDiv.style.display = 'none';
deleteBtn.disabled = false;
deleteBtn.classList.remove('disabled');
}
new bootstrap.Modal(document.getElementById('deleteModal')).show();
}
// Delete config
async function deleteConfig() {
if (!selectedConfigForDeletion) return;
try {
const response = await fetch(`/api/configs/${selectedConfigForDeletion}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
// Hide modal
bootstrap.Modal.getInstance(document.getElementById('deleteModal')).hide();
// Reload configs
await loadConfigs();
// Show success message
showAlert('success', `Config "${selectedConfigForDeletion}" deleted successfully`);
} catch (error) {
console.error('Error deleting config:', error);
showAlert('danger', `Error deleting config: ${error.message}`);
}
}
// Show alert
function showAlert(type, message) {
const alertHtml = `
<div class="alert alert-${type} alert-dismissible fade show mt-3" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
const container = document.querySelector('.container-fluid');
container.insertAdjacentHTML('afterbegin', alertHtml);
// Auto-dismiss after 5 seconds
setTimeout(() => {
const alert = container.querySelector('.alert');
if (alert) {
bootstrap.Alert.getInstance(alert)?.close();
}
}, 5000);
}
// Search filter
document.getElementById('search-input').addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
if (!searchTerm) {
renderConfigs(configsData);
return;
}
const filtered = configsData.filter(config =>
config.filename.toLowerCase().includes(searchTerm) ||
(config.title && config.title.toLowerCase().includes(searchTerm))
);
renderConfigs(filtered);
});
// Setup delete button
document.getElementById('confirm-delete-btn').addEventListener('click', deleteConfig);
// Load configs on page load
document.addEventListener('DOMContentLoaded', loadConfigs);
</script>
{% endblock %}

View File

@@ -0,0 +1,580 @@
{% extends "base.html" %}
{% block title %}Dashboard - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<h1 class="mb-4" style="color: #60a5fa;">Dashboard</h1>
</div>
</div>
<!-- Summary Stats -->
<div class="row mb-4">
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="total-scans">-</div>
<div class="stat-label">Total Scans</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="running-scans">-</div>
<div class="stat-label">Running</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="completed-scans">-</div>
<div class="stat-label">Completed</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="failed-scans">-</div>
<div class="stat-label">Failed</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Quick Actions</h5>
</div>
<div class="card-body">
<button class="btn btn-primary btn-lg" onclick="showTriggerScanModal()">
<span id="trigger-btn-text">Run Scan Now</span>
<span id="trigger-btn-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>
<a href="{{ url_for('main.scans') }}" class="btn btn-secondary btn-lg ms-2">View All Scans</a>
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary btn-lg ms-2">
<i class="bi bi-calendar-plus"></i> Manage Schedules
</a>
</div>
</div>
</div>
</div>
<!-- Scan Activity Chart -->
<div class="row mb-4">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Scan Activity (Last 30 Days)</h5>
</div>
<div class="card-body">
<div id="chart-loading" class="text-center py-4">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<canvas id="scanTrendChart" height="100" style="display: none;"></canvas>
</div>
</div>
</div>
<!-- Schedules Widget -->
<div class="col-md-4">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0" style="color: #60a5fa;">Upcoming Schedules</h5>
<a href="{{ url_for('main.schedules') }}" class="btn btn-sm btn-secondary">Manage</a>
</div>
<div class="card-body">
<div id="schedules-loading" class="text-center py-4">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div id="schedules-content" style="display: none;"></div>
<div id="schedules-empty" class="text-muted text-center py-4" style="display: none;">
No schedules configured yet.
<br><br>
<a href="{{ url_for('main.create_schedule') }}" class="btn btn-sm btn-primary">Create Schedule</a>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Scans -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0" style="color: #60a5fa;">Recent Scans</h5>
<button class="btn btn-sm btn-secondary" onclick="refreshScans()">
<span id="refresh-text">Refresh</span>
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
</button>
</div>
<div class="card-body">
<div id="scans-loading" class="text-center py-4">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div id="scans-error" class="alert alert-danger" style="display: none;"></div>
<div id="scans-empty" class="text-center py-4 text-muted" style="display: none;">
No scans found. Click "Run Scan Now" to trigger your first scan.
</div>
<div id="scans-table-container" style="display: none;">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Timestamp</th>
<th>Duration</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="scans-tbody">
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Trigger Scan Modal -->
<div class="modal fade" id="triggerScanModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
<div class="modal-header" style="border-bottom: 1px solid #334155;">
<h5 class="modal-title" style="color: #60a5fa;">Trigger New Scan</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="trigger-scan-form">
<div class="mb-3">
<label for="config-file" class="form-label">Config File</label>
<select class="form-select" id="config-file" name="config_file" required {% if not config_files %}disabled{% endif %}>
<option value="">Select a config file...</option>
{% for config in config_files %}
<option value="{{ config }}">{{ config }}</option>
{% endfor %}
</select>
{% if config_files %}
<div class="form-text text-muted">
Select a scan configuration file
</div>
{% else %}
<div class="alert alert-warning mt-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle"></i>
<strong>No configurations available</strong>
<p class="mb-2 mt-2">You need to create a configuration file before you can trigger a scan.</p>
<a href="{{ url_for('main.upload_config') }}" class="btn btn-sm btn-primary">
<i class="bi bi-plus-circle"></i> Create Configuration
</a>
</div>
{% endif %}
</div>
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
</form>
</div>
<div class="modal-footer" style="border-top: 1px solid #334155;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="triggerScan()" {% if not config_files %}disabled{% endif %}>
<span id="modal-trigger-text">Trigger Scan</span>
<span id="modal-trigger-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let refreshInterval = null;
// Load initial data when page loads
document.addEventListener('DOMContentLoaded', function() {
refreshScans();
loadStats();
loadScanTrend();
loadSchedules();
// Auto-refresh every 10 seconds if there are running scans
refreshInterval = setInterval(function() {
const runningCount = parseInt(document.getElementById('running-scans').textContent);
if (runningCount > 0) {
refreshScans();
loadStats();
}
}, 10000);
// Refresh schedules every 30 seconds
setInterval(loadSchedules, 30000);
});
// Load dashboard stats
async function loadStats() {
try {
const response = await fetch('/api/scans?per_page=1000');
if (!response.ok) {
throw new Error('Failed to load stats');
}
const data = await response.json();
const scans = data.scans || [];
document.getElementById('total-scans').textContent = scans.length;
document.getElementById('running-scans').textContent = scans.filter(s => s.status === 'running').length;
document.getElementById('completed-scans').textContent = scans.filter(s => s.status === 'completed').length;
document.getElementById('failed-scans').textContent = scans.filter(s => s.status === 'failed').length;
} catch (error) {
console.error('Error loading stats:', error);
}
}
// Refresh scans list
async function refreshScans() {
const loadingEl = document.getElementById('scans-loading');
const errorEl = document.getElementById('scans-error');
const emptyEl = document.getElementById('scans-empty');
const tableEl = document.getElementById('scans-table-container');
const refreshBtn = document.getElementById('refresh-text');
const refreshSpinner = document.getElementById('refresh-spinner');
// Show loading state
loadingEl.style.display = 'block';
errorEl.style.display = 'none';
emptyEl.style.display = 'none';
tableEl.style.display = 'none';
refreshBtn.style.display = 'none';
refreshSpinner.style.display = 'inline-block';
try {
const response = await fetch('/api/scans?per_page=10&page=1');
if (!response.ok) {
throw new Error('Failed to load scans');
}
const data = await response.json();
const scans = data.scans || [];
loadingEl.style.display = 'none';
refreshBtn.style.display = 'inline';
refreshSpinner.style.display = 'none';
if (scans.length === 0) {
emptyEl.style.display = 'block';
} else {
tableEl.style.display = 'block';
renderScansTable(scans);
}
} catch (error) {
console.error('Error loading scans:', error);
loadingEl.style.display = 'none';
refreshBtn.style.display = 'inline';
refreshSpinner.style.display = 'none';
errorEl.textContent = 'Failed to load scans. Please try again.';
errorEl.style.display = 'block';
}
}
// Render scans table
function renderScansTable(scans) {
const tbody = document.getElementById('scans-tbody');
tbody.innerHTML = '';
scans.forEach(scan => {
const row = document.createElement('tr');
row.classList.add('scan-row'); // Fix white row bug
// Format timestamp
const timestamp = new Date(scan.timestamp).toLocaleString();
// Format duration
const duration = scan.duration ? `${scan.duration.toFixed(1)}s` : '-';
// Status badge
let statusBadge = '';
if (scan.status === 'completed') {
statusBadge = '<span class="badge badge-success">Completed</span>';
} else if (scan.status === 'running') {
statusBadge = '<span class="badge badge-info">Running</span>';
} else if (scan.status === 'failed') {
statusBadge = '<span class="badge badge-danger">Failed</span>';
} else {
statusBadge = `<span class="badge badge-info">${scan.status}</span>`;
}
row.innerHTML = `
<td class="mono">${scan.id}</td>
<td>${scan.title || 'Untitled Scan'}</td>
<td class="text-muted">${timestamp}</td>
<td class="mono">${duration}</td>
<td>${statusBadge}</td>
<td>
<a href="/scans/${scan.id}" class="btn btn-sm btn-secondary">View</a>
${scan.status !== 'running' ? `<button class="btn btn-sm btn-danger ms-1" onclick="deleteScan(${scan.id})">Delete</button>` : ''}
</td>
`;
tbody.appendChild(row);
});
}
// Show trigger scan modal
function showTriggerScanModal() {
const modal = new bootstrap.Modal(document.getElementById('triggerScanModal'));
document.getElementById('trigger-error').style.display = 'none';
document.getElementById('trigger-scan-form').reset();
modal.show();
}
// Trigger scan
async function triggerScan() {
const configFile = document.getElementById('config-file').value;
const errorEl = document.getElementById('trigger-error');
const btnText = document.getElementById('modal-trigger-text');
const btnSpinner = document.getElementById('modal-trigger-spinner');
if (!configFile) {
errorEl.textContent = 'Please enter a config file path.';
errorEl.style.display = 'block';
return;
}
// Show loading state
btnText.style.display = 'none';
btnSpinner.style.display = 'inline-block';
errorEl.style.display = 'none';
try {
const response = await fetch('/api/scans', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
config_file: configFile
})
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || data.error || 'Failed to trigger scan');
}
const data = await response.json();
// Hide error before closing modal to prevent flash
errorEl.style.display = 'none';
// Close modal
bootstrap.Modal.getInstance(document.getElementById('triggerScanModal')).hide();
// Show success message
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success alert-dismissible fade show mt-3';
alertDiv.innerHTML = `
Scan triggered successfully! (ID: ${data.scan_id})
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
// Insert at the beginning of container-fluid
const container = document.querySelector('.container-fluid');
container.insertBefore(alertDiv, container.firstChild);
// Refresh scans and stats
refreshScans();
loadStats();
} catch (error) {
console.error('Error triggering scan:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
} finally {
btnText.style.display = 'inline';
btnSpinner.style.display = 'none';
}
}
// Load scan trend chart
async function loadScanTrend() {
const chartLoading = document.getElementById('chart-loading');
const canvas = document.getElementById('scanTrendChart');
try {
const response = await fetch('/api/stats/scan-trend?days=30');
if (!response.ok) {
throw new Error('Failed to load trend data');
}
const data = await response.json();
// Hide loading, show chart
chartLoading.style.display = 'none';
canvas.style.display = 'block';
// Create chart
const ctx = canvas.getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: data.labels,
datasets: [{
label: 'Scans per Day',
data: data.values,
borderColor: '#60a5fa',
backgroundColor: 'rgba(96, 165, 250, 0.1)',
tension: 0.3,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
display: false
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
title: function(context) {
return new Date(context[0].label).toLocaleDateString();
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1,
color: '#94a3b8'
},
grid: {
color: '#334155'
}
},
x: {
ticks: {
color: '#94a3b8',
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 10
},
grid: {
color: '#334155'
}
}
}
}
});
} catch (error) {
console.error('Error loading chart:', error);
chartLoading.innerHTML = '<p class="text-muted">Failed to load chart data</p>';
}
}
// Load upcoming schedules
async function loadSchedules() {
const loadingEl = document.getElementById('schedules-loading');
const contentEl = document.getElementById('schedules-content');
const emptyEl = document.getElementById('schedules-empty');
try {
const response = await fetch('/api/schedules?per_page=5');
if (!response.ok) {
throw new Error('Failed to load schedules');
}
const data = await response.json();
const schedules = data.schedules || [];
loadingEl.style.display = 'none';
if (schedules.length === 0) {
emptyEl.style.display = 'block';
} else {
contentEl.style.display = 'block';
// Filter enabled schedules and sort by next_run
const enabledSchedules = schedules
.filter(s => s.enabled && s.next_run)
.sort((a, b) => new Date(a.next_run) - new Date(b.next_run))
.slice(0, 3);
if (enabledSchedules.length === 0) {
contentEl.innerHTML = '<p class="text-muted">No enabled schedules</p>';
} else {
contentEl.innerHTML = enabledSchedules.map(schedule => {
const nextRun = new Date(schedule.next_run);
const now = new Date();
const diffMs = nextRun - now;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
let timeStr;
if (diffMins < 1) {
timeStr = 'In less than 1 minute';
} else if (diffMins < 60) {
timeStr = `In ${diffMins} minute${diffMins === 1 ? '' : 's'}`;
} else if (diffHours < 24) {
timeStr = `In ${diffHours} hour${diffHours === 1 ? '' : 's'}`;
} else if (diffDays < 7) {
timeStr = `In ${diffDays} day${diffDays === 1 ? '' : 's'}`;
} else {
timeStr = nextRun.toLocaleDateString();
}
return `
<div class="mb-3 pb-3 border-bottom">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>${schedule.name}</strong>
<br>
<small class="text-muted">${timeStr}</small>
<br>
<small class="text-muted mono">${schedule.cron_expression}</small>
</div>
</div>
</div>
`;
}).join('');
}
}
} catch (error) {
console.error('Error loading schedules:', error);
loadingEl.style.display = 'none';
contentEl.style.display = 'block';
contentEl.innerHTML = '<p class="text-muted">Failed to load schedules</p>';
}
}
// Delete scan
async function deleteScan(scanId) {
if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) {
return;
}
try {
const response = await fetch(`/api/scans/${scanId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete scan');
}
// Refresh scans and stats
refreshScans();
loadStats();
} catch (error) {
console.error('Error deleting scan:', error);
alert('Failed to delete scan. Please try again.');
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>400 - Bad Request | SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.error-code {
font-size: 8rem;
font-weight: 700;
color: #f59e0b;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(245, 158, 11, 0.3);
}
.error-title {
font-size: 2rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
}
.error-message {
font-size: 1.1rem;
color: #94a3b8;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">⚠️</div>
<div class="error-code">400</div>
<h1 class="error-title">Bad Request</h1>
<p class="error-message">
The request could not be understood or was missing required parameters.
<br>
Please check your input and try again.
</p>
<a href="/" class="btn btn-primary">Go to Dashboard</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>401 - Unauthorized | SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.error-code {
font-size: 8rem;
font-weight: 700;
color: #f59e0b;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(245, 158, 11, 0.3);
}
.error-title {
font-size: 2rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
}
.error-message {
font-size: 1.1rem;
color: #94a3b8;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">🔒</div>
<div class="error-code">401</div>
<h1 class="error-title">Unauthorized</h1>
<p class="error-message">
You need to be authenticated to access this page.
<br>
Please log in to continue.
</p>
<a href="/auth/login" class="btn btn-primary">Go to Login</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>403 - Forbidden | SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.error-code {
font-size: 8rem;
font-weight: 700;
color: #ef4444;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
}
.error-title {
font-size: 2rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
}
.error-message {
font-size: 1.1rem;
color: #94a3b8;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">🚫</div>
<div class="error-code">403</div>
<h1 class="error-title">Forbidden</h1>
<p class="error-message">
You don't have permission to access this resource.
<br>
If you think this is an error, please contact the administrator.
</p>
<a href="/" class="btn btn-primary">Go to Dashboard</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - Page Not Found | SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.error-code {
font-size: 8rem;
font-weight: 700;
color: #60a5fa;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(96, 165, 250, 0.3);
}
.error-title {
font-size: 2rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
}
.error-message {
font-size: 1.1rem;
color: #94a3b8;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">🔍</div>
<div class="error-code">404</div>
<h1 class="error-title">Page Not Found</h1>
<p class="error-message">
The page you're looking for doesn't exist or has been moved.
<br>
Let's get you back on track.
</p>
<a href="/" class="btn btn-primary">Go to Dashboard</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>405 - Method Not Allowed | SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.error-code {
font-size: 8rem;
font-weight: 700;
color: #f59e0b;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(245, 158, 11, 0.3);
}
.error-title {
font-size: 2rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
}
.error-message {
font-size: 1.1rem;
color: #94a3b8;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">🚧</div>
<div class="error-code">405</div>
<h1 class="error-title">Method Not Allowed</h1>
<p class="error-message">
The HTTP method used is not allowed for this endpoint.
<br>
Please check the API documentation for valid methods.
</p>
<a href="/" class="btn btn-primary">Go to Dashboard</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>500 - Internal Server Error | SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.error-code {
font-size: 8rem;
font-weight: 700;
color: #ef4444;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
}
.error-title {
font-size: 2rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
}
.error-message {
font-size: 1.1rem;
color: #94a3b8;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.error-details {
background: #1e293b;
border: 1px solid #334155;
border-radius: 0.5rem;
padding: 1rem;
margin: 1.5rem 0;
text-align: left;
}
.error-details-title {
font-size: 0.9rem;
font-weight: 600;
color: #94a3b8;
margin-bottom: 0.5rem;
}
.error-details-text {
font-size: 0.85rem;
color: #64748b;
font-family: 'Courier New', monospace;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">⚠️</div>
<div class="error-code">500</div>
<h1 class="error-title">Internal Server Error</h1>
<p class="error-message">
Something went wrong on our end. We've logged the error and will look into it.
<br>
Please try again in a few moments.
</p>
<a href="/" class="btn btn-primary">Go to Dashboard</a>
<div class="error-details">
<div class="error-details-title">Error Information:</div>
<div class="error-details-text">
An unexpected error occurred while processing your request. Our team has been notified and is working to resolve the issue.
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Login - SneakyScanner{% endblock %}
{% set hide_nav = true %}
{% block content %}
<div class="login-card">
<div class="text-center mb-4">
<h1 class="brand-title">SneakyScanner</h1>
<p class="brand-subtitle">Network Security Scanner</p>
</div>
{% if password_not_set %}
<div class="alert alert-warning">
<strong>Setup Required:</strong> Please set an application password first.
<a href="{{ url_for('auth.setup') }}" class="alert-link">Go to Setup</a>
</div>
{% else %}
<form method="post" action="{{ url_for('auth.login') }}">
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password"
class="form-control form-control-lg"
id="password"
name="password"
required
autofocus
placeholder="Enter your password">
</div>
<div class="mb-3 form-check">
<input type="checkbox"
class="form-check-input"
id="remember"
name="remember">
<label class="form-check-label" for="remember">
Remember me
</label>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">
Login
</button>
</form>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,545 @@
{% extends "base.html" %}
{% block title %}Compare 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">
<div>
<a href="{{ url_for('main.scans') }}" class="text-muted text-decoration-none mb-2 d-inline-block">
← Back to All Scans
</a>
<h1 style="color: #60a5fa;">Scan Comparison</h1>
<p class="text-muted">Comparing Scan #{{ scan_id1 }} vs Scan #{{ scan_id2 }}</p>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div id="comparison-loading" class="text-center py-5">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading comparison...</p>
</div>
<!-- Error State -->
<div id="comparison-error" class="alert alert-danger" style="display: none;"></div>
<!-- Config Warning -->
<div id="config-warning" class="alert alert-warning" style="display: none;">
<i class="bi bi-exclamation-triangle"></i>
<strong>Different Configurations Detected</strong>
<p class="mb-0" id="config-warning-message"></p>
</div>
<!-- Comparison Content -->
<div id="comparison-content" style="display: none;">
<!-- Drift Score Card -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Infrastructure Drift Analysis</h5>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-3">
<div class="text-center">
<div class="display-4 mb-2" id="drift-score" style="color: #60a5fa;">-</div>
<div class="text-muted">Drift Score</div>
<small class="text-muted d-block mt-1">(0.0 = identical, 1.0 = completely different)</small>
</div>
</div>
<div class="col-md-9">
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label class="form-label text-muted">Older Scan (#<span id="scan1-id"></span>)</label>
<div id="scan1-title" class="fw-bold">-</div>
<small class="text-muted d-block" id="scan1-timestamp">-</small>
<small class="text-muted d-block"><i class="bi bi-file-earmark-text"></i> <span id="scan1-config">-</span></small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label text-muted">Newer Scan (#<span id="scan2-id"></span>)</label>
<div id="scan2-title" class="fw-bold">-</div>
<small class="text-muted d-block" id="scan2-timestamp">-</small>
<small class="text-muted d-block"><i class="bi bi-file-earmark-text"></i> <span id="scan2-config">-</span></small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label text-muted">Quick Actions</label>
<div>
<a href="/scans/{{ scan_id1 }}" class="btn btn-sm btn-secondary">View Scan #{{ scan_id1 }}</a>
<a href="/scans/{{ scan_id2 }}" class="btn btn-sm btn-secondary">View Scan #{{ scan_id2 }}</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Ports Comparison -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">
<i class="bi bi-hdd-network"></i> Port Changes
</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<div class="stat-card" style="background-color: #065f46; border-color: #6ee7b7;">
<div class="stat-value" id="ports-added-count">0</div>
<div class="stat-label">Ports Added</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #7f1d1d; border-color: #fca5a5;">
<div class="stat-value" id="ports-removed-count">0</div>
<div class="stat-label">Ports Removed</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="ports-unchanged-count">0</div>
<div class="stat-label">Ports Unchanged</div>
</div>
</div>
</div>
<!-- Added Ports -->
<div id="ports-added-section" style="display: none;">
<h6 class="text-success mb-2"><i class="bi bi-plus-circle"></i> Added Ports</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP Address</th>
<th>Port</th>
<th>Protocol</th>
<th>State</th>
</tr>
</thead>
<tbody id="ports-added-tbody"></tbody>
</table>
</div>
</div>
<!-- Removed Ports -->
<div id="ports-removed-section" style="display: none;">
<h6 class="text-danger mb-2"><i class="bi bi-dash-circle"></i> Removed Ports</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP Address</th>
<th>Port</th>
<th>Protocol</th>
<th>State</th>
</tr>
</thead>
<tbody id="ports-removed-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Services Comparison -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">
<i class="bi bi-gear"></i> Service Changes
</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<div class="stat-card" style="background-color: #065f46; border-color: #6ee7b7;">
<div class="stat-value" id="services-added-count">0</div>
<div class="stat-label">Services Added</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #7f1d1d; border-color: #fca5a5;">
<div class="stat-value" id="services-removed-count">0</div>
<div class="stat-label">Services Removed</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #78350f; border-color: #fcd34d;">
<div class="stat-value" id="services-changed-count">0</div>
<div class="stat-label">Services Changed</div>
</div>
</div>
</div>
<!-- Changed Services -->
<div id="services-changed-section" style="display: none;">
<h6 class="text-warning mb-2"><i class="bi bi-arrow-left-right"></i> Changed Services</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP:Port</th>
<th>Old Service</th>
<th>New Service</th>
<th>Old Version</th>
<th>New Version</th>
</tr>
</thead>
<tbody id="services-changed-tbody"></tbody>
</table>
</div>
</div>
<!-- Added Services -->
<div id="services-added-section" style="display: none;">
<h6 class="text-success mb-2"><i class="bi bi-plus-circle"></i> Added Services</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP Address</th>
<th>Port</th>
<th>Service</th>
<th>Product</th>
<th>Version</th>
</tr>
</thead>
<tbody id="services-added-tbody"></tbody>
</table>
</div>
</div>
<!-- Removed Services -->
<div id="services-removed-section" style="display: none;">
<h6 class="text-danger mb-2"><i class="bi bi-dash-circle"></i> Removed Services</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP Address</th>
<th>Port</th>
<th>Service</th>
<th>Product</th>
<th>Version</th>
</tr>
</thead>
<tbody id="services-removed-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Certificates Comparison -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">
<i class="bi bi-shield-lock"></i> Certificate Changes
</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<div class="stat-card" style="background-color: #065f46; border-color: #6ee7b7;">
<div class="stat-value" id="certs-added-count">0</div>
<div class="stat-label">Certificates Added</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #7f1d1d; border-color: #fca5a5;">
<div class="stat-value" id="certs-removed-count">0</div>
<div class="stat-label">Certificates Removed</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #78350f; border-color: #fcd34d;">
<div class="stat-value" id="certs-changed-count">0</div>
<div class="stat-label">Certificates Changed</div>
</div>
</div>
</div>
<!-- Changed Certificates -->
<div id="certs-changed-section" style="display: none;">
<h6 class="text-warning mb-2"><i class="bi bi-arrow-left-right"></i> Changed Certificates</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP:Port</th>
<th>Old Subject</th>
<th>New Subject</th>
<th>Old Expiry</th>
<th>New Expiry</th>
</tr>
</thead>
<tbody id="certs-changed-tbody"></tbody>
</table>
</div>
</div>
<!-- Added/Removed Certificates (shown if any) -->
<div id="certs-added-removed-info" style="display: none;">
<p class="text-muted mb-0">
<i class="bi bi-info-circle"></i>
Additional certificate additions and removals correspond to the port changes shown above.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const scanId1 = {{ scan_id1 }};
const scanId2 = {{ scan_id2 }};
// Load comparison data
async function loadComparison() {
const loadingDiv = document.getElementById('comparison-loading');
const errorDiv = document.getElementById('comparison-error');
const contentDiv = document.getElementById('comparison-content');
try {
const response = await fetch(`/api/scans/${scanId1}/compare/${scanId2}`);
if (!response.ok) {
throw new Error('Failed to load comparison');
}
const data = await response.json();
// Hide loading, show content
loadingDiv.style.display = 'none';
contentDiv.style.display = 'block';
// Populate comparison UI
populateComparison(data);
} catch (error) {
console.error('Error loading comparison:', error);
loadingDiv.style.display = 'none';
errorDiv.textContent = `Error: ${error.message}`;
errorDiv.style.display = 'block';
}
}
function populateComparison(data) {
// Show config warning if configs differ
if (data.config_warning) {
const warningDiv = document.getElementById('config-warning');
const warningMessage = document.getElementById('config-warning-message');
warningMessage.textContent = data.config_warning;
warningDiv.style.display = 'block';
}
// Drift score
const driftScore = data.drift_score || 0;
document.getElementById('drift-score').textContent = driftScore.toFixed(3);
// Color code drift score
const driftElement = document.getElementById('drift-score');
if (driftScore < 0.1) {
driftElement.style.color = '#6ee7b7'; // Green - minimal drift
} else if (driftScore < 0.3) {
driftElement.style.color = '#fcd34d'; // Yellow - moderate drift
} else {
driftElement.style.color = '#fca5a5'; // Red - significant drift
}
// Scan metadata
document.getElementById('scan1-id').textContent = data.scan1.id;
document.getElementById('scan1-title').textContent = data.scan1.title || 'Untitled Scan';
document.getElementById('scan1-timestamp').textContent = new Date(data.scan1.timestamp).toLocaleString();
document.getElementById('scan1-config').textContent = data.scan1.config_file || 'Unknown';
document.getElementById('scan2-id').textContent = data.scan2.id;
document.getElementById('scan2-title').textContent = data.scan2.title || 'Untitled Scan';
document.getElementById('scan2-timestamp').textContent = new Date(data.scan2.timestamp).toLocaleString();
document.getElementById('scan2-config').textContent = data.scan2.config_file || 'Unknown';
// Ports comparison
populatePortsComparison(data.ports);
// Services comparison
populateServicesComparison(data.services);
// Certificates comparison
populateCertificatesComparison(data.certificates);
}
function populatePortsComparison(ports) {
const addedCount = ports.added.length;
const removedCount = ports.removed.length;
const unchangedCount = ports.unchanged.length;
document.getElementById('ports-added-count').textContent = addedCount;
document.getElementById('ports-removed-count').textContent = removedCount;
document.getElementById('ports-unchanged-count').textContent = unchangedCount;
// Show added ports
if (addedCount > 0) {
document.getElementById('ports-added-section').style.display = 'block';
const tbody = document.getElementById('ports-added-tbody');
tbody.innerHTML = '';
ports.added.forEach(port => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td>${port.ip}</td>
<td class="mono">${port.port}</td>
<td>${port.protocol.toUpperCase()}</td>
<td>${port.state}</td>
`;
tbody.appendChild(row);
});
}
// Show removed ports
if (removedCount > 0) {
document.getElementById('ports-removed-section').style.display = 'block';
const tbody = document.getElementById('ports-removed-tbody');
tbody.innerHTML = '';
ports.removed.forEach(port => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td>${port.ip}</td>
<td class="mono">${port.port}</td>
<td>${port.protocol.toUpperCase()}</td>
<td>${port.state}</td>
`;
tbody.appendChild(row);
});
}
}
function populateServicesComparison(services) {
const addedCount = services.added.length;
const removedCount = services.removed.length;
const changedCount = services.changed.length;
document.getElementById('services-added-count').textContent = addedCount;
document.getElementById('services-removed-count').textContent = removedCount;
document.getElementById('services-changed-count').textContent = changedCount;
// Show changed services
if (changedCount > 0) {
document.getElementById('services-changed-section').style.display = 'block';
const tbody = document.getElementById('services-changed-tbody');
tbody.innerHTML = '';
services.changed.forEach(svc => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td class="mono">${svc.ip}:${svc.port}</td>
<td>${svc.old.service_name || '-'}</td>
<td class="text-warning">${svc.new.service_name || '-'}</td>
<td>${svc.old.version || '-'}</td>
<td class="text-warning">${svc.new.version || '-'}</td>
`;
tbody.appendChild(row);
});
}
// Show added services
if (addedCount > 0) {
document.getElementById('services-added-section').style.display = 'block';
const tbody = document.getElementById('services-added-tbody');
tbody.innerHTML = '';
services.added.forEach(svc => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td>${svc.ip}</td>
<td class="mono">${svc.port}</td>
<td>${svc.service_name || '-'}</td>
<td>${svc.product || '-'}</td>
<td>${svc.version || '-'}</td>
`;
tbody.appendChild(row);
});
}
// Show removed services
if (removedCount > 0) {
document.getElementById('services-removed-section').style.display = 'block';
const tbody = document.getElementById('services-removed-tbody');
tbody.innerHTML = '';
services.removed.forEach(svc => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td>${svc.ip}</td>
<td class="mono">${svc.port}</td>
<td>${svc.service_name || '-'}</td>
<td>${svc.product || '-'}</td>
<td>${svc.version || '-'}</td>
`;
tbody.appendChild(row);
});
}
}
function populateCertificatesComparison(certs) {
const addedCount = certs.added.length;
const removedCount = certs.removed.length;
const changedCount = certs.changed.length;
document.getElementById('certs-added-count').textContent = addedCount;
document.getElementById('certs-removed-count').textContent = removedCount;
document.getElementById('certs-changed-count').textContent = changedCount;
// Show changed certificates
if (changedCount > 0) {
document.getElementById('certs-changed-section').style.display = 'block';
const tbody = document.getElementById('certs-changed-tbody');
tbody.innerHTML = '';
certs.changed.forEach(cert => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td class="mono">${cert.ip}:${cert.port}</td>
<td>${cert.old.subject || '-'}</td>
<td class="text-warning">${cert.new.subject || '-'}</td>
<td>${cert.old.not_valid_after ? new Date(cert.old.not_valid_after).toLocaleDateString() : '-'}</td>
<td class="text-warning">${cert.new.not_valid_after ? new Date(cert.new.not_valid_after).toLocaleDateString() : '-'}</td>
`;
tbody.appendChild(row);
});
}
// Show info if there are added/removed certs
if (addedCount > 0 || removedCount > 0) {
document.getElementById('certs-added-removed-info').style.display = 'block';
}
}
// Load comparison on page load
loadComparison();
</script>
{% endblock %}

View File

@@ -0,0 +1,605 @@
{% extends "base.html" %}
{% block title %}Scan #{{ scan_id }} - 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">
<div>
<a href="{{ url_for('main.scans') }}" class="text-muted text-decoration-none mb-2 d-inline-block">
← Back to All Scans
</a>
<h1 style="color: #60a5fa;">Scan #<span id="scan-id">{{ scan_id }}</span></h1>
</div>
<div>
<button class="btn btn-primary" onclick="compareWithPrevious()" id="compare-btn" style="display: none;">
<i class="bi bi-arrow-left-right"></i> Compare with Previous
</button>
<button class="btn btn-secondary ms-2" onclick="refreshScan()">
<span id="refresh-text">Refresh</span>
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
</button>
<button class="btn btn-danger ms-2" onclick="deleteScan()" id="delete-btn">Delete Scan</button>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div id="scan-loading" class="text-center py-5">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading scan details...</p>
</div>
<!-- Error State -->
<div id="scan-error" class="alert alert-danger" style="display: none;"></div>
<!-- Scan Content -->
<div id="scan-content" style="display: none;">
<!-- Summary Card -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Scan Summary</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="mb-3">
<label class="form-label text-muted">Title</label>
<div id="scan-title" class="fw-bold">-</div>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label class="form-label text-muted">Timestamp</label>
<div id="scan-timestamp" class="mono">-</div>
</div>
</div>
<div class="col-md-2">
<div class="mb-3">
<label class="form-label text-muted">Duration</label>
<div id="scan-duration" class="mono">-</div>
</div>
</div>
<div class="col-md-2">
<div class="mb-3">
<label class="form-label text-muted">Status</label>
<div id="scan-status">-</div>
</div>
</div>
<div class="col-md-2">
<div class="mb-3">
<label class="form-label text-muted">Triggered By</label>
<div id="scan-triggered-by">-</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="mb-0">
<label class="form-label text-muted">Config File</label>
<div id="scan-config-file" class="mono">-</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Stats Row -->
<div class="row mb-4">
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="total-sites">0</div>
<div class="stat-label">Sites</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="total-ips">0</div>
<div class="stat-label">IP Addresses</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="total-ports">0</div>
<div class="stat-label">Open Ports</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="total-services">0</div>
<div class="stat-label">Services</div>
</div>
</div>
</div>
<!-- Historical Trend Chart -->
<div class="row mb-4" id="historical-chart-row" style="display: none;">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">
<i class="bi bi-graph-up"></i> Port Count History
</h5>
</div>
<div class="card-body">
<p class="text-muted mb-3">
Historical port count trend for scans using the same configuration
</p>
<div style="position: relative; height: 300px;">
<canvas id="historyChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- Sites and IPs -->
<div id="sites-container">
<!-- Sites will be dynamically inserted here -->
</div>
<!-- Output Files -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Output Files</h5>
</div>
<div class="card-body">
<div id="output-files" class="d-flex gap-2">
<!-- File links will be dynamically inserted here -->
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const scanId = {{ scan_id }};
let scanData = null;
let historyChart = null; // Store chart instance to prevent duplicates
// Load scan on page load
document.addEventListener('DOMContentLoaded', function() {
loadScan().then(() => {
findPreviousScan();
loadHistoricalChart();
});
// Auto-refresh every 10 seconds if scan is running
setInterval(function() {
if (scanData && scanData.status === 'running') {
loadScan();
}
}, 10000);
});
// Load scan details
async function loadScan() {
const loadingEl = document.getElementById('scan-loading');
const errorEl = document.getElementById('scan-error');
const contentEl = document.getElementById('scan-content');
// Show loading state
loadingEl.style.display = 'block';
errorEl.style.display = 'none';
contentEl.style.display = 'none';
try {
const response = await fetch(`/api/scans/${scanId}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Scan not found');
}
throw new Error('Failed to load scan');
}
scanData = await response.json();
loadingEl.style.display = 'none';
contentEl.style.display = 'block';
renderScan(scanData);
} catch (error) {
console.error('Error loading scan:', error);
loadingEl.style.display = 'none';
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
// Render scan details
function renderScan(scan) {
// Summary
document.getElementById('scan-title').textContent = scan.title || 'Untitled Scan';
document.getElementById('scan-timestamp').textContent = new Date(scan.timestamp).toLocaleString();
document.getElementById('scan-duration').textContent = scan.duration ? `${scan.duration.toFixed(1)}s` : '-';
document.getElementById('scan-triggered-by').textContent = scan.triggered_by || 'manual';
document.getElementById('scan-config-file').textContent = scan.config_file || '-';
// Status badge
let statusBadge = '';
if (scan.status === 'completed') {
statusBadge = '<span class="badge badge-success">Completed</span>';
} else if (scan.status === 'running') {
statusBadge = '<span class="badge badge-info">Running</span>';
document.getElementById('delete-btn').disabled = true;
} else if (scan.status === 'failed') {
statusBadge = '<span class="badge badge-danger">Failed</span>';
} else {
statusBadge = `<span class="badge badge-info">${scan.status}</span>`;
}
document.getElementById('scan-status').innerHTML = statusBadge;
// Stats
const sites = scan.sites || [];
let totalIps = 0;
let totalPorts = 0;
let totalServices = 0;
sites.forEach(site => {
const ips = site.ips || [];
totalIps += ips.length;
ips.forEach(ip => {
const ports = ip.ports || [];
totalPorts += ports.length;
ports.forEach(port => {
totalServices += (port.services || []).length;
});
});
});
document.getElementById('total-sites').textContent = sites.length;
document.getElementById('total-ips').textContent = totalIps;
document.getElementById('total-ports').textContent = totalPorts;
document.getElementById('total-services').textContent = totalServices;
// Sites
renderSites(sites);
// Output files
renderOutputFiles(scan);
}
// Render sites
function renderSites(sites) {
const container = document.getElementById('sites-container');
container.innerHTML = '';
sites.forEach((site, siteIdx) => {
const siteCard = document.createElement('div');
siteCard.className = 'row mb-4';
siteCard.innerHTML = `
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">${site.name}</h5>
</div>
<div class="card-body">
<div id="site-${siteIdx}-ips"></div>
</div>
</div>
</div>
`;
container.appendChild(siteCard);
// Render IPs for this site
const ipsContainer = document.getElementById(`site-${siteIdx}-ips`);
const ips = site.ips || [];
ips.forEach((ip, ipIdx) => {
const ipDiv = document.createElement('div');
ipDiv.className = 'mb-3';
ipDiv.innerHTML = `
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mono mb-0">${ip.address}</h6>
<div>
${ip.ping_actual ? '<span class="badge badge-success">Ping: Responsive</span>' : '<span class="badge badge-danger">Ping: No Response</span>'}
</div>
</div>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Port</th>
<th>Protocol</th>
<th>State</th>
<th>Service</th>
<th>Product</th>
<th>Version</th>
<th>Status</th>
</tr>
</thead>
<tbody id="site-${siteIdx}-ip-${ipIdx}-ports"></tbody>
</table>
</div>
`;
ipsContainer.appendChild(ipDiv);
// Render ports for this IP
const portsContainer = document.getElementById(`site-${siteIdx}-ip-${ipIdx}-ports`);
const ports = ip.ports || [];
if (ports.length === 0) {
portsContainer.innerHTML = '<tr class="scan-row"><td colspan="7" class="text-center text-muted">No ports found</td></tr>';
} else {
ports.forEach(port => {
const service = port.services && port.services.length > 0 ? port.services[0] : null;
const row = document.createElement('tr');
row.classList.add('scan-row'); // Fix white row bug
row.innerHTML = `
<td class="mono">${port.port}</td>
<td>${port.protocol.toUpperCase()}</td>
<td><span class="badge badge-success">${port.state || 'open'}</span></td>
<td>${service ? service.service_name : '-'}</td>
<td>${service ? service.product || '-' : '-'}</td>
<td class="mono">${service ? service.version || '-' : '-'}</td>
<td>${port.expected ? '<span class="badge badge-good">Expected</span>' : '<span class="badge badge-warning">Unexpected</span>'}</td>
`;
portsContainer.appendChild(row);
});
}
});
});
}
// Render output files
function renderOutputFiles(scan) {
const container = document.getElementById('output-files');
container.innerHTML = '';
const files = [];
if (scan.json_path) {
files.push({ label: 'JSON', path: scan.json_path, icon: '📄' });
}
if (scan.html_path) {
files.push({ label: 'HTML Report', path: scan.html_path, icon: '🌐' });
}
if (scan.zip_path) {
files.push({ label: 'ZIP Archive', path: scan.zip_path, icon: '📦' });
}
if (files.length === 0) {
container.innerHTML = '<p class="text-muted mb-0">No output files generated yet.</p>';
} else {
files.forEach(file => {
const link = document.createElement('a');
link.href = `/output/${file.path.split('/').pop()}`;
link.className = 'btn btn-secondary';
link.target = '_blank';
link.innerHTML = `${file.icon} ${file.label}`;
container.appendChild(link);
});
}
}
// Refresh scan
function refreshScan() {
const refreshBtn = document.getElementById('refresh-text');
const refreshSpinner = document.getElementById('refresh-spinner');
refreshBtn.style.display = 'none';
refreshSpinner.style.display = 'inline-block';
loadScan().finally(() => {
refreshBtn.style.display = 'inline';
refreshSpinner.style.display = 'none';
});
}
// Delete scan
async function deleteScan() {
if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) {
return;
}
// Disable delete button to prevent double-clicks
const deleteBtn = document.getElementById('delete-btn');
deleteBtn.disabled = true;
deleteBtn.textContent = 'Deleting...';
try {
const response = await fetch(`/api/scans/${scanId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
});
// Check status code first
if (!response.ok) {
// Try to get error message from response
let errorMessage = `HTTP ${response.status}: Failed to delete scan`;
try {
const data = await response.json();
errorMessage = data.message || errorMessage;
} catch (e) {
// Ignore JSON parse errors for error responses
}
throw new Error(errorMessage);
}
// For successful responses, try to parse JSON but don't fail if it doesn't work
try {
await response.json();
} catch (e) {
console.warn('Response is not valid JSON, but deletion succeeded');
}
// Wait 2 seconds to ensure deletion completes fully
await new Promise(resolve => setTimeout(resolve, 2000));
// Redirect to scans list
window.location.href = '{{ url_for("main.scans") }}';
} catch (error) {
console.error('Error deleting scan:', error);
alert(`Failed to delete scan: ${error.message}`);
// Re-enable button on error
deleteBtn.disabled = false;
deleteBtn.textContent = 'Delete Scan';
}
}
// Find previous scan and show compare button
let previousScanId = null;
let currentConfigFile = null;
async function findPreviousScan() {
try {
// Get current scan details first to know which config it used
const currentScanResponse = await fetch(`/api/scans/${scanId}`);
const currentScanData = await currentScanResponse.json();
currentConfigFile = currentScanData.config_file;
// Get list of completed scans
const response = await fetch('/api/scans?per_page=100&status=completed');
const data = await response.json();
if (data.scans && data.scans.length > 0) {
// Find the current scan
const currentScanIndex = data.scans.findIndex(s => s.id === scanId);
if (currentScanIndex !== -1) {
// Look for the most recent previous scan with the SAME config file
for (let i = currentScanIndex + 1; i < data.scans.length; i++) {
const previousScan = data.scans[i];
// Check if this scan uses the same config
if (previousScan.config_file === currentConfigFile) {
previousScanId = previousScan.id;
// Show the compare button
const compareBtn = document.getElementById('compare-btn');
if (compareBtn) {
compareBtn.style.display = 'inline-block';
compareBtn.title = `Compare with Scan #${previousScanId} (same config)`;
}
break; // Found the most recent matching scan
}
}
// If no matching config found, don't show compare button
if (!previousScanId) {
console.log('No previous scans found with the same configuration');
}
}
}
} catch (error) {
console.error('Error finding previous scan:', error);
}
}
// Compare with previous scan
function compareWithPrevious() {
if (previousScanId) {
window.location.href = `/scans/${previousScanId}/compare/${scanId}`;
}
}
// Load historical trend chart
async function loadHistoricalChart() {
try {
const response = await fetch(`/api/stats/scan-history/${scanId}?limit=20`);
const data = await response.json();
// Only show chart if there are multiple scans
if (data.scans && data.scans.length > 1) {
document.getElementById('historical-chart-row').style.display = 'block';
// Destroy existing chart to prevent canvas growth bug
if (historyChart) {
historyChart.destroy();
}
const ctx = document.getElementById('historyChart').getContext('2d');
historyChart = new Chart(ctx, {
type: 'line',
data: {
labels: data.labels,
datasets: [{
label: 'Open Ports',
data: data.port_counts,
borderColor: '#60a5fa',
backgroundColor: 'rgba(96, 165, 250, 0.1)',
tension: 0.3,
fill: true,
pointBackgroundColor: '#60a5fa',
pointBorderColor: '#1e293b',
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: '#1e293b',
titleColor: '#e2e8f0',
bodyColor: '#e2e8f0',
borderColor: '#334155',
borderWidth: 1,
callbacks: {
afterLabel: function(context) {
const scan = data.scans[context.dataIndex];
return `Scan ID: ${scan.id}\nIPs: ${scan.ip_count}`;
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1,
color: '#94a3b8'
},
grid: {
color: '#334155'
}
},
x: {
ticks: {
color: '#94a3b8',
maxRotation: 45,
minRotation: 45
},
grid: {
color: '#334155'
}
}
},
onClick: (event, elements) => {
if (elements.length > 0) {
const index = elements[0].index;
const scan = data.scans[index];
window.location.href = `/scans/${scan.id}`;
}
}
}
});
}
} catch (error) {
console.error('Error loading historical chart:', error);
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,487 @@
{% extends "base.html" %}
{% block title %}All Scans - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
<h1 style="color: #60a5fa;">All Scans</h1>
<button class="btn btn-primary" onclick="showTriggerScanModal()">
<span id="trigger-btn-text">Trigger New Scan</span>
<span id="trigger-btn-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label for="status-filter" class="form-label">Filter by Status</label>
<select class="form-select" id="status-filter" onchange="filterScans()">
<option value="">All Statuses</option>
<option value="running">Running</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
</div>
<div class="col-md-4">
<label for="per-page" class="form-label">Results per Page</label>
<select class="form-select" id="per-page" onchange="changePerPage()">
<option value="10">10</option>
<option value="20" selected>20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<button class="btn btn-secondary w-100" onclick="refreshScans()">
<span id="refresh-text">Refresh</span>
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Scans Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Scan History</h5>
</div>
<div class="card-body">
<div id="scans-loading" class="text-center py-5">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading scans...</p>
</div>
<div id="scans-error" class="alert alert-danger" style="display: none;"></div>
<div id="scans-empty" class="text-center py-5 text-muted" style="display: none;">
<h5>No scans found</h5>
<p>Click "Trigger New Scan" to create your first scan.</p>
</div>
<div id="scans-table-container" style="display: none;">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 80px;">ID</th>
<th>Title</th>
<th style="width: 200px;">Timestamp</th>
<th style="width: 100px;">Duration</th>
<th style="width: 120px;">Status</th>
<th style="width: 200px;">Actions</th>
</tr>
</thead>
<tbody id="scans-tbody">
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="d-flex justify-content-between align-items-center mt-3">
<div class="text-muted">
Showing <span id="showing-start">0</span> to <span id="showing-end">0</span> of <span id="total-count">0</span> scans
</div>
<nav>
<ul class="pagination mb-0" id="pagination">
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Trigger Scan Modal -->
<div class="modal fade" id="triggerScanModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
<div class="modal-header" style="border-bottom: 1px solid #334155;">
<h5 class="modal-title" style="color: #60a5fa;">Trigger New Scan</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="trigger-scan-form">
<div class="mb-3">
<label for="config-file" class="form-label">Config File</label>
<select class="form-select" id="config-file" name="config_file" required {% if not config_files %}disabled{% endif %}>
<option value="">Select a config file...</option>
{% for config in config_files %}
<option value="{{ config }}">{{ config }}</option>
{% endfor %}
</select>
{% if config_files %}
<div class="form-text text-muted">
Select a scan configuration file
</div>
{% else %}
<div class="alert alert-warning mt-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle"></i>
<strong>No configurations available</strong>
<p class="mb-2 mt-2">You need to create a configuration file before you can trigger a scan.</p>
<a href="{{ url_for('main.upload_config') }}" class="btn btn-sm btn-primary">
<i class="bi bi-plus-circle"></i> Create Configuration
</a>
</div>
{% endif %}
</div>
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
</form>
</div>
<div class="modal-footer" style="border-top: 1px solid #334155;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="triggerScan()" {% if not config_files %}disabled{% endif %}>
<span id="modal-trigger-text">Trigger Scan</span>
<span id="modal-trigger-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentPage = 1;
let perPage = 20;
let statusFilter = '';
let totalCount = 0;
// Load initial data when page loads
document.addEventListener('DOMContentLoaded', function() {
loadScans();
// Auto-refresh every 15 seconds
setInterval(function() {
loadScans();
}, 15000);
});
// Load scans from API
async function loadScans() {
const loadingEl = document.getElementById('scans-loading');
const errorEl = document.getElementById('scans-error');
const emptyEl = document.getElementById('scans-empty');
const tableEl = document.getElementById('scans-table-container');
// Show loading state
loadingEl.style.display = 'block';
errorEl.style.display = 'none';
emptyEl.style.display = 'none';
tableEl.style.display = 'none';
try {
let url = `/api/scans?page=${currentPage}&per_page=${perPage}`;
if (statusFilter) {
url += `&status=${statusFilter}`;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to load scans');
}
const data = await response.json();
const scans = data.scans || [];
totalCount = data.total || 0;
loadingEl.style.display = 'none';
if (scans.length === 0) {
emptyEl.style.display = 'block';
} else {
tableEl.style.display = 'block';
renderScansTable(scans);
renderPagination(data.page, data.per_page, data.total, data.pages);
}
} catch (error) {
console.error('Error loading scans:', error);
loadingEl.style.display = 'none';
errorEl.textContent = 'Failed to load scans. Please try again.';
errorEl.style.display = 'block';
}
}
// Render scans table
function renderScansTable(scans) {
const tbody = document.getElementById('scans-tbody');
tbody.innerHTML = '';
scans.forEach(scan => {
const row = document.createElement('tr');
row.classList.add('scan-row'); // Fix white row bug
// Format timestamp
const timestamp = new Date(scan.timestamp).toLocaleString();
// Format duration
const duration = scan.duration ? `${scan.duration.toFixed(1)}s` : '-';
// Status badge
let statusBadge = '';
if (scan.status === 'completed') {
statusBadge = '<span class="badge badge-success">Completed</span>';
} else if (scan.status === 'running') {
statusBadge = '<span class="badge badge-info">Running</span>';
} else if (scan.status === 'failed') {
statusBadge = '<span class="badge badge-danger">Failed</span>';
} else {
statusBadge = `<span class="badge badge-info">${scan.status}</span>`;
}
row.innerHTML = `
<td class="mono">${scan.id}</td>
<td>${scan.title || 'Untitled Scan'}</td>
<td class="text-muted">${timestamp}</td>
<td class="mono">${duration}</td>
<td>${statusBadge}</td>
<td>
<a href="/scans/${scan.id}" class="btn btn-sm btn-secondary">View</a>
${scan.status !== 'running' ? `<button class="btn btn-sm btn-danger ms-1" onclick="deleteScan(${scan.id})">Delete</button>` : ''}
</td>
`;
tbody.appendChild(row);
});
}
// Render pagination
function renderPagination(page, per_page, total, pages) {
const paginationEl = document.getElementById('pagination');
paginationEl.innerHTML = '';
// Update showing text
const start = (page - 1) * per_page + 1;
const end = Math.min(page * per_page, total);
document.getElementById('showing-start').textContent = start;
document.getElementById('showing-end').textContent = end;
document.getElementById('total-count').textContent = total;
if (pages <= 1) {
return;
}
// Previous button
const prevLi = document.createElement('li');
prevLi.className = `page-item ${page === 1 ? 'disabled' : ''}`;
prevLi.innerHTML = `<a class="page-link" href="#" onclick="goToPage(${page - 1}); return false;">Previous</a>`;
paginationEl.appendChild(prevLi);
// Page numbers
const maxPagesToShow = 5;
let startPage = Math.max(1, page - Math.floor(maxPagesToShow / 2));
let endPage = Math.min(pages, startPage + maxPagesToShow - 1);
if (endPage - startPage < maxPagesToShow - 1) {
startPage = Math.max(1, endPage - maxPagesToShow + 1);
}
if (startPage > 1) {
const firstLi = document.createElement('li');
firstLi.className = 'page-item';
firstLi.innerHTML = `<a class="page-link" href="#" onclick="goToPage(1); return false;">1</a>`;
paginationEl.appendChild(firstLi);
if (startPage > 2) {
const ellipsisLi = document.createElement('li');
ellipsisLi.className = 'page-item disabled';
ellipsisLi.innerHTML = '<a class="page-link" href="#">...</a>';
paginationEl.appendChild(ellipsisLi);
}
}
for (let i = startPage; i <= endPage; i++) {
const pageLi = document.createElement('li');
pageLi.className = `page-item ${i === page ? 'active' : ''}`;
pageLi.innerHTML = `<a class="page-link" href="#" onclick="goToPage(${i}); return false;">${i}</a>`;
paginationEl.appendChild(pageLi);
}
if (endPage < pages) {
if (endPage < pages - 1) {
const ellipsisLi = document.createElement('li');
ellipsisLi.className = 'page-item disabled';
ellipsisLi.innerHTML = '<a class="page-link" href="#">...</a>';
paginationEl.appendChild(ellipsisLi);
}
const lastLi = document.createElement('li');
lastLi.className = 'page-item';
lastLi.innerHTML = `<a class="page-link" href="#" onclick="goToPage(${pages}); return false;">${pages}</a>`;
paginationEl.appendChild(lastLi);
}
// Next button
const nextLi = document.createElement('li');
nextLi.className = `page-item ${page === pages ? 'disabled' : ''}`;
nextLi.innerHTML = `<a class="page-link" href="#" onclick="goToPage(${page + 1}); return false;">Next</a>`;
paginationEl.appendChild(nextLi);
}
// Navigation functions
function goToPage(page) {
currentPage = page;
loadScans();
}
function filterScans() {
statusFilter = document.getElementById('status-filter').value;
currentPage = 1;
loadScans();
}
function changePerPage() {
perPage = parseInt(document.getElementById('per-page').value);
currentPage = 1;
loadScans();
}
function refreshScans() {
const refreshBtn = document.getElementById('refresh-text');
const refreshSpinner = document.getElementById('refresh-spinner');
refreshBtn.style.display = 'none';
refreshSpinner.style.display = 'inline-block';
loadScans().finally(() => {
refreshBtn.style.display = 'inline';
refreshSpinner.style.display = 'none';
});
}
// Show trigger scan modal
function showTriggerScanModal() {
const modal = new bootstrap.Modal(document.getElementById('triggerScanModal'));
document.getElementById('trigger-error').style.display = 'none';
document.getElementById('trigger-scan-form').reset();
modal.show();
}
// Trigger scan
async function triggerScan() {
const configFile = document.getElementById('config-file').value;
const errorEl = document.getElementById('trigger-error');
const btnText = document.getElementById('modal-trigger-text');
const btnSpinner = document.getElementById('modal-trigger-spinner');
if (!configFile) {
errorEl.textContent = 'Please enter a config file path.';
errorEl.style.display = 'block';
return;
}
// Show loading state
btnText.style.display = 'none';
btnSpinner.style.display = 'inline-block';
errorEl.style.display = 'none';
try {
const response = await fetch('/api/scans', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
config_file: configFile
})
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to trigger scan');
}
const data = await response.json();
// Hide error before closing modal to prevent flash
errorEl.style.display = 'none';
// Close modal
bootstrap.Modal.getInstance(document.getElementById('triggerScanModal')).hide();
// Show success message
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success alert-dismissible fade show mt-3';
alertDiv.innerHTML = `
Scan triggered successfully! (ID: ${data.scan_id})
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
// Insert at the beginning of container-fluid
const container = document.querySelector('.container-fluid');
container.insertBefore(alertDiv, container.firstChild);
// Refresh scans
loadScans();
} catch (error) {
console.error('Error triggering scan:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
} finally {
btnText.style.display = 'inline';
btnSpinner.style.display = 'none';
}
}
// Delete scan
async function deleteScan(scanId) {
if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) {
return;
}
try {
const response = await fetch(`/api/scans/${scanId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete scan');
}
// Show success message
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success alert-dismissible fade show mt-3';
alertDiv.innerHTML = `
Scan ${scanId} deleted successfully.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.querySelector('.container-fluid').insertBefore(alertDiv, document.querySelector('.row'));
// Refresh scans
loadScans();
} catch (error) {
console.error('Error deleting scan:', error);
alert('Failed to delete scan. Please try again.');
}
}
// Custom pagination styles
const style = document.createElement('style');
style.textContent = `
.pagination {
--bs-pagination-bg: #1e293b;
--bs-pagination-border-color: #334155;
--bs-pagination-hover-bg: #334155;
--bs-pagination-hover-border-color: #475569;
--bs-pagination-focus-bg: #334155;
--bs-pagination-active-bg: #3b82f6;
--bs-pagination-active-border-color: #3b82f6;
--bs-pagination-disabled-bg: #0f172a;
--bs-pagination-disabled-border-color: #334155;
--bs-pagination-color: #e2e8f0;
--bs-pagination-hover-color: #e2e8f0;
--bs-pagination-disabled-color: #64748b;
}
`;
document.head.appendChild(style);
</script>
{% endblock %}

View File

@@ -0,0 +1,442 @@
{% 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 (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>
<span class="badge bg-info">LOCAL TIME</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><br>
<strong class="text-info"> All times use your local timezone (CST/UTC-6)</strong>
</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 (local time):</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-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="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>
<script>
// Update local time and timezone info every second
function updateServerTime() {
const now = new Date();
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();
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 (local time)';
}
if (minute === '0' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
return `Runs daily at ${hour.padStart(2, '0')}:00 (local time)`;
}
if (minute !== '*' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
return `Runs daily at ${hour.padStart(2, '0')}:${minute.padStart(2, '0')} (local time)`;
}
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`;
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);
}
</script>
{% endblock %}

View File

@@ -0,0 +1,596 @@
{% 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-file').value = schedule.config_file;
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 %}

View File

@@ -0,0 +1,393 @@
{% 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">${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`;
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 %}

View File

@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Setup - SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
}
.setup-container {
width: 100%;
max-width: 500px;
padding: 2rem;
}
.card {
border: none;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.brand-title {
color: #00d9ff;
font-weight: 600;
margin-bottom: 0.5rem;
}
</style>
</head>
<body>
<div class="setup-container">
<div class="card">
<div class="card-body p-5">
<div class="text-center mb-4">
<h1 class="brand-title">SneakyScanner</h1>
<p class="text-muted">Initial Setup</p>
</div>
<div class="alert alert-info mb-4">
<strong>Welcome!</strong> Please set an application password to secure your scanner.
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="post" action="{{ url_for('auth.setup') }}">
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password"
class="form-control"
id="password"
name="password"
required
minlength="8"
autofocus
placeholder="Enter password (min 8 characters)">
<div class="form-text">Password must be at least 8 characters long.</div>
</div>
<div class="mb-4">
<label for="confirm_password" class="form-label">Confirm Password</label>
<input type="password"
class="form-control"
id="confirm_password"
name="confirm_password"
required
minlength="8"
placeholder="Confirm your password">
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">
Set Password
</button>
</form>
</div>
</div>
<div class="text-center mt-3">
<small class="text-muted">SneakyScanner v1.0 - Phase 2</small>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>