restructure of dirs, huge docs update
This commit is contained in:
95
app/web/templates/base.html
Normal file
95
app/web/templates/base.html
Normal 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>
|
||||
263
app/web/templates/config_edit.html
Normal file
263
app/web/templates/config_edit.html
Normal 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 %}
|
||||
415
app/web/templates/config_upload.html
Normal file
415
app/web/templates/config_upload.html
Normal 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 %}
|
||||
377
app/web/templates/configs.html
Normal file
377
app/web/templates/configs.html
Normal 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 %}
|
||||
580
app/web/templates/dashboard.html
Normal file
580
app/web/templates/dashboard.html
Normal 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 %}
|
||||
84
app/web/templates/errors/400.html
Normal file
84
app/web/templates/errors/400.html
Normal 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>
|
||||
84
app/web/templates/errors/401.html
Normal file
84
app/web/templates/errors/401.html
Normal 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>
|
||||
84
app/web/templates/errors/403.html
Normal file
84
app/web/templates/errors/403.html
Normal 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>
|
||||
84
app/web/templates/errors/404.html
Normal file
84
app/web/templates/errors/404.html
Normal 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>
|
||||
84
app/web/templates/errors/405.html
Normal file
84
app/web/templates/errors/405.html
Normal 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>
|
||||
114
app/web/templates/errors/500.html
Normal file
114
app/web/templates/errors/500.html
Normal 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>
|
||||
48
app/web/templates/login.html
Normal file
48
app/web/templates/login.html
Normal 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 %}
|
||||
545
app/web/templates/scan_compare.html
Normal file
545
app/web/templates/scan_compare.html
Normal 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 %}
|
||||
605
app/web/templates/scan_detail.html
Normal file
605
app/web/templates/scan_detail.html
Normal 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 %}
|
||||
487
app/web/templates/scans.html
Normal file
487
app/web/templates/scans.html
Normal 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 %}
|
||||
442
app/web/templates/schedule_create.html
Normal file
442
app/web/templates/schedule_create.html
Normal 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 %}
|
||||
596
app/web/templates/schedule_edit.html
Normal file
596
app/web/templates/schedule_edit.html
Normal 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 %}
|
||||
393
app/web/templates/schedules.html
Normal file
393
app/web/templates/schedules.html
Normal 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 %}
|
||||
95
app/web/templates/setup.html
Normal file
95
app/web/templates/setup.html
Normal 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>
|
||||
Reference in New Issue
Block a user