Phase 3 Step 7: Scan Comparison Features & UX Improvements
Implemented comprehensive scan comparison functionality with historical analysis and improved user experience for scan triggering. Features Added: - Scan comparison engine with ports, services, and certificates analysis - Drift score calculation (0.0-1.0 scale) for infrastructure changes - Side-by-side comparison UI with color-coded changes (added/removed/changed) - Historical trend charts showing port counts over time - "Compare with Previous" button on scan detail pages - Scan history API endpoint for trending data API Endpoints: - GET /api/scans/<id1>/compare/<id2> - Compare two scans - GET /api/stats/scan-history/<id> - Historical scan data for charts UI Improvements: - Replaced config file text inputs with dropdown selectors - Added config file selection to dashboard and scans pages - Improved delete scan confirmation with proper async handling - Enhanced error messages with detailed validation feedback - Added 2-second delay before redirect to ensure deletion completes Comparison Features: - Port changes: tracks added, removed, and unchanged ports - Service changes: detects version updates and service modifications - Certificate changes: monitors SSL/TLS certificate updates - Interactive historical charts with clickable data points - Automatic detection of previous scan for comparison Bug Fixes: - Fixed scan deletion UI alert appearing on successful deletion - Prevented config file path duplication (configs/configs/...) - Improved error handling for failed API responses - Added proper JSON response parsing with fallback handling Testing: - Created comprehensive test suite for comparison functionality - Tests cover comparison API, service methods, and drift scoring - Added edge case tests for identical scans and missing data
This commit is contained in:
@@ -154,13 +154,19 @@
|
||||
<form id="trigger-scan-form">
|
||||
<div class="mb-3">
|
||||
<label for="config-file" class="form-label">Config File</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="config-file"
|
||||
name="config_file"
|
||||
placeholder="/app/configs/example.yaml"
|
||||
required>
|
||||
<div class="form-text text-muted">Path to YAML configuration file</div>
|
||||
<select class="form-select" id="config-file" name="config_file" required>
|
||||
<option value="">Select a config file...</option>
|
||||
{% for config in config_files %}
|
||||
<option value="{{ config }}">{{ config }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text text-muted">
|
||||
{% if config_files %}
|
||||
Select a scan configuration file
|
||||
{% else %}
|
||||
<span class="text-warning">No config files found in /app/configs/</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
||||
</form>
|
||||
@@ -349,7 +355,7 @@
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to trigger scan');
|
||||
throw new Error(data.message || data.error || 'Failed to trigger scan');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
526
web/templates/scan_compare.html
Normal file
526
web/templates/scan_compare.html
Normal file
@@ -0,0 +1,526 @@
|
||||
{% 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>
|
||||
|
||||
<!-- 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" id="scan1-timestamp">-</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" id="scan2-timestamp">-</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) {
|
||||
// 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('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();
|
||||
|
||||
// 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 %}
|
||||
@@ -13,7 +13,10 @@
|
||||
<h1 style="color: #60a5fa;">Scan #<span id="scan-id">{{ scan_id }}</span></h1>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-secondary" onclick="refreshScan()">
|
||||
<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>
|
||||
@@ -117,6 +120,25 @@
|
||||
</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>
|
||||
<canvas id="historyChart" height="80"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sites and IPs -->
|
||||
<div id="sites-container">
|
||||
<!-- Sites will be dynamically inserted here -->
|
||||
@@ -379,21 +401,180 @@
|
||||
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'
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Check status code first
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete scan');
|
||||
// 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. Please try again.');
|
||||
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;
|
||||
async function findPreviousScan() {
|
||||
try {
|
||||
// Get list of scans to find the previous one
|
||||
const response = await fetch('/api/scans?per_page=100&status=completed');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.scans && data.scans.length > 0) {
|
||||
// Find scans older than current scan
|
||||
const currentScanIndex = data.scans.findIndex(s => s.id === scanId);
|
||||
|
||||
if (currentScanIndex !== -1 && currentScanIndex < data.scans.length - 1) {
|
||||
// Get the next scan in the list (which is older due to desc order)
|
||||
previousScanId = data.scans[currentScanIndex + 1].id;
|
||||
|
||||
// Show the compare button
|
||||
const compareBtn = document.getElementById('compare-btn');
|
||||
if (compareBtn) {
|
||||
compareBtn.style.display = 'inline-block';
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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';
|
||||
|
||||
const ctx = document.getElementById('historyChart').getContext('2d');
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize: find previous scan and load chart after loading current scan
|
||||
loadScan().then(() => {
|
||||
findPreviousScan();
|
||||
loadHistoricalChart();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -114,13 +114,19 @@
|
||||
<form id="trigger-scan-form">
|
||||
<div class="mb-3">
|
||||
<label for="config-file" class="form-label">Config File</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="config-file"
|
||||
name="config_file"
|
||||
placeholder="/app/configs/example.yaml"
|
||||
required>
|
||||
<div class="form-text text-muted">Path to YAML configuration file</div>
|
||||
<select class="form-select" id="config-file" name="config_file" required>
|
||||
<option value="">Select a config file...</option>
|
||||
{% for config in config_files %}
|
||||
<option value="{{ config }}">{{ config }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text text-muted">
|
||||
{% if config_files %}
|
||||
Select a scan configuration file
|
||||
{% else %}
|
||||
<span class="text-warning">No config files found in /app/configs/</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user