phase 4 complete
This commit is contained in:
@@ -53,6 +53,10 @@
|
||||
<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">
|
||||
|
||||
263
web/templates/config_edit.html
Normal file
263
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
web/templates/config_upload.html
Normal file
415
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
web/templates/configs.html
Normal file
377
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 %}
|
||||
@@ -360,6 +360,9 @@
|
||||
|
||||
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();
|
||||
|
||||
@@ -370,7 +373,9 @@
|
||||
Scan triggered successfully! (ID: ${data.scan_id})
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.querySelector('.container-fluid').insertBefore(alertDiv, document.querySelector('.row'));
|
||||
// Insert at the beginning of container-fluid
|
||||
const container = document.querySelector('.container-fluid');
|
||||
container.insertBefore(alertDiv, container.firstChild);
|
||||
|
||||
// Refresh scans and stats
|
||||
refreshScans();
|
||||
|
||||
@@ -2,57 +2,6 @@
|
||||
|
||||
{% block title %}Login - SneakyScanner{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
body {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
}
|
||||
|
||||
.container-fluid {
|
||||
max-width: 450px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background-color: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
padding: 3rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
color: #60a5fa;
|
||||
font-weight: 600;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
color: #94a3b8;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% set hide_nav = true %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -133,7 +133,9 @@
|
||||
<p class="text-muted mb-3">
|
||||
Historical port count trend for scans using the same configuration
|
||||
</p>
|
||||
<canvas id="historyChart" height="80"></canvas>
|
||||
<div style="position: relative; height: 300px;">
|
||||
<canvas id="historyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,10 +168,14 @@
|
||||
<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();
|
||||
loadScan().then(() => {
|
||||
findPreviousScan();
|
||||
loadHistoricalChart();
|
||||
});
|
||||
|
||||
// Auto-refresh every 10 seconds if scan is running
|
||||
setInterval(function() {
|
||||
@@ -494,8 +500,13 @@
|
||||
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');
|
||||
new Chart(ctx, {
|
||||
historyChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
@@ -570,11 +581,5 @@
|
||||
console.error('Error loading historical chart:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize: find previous scan and load chart after loading current scan
|
||||
loadScan().then(() => {
|
||||
findPreviousScan();
|
||||
loadHistoricalChart();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -396,6 +396,9 @@
|
||||
|
||||
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();
|
||||
|
||||
@@ -406,7 +409,9 @@
|
||||
Scan triggered successfully! (ID: ${data.scan_id})
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.querySelector('.container-fluid').insertBefore(alertDiv, document.querySelector('.row'));
|
||||
// Insert at the beginning of container-fluid
|
||||
const container = document.querySelector('.container-fluid');
|
||||
container.insertBefore(alertDiv, container.firstChild);
|
||||
|
||||
// Refresh scans
|
||||
loadScans();
|
||||
|
||||
Reference in New Issue
Block a user