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:
2025-11-14 11:51:27 -06:00
parent 0791c60f60
commit a64096ece3
8 changed files with 1664 additions and 200 deletions

View File

@@ -1,7 +1,7 @@
# Phase 2 Implementation Plan: Flask Web App Core
**Status:** Step 4 Complete ✅ - Authentication System (Days 7-8)
**Progress:** 8/14 days complete (57%)
**Status:** Step 5 Complete ✅ - Basic UI Templates (Days 9-10)
**Progress:** 10/14 days complete (71%)
**Estimated Duration:** 14 days (2 weeks)
**Dependencies:** Phase 1 Complete ✅
@@ -34,8 +34,17 @@
- Bootstrap 5 dark theme UI templates
- 30+ authentication tests
- 1,200+ lines of code added
- **Step 5: Basic UI Templates** (Days 9-10) - NEXT
- 📋 **Step 6: Docker & Deployment** (Day 11) - Pending
- **Step 5: Basic UI Templates** (Days 9-10) - COMPLETE
- base.html template with navigation and slate dark theme
- dashboard.html with stats cards and recent scans
- scans.html with pagination and filtering
- scan_detail.html with comprehensive scan results display
- login.html updated to use dark theme
- All templates use matching color scheme from report_mockup.html
- AJAX-powered dynamic data loading
- Auto-refresh for running scans
- Responsive design with Bootstrap 5
- 📋 **Step 6: Docker & Deployment** (Day 11) - NEXT
- 📋 **Step 7: Error Handling & Logging** (Day 12) - Pending
- 📋 **Step 8: Testing & Documentation** (Days 13-14) - Pending

View File

@@ -20,8 +20,8 @@ import yaml
from libnmap.process import NmapProcess
from libnmap.parser import NmapParser
from screenshot_capture import ScreenshotCapture
from report_generator import HTMLReportGenerator
from src.screenshot_capture import ScreenshotCapture
from src.report_generator import HTMLReportGenerator
# Force unbuffered output for Docker
sys.stdout.reconfigure(line_buffering=True)

View File

@@ -11,7 +11,8 @@ from sqlalchemy.exc import SQLAlchemyError
from web.auth.decorators import api_auth_required
from web.services.scan_service import ScanService
from web.utils.validators import validate_config_file, validate_page_params
from web.utils.validators import validate_config_file
from web.utils.pagination import validate_page_params
bp = Blueprint('scans', __name__)
logger = logging.getLogger(__name__)

345
web/templates/base.html Normal file
View File

@@ -0,0 +1,345 @@
<!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>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
line-height: 1.6;
}
/* Navbar */
.navbar-custom {
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
border-bottom: 1px solid #475569;
padding: 1rem 0;
}
.navbar-brand {
font-size: 1.5rem;
font-weight: 600;
color: #60a5fa !important;
}
.nav-link {
color: #94a3b8 !important;
transition: color 0.2s;
}
.nav-link:hover,
.nav-link.active {
color: #60a5fa !important;
}
/* Container */
.container-fluid {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* Cards */
.card {
background-color: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
margin-bottom: 25px;
}
.card-header {
background-color: #334155;
border-bottom: 1px solid #475569;
padding: 15px 20px;
border-radius: 12px 12px 0 0 !important;
}
.card-body {
padding: 25px;
}
.card-title {
color: #60a5fa;
font-size: 1.5rem;
margin-bottom: 15px;
}
/* Badges */
.badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-expected,
.badge-good,
.badge-success {
background-color: #065f46;
color: #6ee7b7;
}
.badge-unexpected,
.badge-critical,
.badge-danger {
background-color: #7f1d1d;
color: #fca5a5;
}
.badge-missing,
.badge-warning {
background-color: #78350f;
color: #fcd34d;
}
.badge-info {
background-color: #1e3a8a;
color: #93c5fd;
}
/* Buttons */
.btn-primary {
background-color: #3b82f6;
border-color: #3b82f6;
color: #ffffff;
}
.btn-primary:hover {
background-color: #2563eb;
border-color: #2563eb;
}
.btn-secondary {
background-color: #334155;
border-color: #334155;
color: #e2e8f0;
}
.btn-secondary:hover {
background-color: #475569;
border-color: #475569;
}
.btn-danger {
background-color: #7f1d1d;
border-color: #7f1d1d;
color: #fca5a5;
}
.btn-danger:hover {
background-color: #991b1b;
border-color: #991b1b;
}
/* Tables */
.table {
color: #e2e8f0;
border-color: #334155;
}
.table thead {
background-color: #334155;
color: #94a3b8;
}
.table tbody tr {
background-color: #1e293b;
border-color: #334155;
}
.table tbody tr:hover {
background-color: #334155;
cursor: pointer;
}
.table th,
.table td {
padding: 12px;
border-color: #334155;
}
/* Alerts */
.alert {
border-radius: 8px;
border: 1px solid;
}
.alert-success {
background-color: #065f46;
border-color: #10b981;
color: #6ee7b7;
}
.alert-danger {
background-color: #7f1d1d;
border-color: #ef4444;
color: #fca5a5;
}
.alert-warning {
background-color: #78350f;
border-color: #f59e0b;
color: #fcd34d;
}
.alert-info {
background-color: #1e3a8a;
border-color: #3b82f6;
color: #93c5fd;
}
/* Form Controls */
.form-control,
.form-select {
background-color: #1e293b;
border-color: #334155;
color: #e2e8f0;
}
.form-control:focus,
.form-select:focus {
background-color: #1e293b;
border-color: #60a5fa;
color: #e2e8f0;
box-shadow: 0 0 0 0.2rem rgba(96, 165, 250, 0.25);
}
.form-label {
color: #94a3b8;
font-weight: 500;
}
/* Stats Cards */
.stat-card {
background-color: #0f172a;
padding: 20px;
border-radius: 8px;
border: 1px solid #334155;
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: 600;
color: #60a5fa;
}
.stat-label {
color: #94a3b8;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 5px;
}
/* Footer */
.footer {
margin-top: 40px;
padding: 20px 0;
border-top: 1px solid #334155;
text-align: center;
color: #64748b;
font-size: 0.9rem;
}
/* Utilities */
.text-muted {
color: #94a3b8 !important;
}
.text-success {
color: #10b981 !important;
}
.text-warning {
color: #f59e0b !important;
}
.text-danger {
color: #ef4444 !important;
}
.text-info {
color: #60a5fa !important;
}
.mono {
font-family: 'Courier New', monospace;
}
/* Spinner for loading states */
.spinner-border-sm {
color: #60a5fa;
}
{% block extra_styles %}{% endblock %}
</style>
</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>
</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 2 Complete
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,84 +1,355 @@
<!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>Dashboard - SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background: #1a1a2e;
}
.navbar-brand {
color: #00d9ff !important;
font-weight: 600;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<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 active" href="{{ url_for('main.dashboard') }}">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.scans') }}">Scans</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>
{% extends "base.html" %}
<div class="container-fluid mt-4">
<div class="row">
{% block title %}Dashboard - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<h1 class="mb-4">Dashboard</h1>
{% 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>
<h1 class="mb-4" style="color: #60a5fa;">Dashboard</h1>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<div class="alert alert-info">
<h4 class="alert-heading">Phase 2 Complete!</h4>
<p>Authentication system is now active. Full dashboard UI will be implemented in Phase 5.</p>
<hr>
<p class="mb-0">Use the API endpoints to trigger scans and view results.</p>
<!-- 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">
<h5 class="card-title">Quick Actions</h5>
<p class="card-text">Use the API to manage scans:</p>
<ul>
<li><code>POST /api/scans</code> - Trigger a new scan</li>
<li><code>GET /api/scans</code> - List all scans</li>
<li><code>GET /api/scans/{id}</code> - View scan details</li>
<li><code>DELETE /api/scans/{id}</code> - Delete a scan</li>
</ul>
</div>
</div>
<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>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
<!-- 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>
<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>
</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()">
<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();
// 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);
});
// 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');
// 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.error || 'Failed to trigger scan');
}
const data = await response.json();
// 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>
`;
document.querySelector('.container-fluid').insertBefore(alertDiv, document.querySelector('.row'));
// 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';
}
}
// Delete scan
async function deleteScan(scanId) {
if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) {
return;
}
try {
const response = await fetch(`/api/scans/${scanId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete scan');
}
// Refresh scans and stats
refreshScans();
loadStats();
} catch (error) {
console.error('Error deleting scan:', error);
alert('Failed to delete scan. Please try again.');
}
}
</script>
{% endblock %}

View File

@@ -1,54 +1,67 @@
<!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>Login - SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
{% extends "base.html" %}
{% block title %}Login - SneakyScanner{% endblock %}
{% block extra_styles %}
body {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
}
.login-container {
width: 100%;
max-width: 400px;
padding: 2rem;
}
.card {
border: none;
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: #00d9ff;
}
.brand-title {
color: #60a5fa;
font-weight: 600;
font-size: 2rem;
margin-bottom: 0.5rem;
}
</style>
</head>
<body>
<div class="login-container">
<div class="card">
<div class="card-body p-5">
}
.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 %}
<div class="login-card">
<div class="text-center mb-4">
<h1 class="brand-title">SneakyScanner</h1>
<p class="text-muted">Network Security Scanner</p>
<p class="brand-subtitle">Network Security Scanner</p>
</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 %}
{% if password_not_set %}
<div class="alert alert-warning">
<strong>Setup Required:</strong> Please set an application password first.
@@ -82,14 +95,5 @@
</button>
</form>
{% endif %}
</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>
</div>
{% endblock %}

View File

@@ -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>
</body>
</html>
<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>
</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 %}

View File

@@ -1,16 +1,468 @@
<!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>Scans - 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>Scans List</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 %}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>
</body>
</html>
</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>
<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>
</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()">
<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');
// 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();
// 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>
`;
document.querySelector('.container-fluid').insertBefore(alertDiv, document.querySelector('.row'));
// 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 %}