Phase 2 Step 5: Implement Basic UI Templates
Implement comprehensive web UI with dark slate theme matching HTML reports: Templates: - Create base.html with navigation, dark theme (#0f172a background) - Update dashboard.html with stats cards and recent scans table - Update scans.html with pagination, filtering, and status badges - Update scan_detail.html with comprehensive scan results display - Update login.html to extend base template with centered design Features: - AJAX-powered dynamic data loading from API endpoints - Auto-refresh for running scans (10-15 second intervals) - Responsive Bootstrap 5 grid layout - Color scheme matches report_mockup.html (slate dark theme) - Status badges (success/danger/warning/info) with proper colors - Modal dialogs for triggering scans - Pagination with ellipsis for large result sets - Delete confirmation dialogs - Loading spinners for async operations Bug Fixes: - Fix scanner.py imports to use 'src.' prefix for module imports - Fix scans.py to import validate_page_params from pagination module All templates use consistent color palette: - Background: #0f172a, Cards: #1e293b, Accent: #60a5fa - Success: #065f46/#6ee7b7, Danger: #7f1d1d/#fca5a5 - Warning: #78350f/#fcd34d, Info: #1e3a8a/#93c5fd
This commit is contained in:
@@ -1,16 +1,398 @@
|
||||
<!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>Scan Detail - SneakyScanner</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-4">
|
||||
<h1>Scan Detail #{{ scan_id }}</h1>
|
||||
<p>This page will be implemented in Phase 5.</p>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">Back to Dashboard</a>
|
||||
{% 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-secondary" 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>
|
||||
</body>
|
||||
</html>
|
||||
</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>
|
||||
|
||||
<!-- 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;
|
||||
|
||||
// Load scan on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadScan();
|
||||
|
||||
// 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><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.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;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/scans/${scanId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete scan');
|
||||
}
|
||||
|
||||
// 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.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user