Files
SneakyScan/app/web/templates/sites.html
Phillip Tarrant 0ec338e252 Migrate from file-based configs to database with per-IP site configuration
Major architectural changes:
   - Replace YAML config files with database-stored ScanConfig model
   - Remove CIDR block support in favor of individual IP addresses per site
   - Each IP now has its own expected_ping, expected_tcp_ports, expected_udp_ports
   - AlertRule now uses config_id FK instead of config_file string

   API changes:
   - POST /api/scans now requires config_id instead of config_file
   - Alert rules API uses config_id with validation
   - All config dropdowns fetch from /api/configs dynamically

   Template updates:
   - scans.html, dashboard.html, alert_rules.html load configs via API
   - Display format: Config Title (X sites) in dropdowns
   - Removed Jinja2 config_files loops

   Migrations:
   - 008: Expand CIDRs to individual IPs with per-IP port configs
   - 009: Remove CIDR-related columns
   - 010: Add config_id to alert_rules, remove config_file
2025-11-19 19:40:34 -06:00

1215 lines
50 KiB
HTML

{% extends "base.html" %}
{% block title %}Sites - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: #60a5fa;">Site Management</h1>
<div>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createSiteModal">
<i class="bi bi-plus-circle"></i> Create New Site
</button>
</div>
</div>
</div>
</div>
<!-- Summary Stats -->
<div class="row mb-4">
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="total-sites">-</div>
<div class="stat-label">Total Sites</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="total-ips">-</div>
<div class="stat-label">Total IPs</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="sites-in-use">-</div>
<div class="stat-label">Used in Scans</div>
</div>
</div>
</div>
<!-- Sites Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0" style="color: #60a5fa;">All Sites</h5>
<input type="text" id="search-input" class="form-control" style="max-width: 300px;"
placeholder="Search sites...">
</div>
</div>
<div class="card-body">
<div id="sites-loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading sites...</p>
</div>
<div id="sites-error" style="display: none;" class="alert alert-danger">
<strong>Error:</strong> <span id="error-message"></span>
</div>
<div id="sites-content" style="display: none;">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Site Name</th>
<th>Description</th>
<th>IPs</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="sites-tbody">
<!-- Populated by JavaScript -->
</tbody>
</table>
</div>
<div id="empty-state" style="display: none;" class="text-center py-5">
<i class="bi bi-globe" style="font-size: 3rem; color: #64748b;"></i>
<h5 class="mt-3 text-muted">No sites defined</h5>
<p class="text-muted">Create your first site to organize your IP addresses</p>
<button class="btn btn-primary mt-2" data-bs-toggle="modal" data-bs-target="#createSiteModal">
<i class="bi bi-plus-circle"></i> Create Site
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Create Site Modal -->
<div class="modal fade" id="createSiteModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
<div class="modal-header" style="border-bottom: 1px solid #334155;">
<h5 class="modal-title" style="color: #60a5fa;">
<i class="bi bi-plus-circle"></i> Create New Site
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="create-site-form">
<div class="mb-3">
<label for="site-name" class="form-label" style="color: #e2e8f0;">Site Name *</label>
<input type="text" class="form-control" id="site-name" required
placeholder="e.g., Production Web Servers">
</div>
<div class="mb-3">
<label for="site-description" class="form-label" style="color: #e2e8f0;">Description</label>
<textarea class="form-control" id="site-description" rows="3"
placeholder="Optional description of this site"></textarea>
</div>
<div class="alert alert-info" style="background-color: #1e3a5f; border-color: #2d5a8c; color: #a5d6ff;">
<i class="bi bi-info-circle"></i> After creating the site, you'll be able to add IP addresses using CIDRs, individual IPs, or bulk import.
</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="createSite()">
<i class="bi bi-check-circle"></i> Create Site
</button>
</div>
</div>
</div>
</div>
<!-- View Site Modal -->
<div class="modal fade" id="viewSiteModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
<div class="modal-header" style="border-bottom: 1px solid #334155;">
<h5 class="modal-title" style="color: #60a5fa;">
<i class="bi bi-globe"></i> <span id="view-site-name"></span>
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<h6 style="color: #94a3b8;">Description:</h6>
<p id="view-site-description" style="color: #e2e8f0;"></p>
<div class="d-flex justify-content-between align-items-center mt-3 mb-2">
<h6 style="color: #94a3b8; margin: 0;">IP Addresses (<span id="ip-count">0</span>):</h6>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-plus-circle"></i> Add IPs
</button>
<ul class="dropdown-menu dropdown-menu-dark">
<li><a class="dropdown-item" href="#" onclick="showAddIpMethod('cidr'); return false;"><i class="bi bi-diagram-3"></i> From CIDR</a></li>
<li><a class="dropdown-item" href="#" onclick="showAddIpMethod('individual'); return false;"><i class="bi bi- hdd-network"></i> Individual IP</a></li>
<li><a class="dropdown-item" href="#" onclick="showAddIpMethod('bulk'); return false;"><i class="bi bi-file-earmark-text"></i> Bulk Import</a></li>
</ul>
</div>
</div>
<!-- Add from CIDR Form -->
<div id="add-cidr-form" style="display: none; margin-bottom: 15px; padding: 15px; background-color: #0f172a; border: 1px solid #334155; border-radius: 5px;">
<h6 style="color: #60a5fa;"><i class="bi bi-diagram-3"></i> Add IPs from CIDR</h6>
<div class="row">
<div class="col-md-6">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">CIDR Range *</label>
<input type="text" class="form-control form-control-sm" id="bulk-cidr" placeholder="e.g., 10.0.0.0/24">
<small style="color: #64748b;">Max /24 (256 IPs)</small>
</div>
<div class="col-md-6">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected Ping</label>
<select class="form-select form-select-sm" id="bulk-cidr-ping">
<option value="null">Not Set</option>
<option value="true">Yes</option>
<option value="false" selected>No</option>
</select>
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected TCP Ports</label>
<input type="text" class="form-control form-control-sm" id="bulk-cidr-tcp-ports" placeholder="e.g., 22,80,443">
<small style="color: #64748b;">Comma-separated</small>
</div>
<div class="col-md-6">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected UDP Ports</label>
<input type="text" class="form-control form-control-sm" id="bulk-cidr-udp-ports" placeholder="e.g., 53,123">
<small style="color: #64748b;">Comma-separated</small>
</div>
</div>
<div class="mt-3">
<button class="btn btn-sm btn-primary" onclick="addIpsFromCidr()">
<i class="bi bi-check-circle"></i> Add IPs
</button>
<button class="btn btn-sm btn-secondary" onclick="hideAllAddForms()">
Cancel
</button>
</div>
</div>
<!-- Add Individual IP Form -->
<div id="add-individual-form" style="display: none; margin-bottom: 15px; padding: 15px; background-color: #0f172a; border: 1px solid #334155; border-radius: 5px;">
<h6 style="color: #60a5fa;"><i class="bi bi-hdd-network"></i> Add Individual IP</h6>
<div class="row">
<div class="col-md-6">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">IP Address *</label>
<input type="text" class="form-control form-control-sm" id="individual-ip" placeholder="e.g., 192.168.1.100">
</div>
<div class="col-md-6">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected Ping</label>
<select class="form-select form-select-sm" id="individual-ping">
<option value="null">Not Set</option>
<option value="true">Yes</option>
<option value="false" selected>No</option>
</select>
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected TCP Ports</label>
<input type="text" class="form-control form-control-sm" id="individual-tcp-ports" placeholder="e.g., 22,80,443">
<small style="color: #64748b;">Comma-separated</small>
</div>
<div class="col-md-6">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected UDP Ports</label>
<input type="text" class="form-control form-control-sm" id="individual-udp-ports" placeholder="e.g., 53,123">
<small style="color: #64748b;">Comma-separated</small>
</div>
</div>
<div class="mt-3">
<button class="btn btn-sm btn-primary" onclick="addIndividualIp()">
<i class="bi bi-check-circle"></i> Add IP
</button>
<button class="btn btn-sm btn-secondary" onclick="hideAllAddForms()">
Cancel
</button>
</div>
</div>
<!-- Bulk Import Form -->
<div id="add-bulk-form" style="display: none; margin-bottom: 15px; padding: 15px; background-color: #0f172a; border: 1px solid #334155; border-radius: 5px;">
<h6 style="color: #60a5fa;"><i class="bi bi-file-earmark-text"></i> Bulk Import IPs</h6>
<div class="mb-2">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">IP Addresses *</label>
<textarea class="form-control form-control-sm" id="bulk-ips" rows="5" placeholder="Paste IPs here (one per line, or comma/space separated)"></textarea>
<small style="color: #64748b;">Supports: one per line, comma-separated, or space-separated</small>
</div>
<div class="row mt-2">
<div class="col-md-4">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected Ping</label>
<select class="form-select form-select-sm" id="bulk-ping">
<option value="null">Not Set</option>
<option value="true">Yes</option>
<option value="false" selected>No</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected TCP Ports</label>
<input type="text" class="form-control form-control-sm" id="bulk-tcp-ports" placeholder="e.g., 22,80,443">
<small style="color: #64748b;">Comma-separated</small>
</div>
<div class="col-md-4">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected UDP Ports</label>
<input type="text" class="form-control form-control-sm" id="bulk-udp-ports" placeholder="e.g., 53,123">
<small style="color: #64748b;">Comma-separated</small>
</div>
</div>
<div class="mt-3">
<button class="btn btn-sm btn-primary" onclick="addIpsFromBulk()">
<i class="bi bi-check-circle"></i> Import IPs
</button>
<button class="btn btn-sm btn-secondary" onclick="hideAllAddForms()">
Cancel
</button>
</div>
</div>
<!-- IP Table -->
<div id="view-site-ips-container">
<div id="ips-loading" style="display: none;" class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<span class="ms-2" style="color: #94a3b8;">Loading IPs...</span>
</div>
<div id="view-site-ips" class="table-responsive">
<!-- Will be populated by JavaScript -->
</div>
</div>
<h6 class="mt-3" style="color: #94a3b8;">Usage:</h6>
<div id="view-site-usage">
<p style="color: #94a3b8;"><i class="bi bi-hourglass"></i> Loading usage...</p>
</div>
</div>
<div class="modal-footer" style="border-top: 1px solid #334155;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Edit Site Modal -->
<div class="modal fade" id="editSiteModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
<div class="modal-header" style="border-bottom: 1px solid #334155;">
<h5 class="modal-title" style="color: #60a5fa;">
<i class="bi bi-pencil"></i> Edit Site
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-site-id">
<div class="mb-3">
<label for="edit-site-name" class="form-label">Site Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="edit-site-name" required>
</div>
<div class="mb-3">
<label for="edit-site-description" class="form-label">Description</label>
<textarea class="form-control" id="edit-site-description" rows="3"></textarea>
</div>
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> To manage IP addresses, use the "View" button on the site.
</div>
<div class="alert alert-danger" id="edit-site-error" style="display: none;">
<span id="edit-site-error-message"></span>
</div>
</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" id="save-edit-site-btn">
<i class="bi bi-check-circle"></i> Save Changes
</button>
</div>
</div>
</div>
</div>
<!-- Edit IP Modal -->
<div class="modal fade" id="editIpModal" 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;">
<i class="bi bi-pencil"></i> Edit IP Settings
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-ip-site-id">
<input type="hidden" id="edit-ip-id">
<div class="mb-3">
<label class="form-label" style="color: #e2e8f0;">IP Address</label>
<input type="text" class="form-control" id="edit-ip-address" readonly>
</div>
<div class="mb-3">
<label for="edit-ip-ping" class="form-label" style="color: #e2e8f0;">Expected Ping</label>
<select class="form-select" id="edit-ip-ping">
<option value="null">Not Set</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
<div class="mb-3">
<label for="edit-ip-tcp-ports" class="form-label" style="color: #e2e8f0;">Expected TCP Ports</label>
<input type="text" class="form-control" id="edit-ip-tcp-ports" placeholder="e.g., 22,80,443">
<small style="color: #64748b;">Comma-separated port numbers</small>
</div>
<div class="mb-3">
<label for="edit-ip-udp-ports" class="form-label" style="color: #e2e8f0;">Expected UDP Ports</label>
<input type="text" class="form-control" id="edit-ip-udp-ports" placeholder="e.g., 53,123">
<small style="color: #64748b;">Comma-separated port numbers</small>
</div>
</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" id="save-ip-btn">
<i class="bi bi-check-circle"></i> Save Changes
</button>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
<div class="modal-header" style="border-bottom: 1px solid #334155;">
<h5 class="modal-title" style="color: #f87171;">
<i class="bi bi-exclamation-triangle"></i> Confirm Deletion
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p style="color: #e2e8f0;">Are you sure you want to delete the site:</p>
<p style="color: #60a5fa; font-weight: bold;" id="delete-site-name"></p>
<p style="color: #fbbf24;" id="delete-warning" style="display: none;">
<i class="bi bi-exclamation-circle"></i>
<span id="delete-warning-message"></span>
</p>
</div>
<div class="modal-footer" style="border-top: 1px solid #334155;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirm-delete-btn">
<i class="bi bi-trash"></i> Delete
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Global variables
let sitesData = [];
let selectedSiteForDeletion = null;
let currentViewingSiteId = null; // Track the site ID currently being viewed
let currentSitePage = 1;
// Helper function to clean up any stray modal backdrops
function cleanupModalBackdrops() {
// Remove any leftover backdrops
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
backdrop.remove();
});
// Remove modal-open class from body
document.body.classList.remove('modal-open');
document.body.style.removeProperty('overflow');
document.body.style.removeProperty('padding-right');
}
// Helper function to show a modal (reuses existing instance if available)
function showModal(modalId) {
const modalElement = document.getElementById(modalId);
let modal = bootstrap.Modal.getInstance(modalElement);
if (!modal) {
modal = new bootstrap.Modal(modalElement);
}
modal.show();
return modal;
}
// Format date
function formatDate(timestamp) {
if (!timestamp) return 'Unknown';
const date = new Date(timestamp);
return date.toLocaleString();
}
// Show/hide add IP form methods
function showAddIpMethod(method) {
hideAllAddForms();
if (method === 'cidr') {
document.getElementById('add-cidr-form').style.display = 'block';
} else if (method === 'individual') {
document.getElementById('add-individual-form').style.display = 'block';
} else if (method === 'bulk') {
document.getElementById('add-bulk-form').style.display = 'block';
}
}
function hideAllAddForms() {
document.getElementById('add-cidr-form').style.display = 'none';
document.getElementById('add-individual-form').style.display = 'none';
document.getElementById('add-bulk-form').style.display = 'none';
// Reset forms
document.getElementById('bulk-cidr').value = '';
document.getElementById('individual-ip').value = '';
document.getElementById('bulk-ips').value = '';
}
// Parse port list from comma-separated string
function parsePortList(portString) {
if (!portString || !portString.trim()) return [];
return portString.split(',')
.map(p => parseInt(p.trim()))
.filter(p => !isNaN(p) && p > 0 && p <= 65535);
}
// Load sites from API
async function loadSites() {
try {
document.getElementById('sites-loading').style.display = 'block';
document.getElementById('sites-error').style.display = 'none';
document.getElementById('sites-content').style.display = 'none';
const response = await fetch('/api/sites?all=true');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
sitesData = data.sites || [];
updateStats();
renderSites(sitesData);
document.getElementById('sites-loading').style.display = 'none';
document.getElementById('sites-content').style.display = 'block';
} catch (error) {
console.error('Error loading sites:', error);
document.getElementById('sites-loading').style.display = 'none';
document.getElementById('sites-error').style.display = 'block';
document.getElementById('error-message').textContent = error.message;
}
}
// Update summary stats
function updateStats() {
const totalSites = sitesData.length;
const totalIps = sitesData.reduce((sum, site) => sum + (site.ip_count || 0), 0);
document.getElementById('total-sites').textContent = totalSites;
document.getElementById('total-ips').textContent = totalIps;
document.getElementById('sites-in-use').textContent = '-'; // Will be updated async
// Count sites in use (async)
let sitesInUse = 0;
sitesData.forEach(async (site) => {
try {
const response = await fetch(`/api/sites/${site.id}/usage`);
if (response.ok) {
const data = await response.json();
if (data.count > 0) {
sitesInUse++;
document.getElementById('sites-in-use').textContent = sitesInUse;
}
}
} catch (e) {
console.error('Error checking site usage:', e);
}
});
}
// Render sites table
function renderSites(sites) {
const tbody = document.getElementById('sites-tbody');
const emptyState = document.getElementById('empty-state');
if (sites.length === 0) {
tbody.innerHTML = '';
emptyState.style.display = 'block';
return;
}
emptyState.style.display = 'none';
tbody.innerHTML = sites.map(site => {
const ipCount = site.ip_count || 0;
const ipBadge = ipCount > 0
? `<span class="badge bg-info">${ipCount} IP${ipCount !== 1 ? 's' : ''}</span>`
: '<span class="badge bg-secondary">No IPs</span>';
return `
<tr>
<td><strong style="color: #60a5fa;">${site.name}</strong></td>
<td style="color: #94a3b8;">${site.description || '<em>No description</em>'}</td>
<td>${ipBadge}</td>
<td>${formatDate(site.created_at)}</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-primary" onclick="viewSite(${site.id})" title="View">
<i class="bi bi-eye"></i>
</button>
<button class="btn btn-outline-warning" onclick="editSite(${site.id})" title="Edit">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-outline-danger" onclick="confirmDelete(${site.id}, '${site.name}')" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
`;
}).join('');
}
// Create new site
async function createSite() {
try {
const name = document.getElementById('site-name').value.trim();
const description = document.getElementById('site-description').value.trim();
if (!name) {
showAlert('warning', 'Site name is required');
return;
}
const response = await fetch('/api/sites', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: name,
description: description || null
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
const newSite = await response.json();
// Hide create modal
bootstrap.Modal.getInstance(document.getElementById('createSiteModal')).hide();
// Reset form
document.getElementById('create-site-form').reset();
// Reload sites
await loadSites();
// Show success message and open view modal
showAlert('success', `Site "${name}" created successfully. Now add some IP addresses!`);
// Open the view modal to add IPs
viewSite(newSite.id);
} catch (error) {
console.error('Error creating site:', error);
showAlert('danger', `Error creating site: ${error.message}`);
}
}
// View site details
async function viewSite(siteId) {
try {
currentViewingSiteId = siteId;
const response = await fetch(`/api/sites/${siteId}`);
if (!response.ok) {
throw new Error(`Failed to load site: ${response.statusText}`);
}
const site = await response.json();
document.getElementById('view-site-name').textContent = site.name;
document.getElementById('view-site-description').textContent = site.description || 'No description';
// Load IPs
await loadSiteIps(siteId);
// Load usage
document.getElementById('view-site-usage').innerHTML = '<p style="color: #94a3b8;"><i class="bi bi-hourglass"></i> Loading usage...</p>';
const usageResponse = await fetch(`/api/sites/${siteId}/usage`);
if (usageResponse.ok) {
const usage = await usageResponse.json();
const usageHtml = usage.count > 0
? `<p style="color: #e2e8f0;">Used in <strong>${usage.count}</strong> scan(s)</p>
<ul style="color: #94a3b8;">${usage.scans.map(scan =>
`<li>${scan.title} - ${new Date(scan.timestamp).toLocaleString()} (${scan.status})</li>`
).join('')}</ul>`
: '<p style="color: #94a3b8;"><em>Not used in any scans</em></p>';
document.getElementById('view-site-usage').innerHTML = usageHtml;
}
showModal('viewSiteModal');
} catch (error) {
console.error('Error viewing site:', error);
showAlert('danger', `Error: ${error.message}`);
}
}
// Load IPs for a site
async function loadSiteIps(siteId) {
try {
document.getElementById('ips-loading').style.display = 'block';
const response = await fetch(`/api/sites/${siteId}/ips?per_page=100`);
if (!response.ok) {
throw new Error('Failed to load IPs');
}
const data = await response.json();
const ips = data.ips || [];
document.getElementById('ip-count').textContent = data.total || ips.length;
// Render flat IP table
if (ips.length === 0) {
document.getElementById('view-site-ips').innerHTML = `
<div class="text-center py-4" style="color: #94a3b8;">
<i class="bi bi-hdd-network" style="font-size: 2rem;"></i>
<p class="mt-2"><em>No IPs added yet</em></p>
<p class="text-muted" style="font-size: 0.875rem;">Use the "Add IPs" button above to get started</p>
</div>
`;
} else {
const tableHtml = `
<table class="table table-sm table-hover">
<thead style="position: sticky; top: 0; background-color: #1e293b;">
<tr>
<th>IP Address</th>
<th>Ping</th>
<th>TCP Ports</th>
<th>UDP Ports</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${ips.map(ip => `
<tr>
<td><code>${ip.ip_address}</code></td>
<td>${ip.expected_ping ? '<span class="badge bg-success">Yes</span>' : '<span class="badge bg-secondary">No</span>'}</td>
<td style="font-size: 0.875rem;">${ip.expected_tcp_ports?.length > 0 ? ip.expected_tcp_ports.join(', ') : '-'}</td>
<td style="font-size: 0.875rem;">${ip.expected_udp_ports?.length > 0 ? ip.expected_udp_ports.join(', ') : '-'}</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-primary" onclick="editIp(${siteId}, ${ip.id}, '${ip.ip_address}', ${ip.expected_ping}, ${JSON.stringify(ip.expected_tcp_ports || [])}, ${JSON.stringify(ip.expected_udp_ports || [])})" title="Edit">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-outline-danger" onclick="confirmDeleteIp(${siteId}, ${ip.id}, '${ip.ip_address}')" title="Delete IP">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('view-site-ips').innerHTML = tableHtml;
}
document.getElementById('ips-loading').style.display = 'none';
} catch (error) {
console.error('Error loading IPs:', error);
document.getElementById('view-site-ips').innerHTML = `<p style="color: #f87171;">Error loading IPs: ${error.message}</p>`;
document.getElementById('ips-loading').style.display = 'none';
}
}
// Add IPs from CIDR
async function addIpsFromCidr() {
try {
const cidr = document.getElementById('bulk-cidr').value.trim();
if (!cidr) {
showAlert('warning', 'CIDR is required');
return;
}
const pingValue = document.getElementById('bulk-cidr-ping').value;
const expectedPing = pingValue === 'null' ? null : (pingValue === 'true');
const expectedTcpPorts = parsePortList(document.getElementById('bulk-cidr-tcp-ports').value);
const expectedUdpPorts = parsePortList(document.getElementById('bulk-cidr-udp-ports').value);
const response = await fetch(`/api/sites/${currentViewingSiteId}/ips/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_type: 'cidr',
cidr: cidr,
expected_ping: expectedPing,
expected_tcp_ports: expectedTcpPorts,
expected_udp_ports: expectedUdpPorts
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
const result = await response.json();
hideAllAddForms();
await loadSiteIps(currentViewingSiteId);
await loadSites(); // Refresh stats
showAlert('success', `Added ${result.ip_count} IPs from CIDR ${cidr}${result.ips_skipped.length > 0 ? ` (${result.ips_skipped.length} duplicates skipped)` : ''}`);
} catch (error) {
console.error('Error adding IPs from CIDR:', error);
showAlert('danger', `Error: ${error.message}`);
}
}
// Add individual IP
async function addIndividualIp() {
try {
const ipAddress = document.getElementById('individual-ip').value.trim();
if (!ipAddress) {
showAlert('warning', 'IP address is required');
return;
}
const pingValue = document.getElementById('individual-ping').value;
const expectedPing = pingValue === 'null' ? null : (pingValue === 'true');
const expectedTcpPorts = parsePortList(document.getElementById('individual-tcp-ports').value);
const expectedUdpPorts = parsePortList(document.getElementById('individual-udp-ports').value);
const response = await fetch(`/api/sites/${currentViewingSiteId}/ips`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ip_address: ipAddress,
expected_ping: expectedPing,
expected_tcp_ports: expectedTcpPorts,
expected_udp_ports: expectedUdpPorts
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
hideAllAddForms();
await loadSiteIps(currentViewingSiteId);
await loadSites(); // Refresh stats
showAlert('success', `IP ${ipAddress} added successfully`);
} catch (error) {
console.error('Error adding IP:', error);
showAlert('danger', `Error: ${error.message}`);
}
}
// Add IPs from bulk import
async function addIpsFromBulk() {
try {
const bulkText = document.getElementById('bulk-ips').value.trim();
if (!bulkText) {
showAlert('warning', 'IP list is required');
return;
}
// Parse IPs from text (supports newlines, commas, spaces)
const ipList = bulkText.split(/[\n,\s]+/).map(ip => ip.trim()).filter(ip => ip);
if (ipList.length === 0) {
showAlert('warning', 'No valid IPs found');
return;
}
const pingValue = document.getElementById('bulk-ping').value;
const expectedPing = pingValue === 'null' ? null : (pingValue === 'true');
const expectedTcpPorts = parsePortList(document.getElementById('bulk-tcp-ports').value);
const expectedUdpPorts = parsePortList(document.getElementById('bulk-udp-ports').value);
const response = await fetch(`/api/sites/${currentViewingSiteId}/ips/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_type: 'list',
ips: ipList,
expected_ping: expectedPing,
expected_tcp_ports: expectedTcpPorts,
expected_udp_ports: expectedUdpPorts
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
const result = await response.json();
hideAllAddForms();
await loadSiteIps(currentViewingSiteId);
await loadSites(); // Refresh stats
let message = `Added ${result.ip_count} IPs`;
if (result.ips_skipped.length > 0) message += ` (${result.ips_skipped.length} duplicates skipped)`;
if (result.errors.length > 0) message += ` (${result.errors.length} errors)`;
showAlert(result.errors.length > 0 ? 'warning' : 'success', message);
} catch (error) {
console.error('Error adding IPs from bulk:', error);
showAlert('danger', `Error: ${error.message}`);
}
}
// Confirm delete IP
function confirmDeleteIp(siteId, ipId, ipAddress) {
if (confirm(`Are you sure you want to delete IP ${ipAddress}?`)) {
deleteIp(siteId, ipId);
}
}
// Delete IP
async function deleteIp(siteId, ipId) {
try {
const response = await fetch(`/api/sites/${siteId}/ips/${ipId}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
await loadSiteIps(siteId);
await loadSites(); // Refresh stats
showAlert('success', 'IP deleted successfully');
} catch (error) {
console.error('Error deleting IP:', error);
showAlert('danger', `Error deleting IP: ${error.message}`);
}
}
// Edit site
async function editSite(siteId) {
try {
const response = await fetch(`/api/sites/${siteId}`);
if (!response.ok) throw new Error('Failed to load site');
const data = await response.json();
// Populate form
document.getElementById('edit-site-id').value = data.id;
document.getElementById('edit-site-name').value = data.name;
document.getElementById('edit-site-description').value = data.description || '';
// Hide error
document.getElementById('edit-site-error').style.display = 'none';
// Show modal
showModal('editSiteModal');
} catch (error) {
console.error('Error loading site:', error);
showAlert('danger', `Error loading site: ${error.message}`);
}
}
// Save edited site
document.getElementById('save-edit-site-btn').addEventListener('click', async function() {
const siteId = document.getElementById('edit-site-id').value;
const name = document.getElementById('edit-site-name').value.trim();
const description = document.getElementById('edit-site-description').value.trim();
if (!name) {
document.getElementById('edit-site-error-message').textContent = 'Site name is required';
document.getElementById('edit-site-error').style.display = 'block';
return;
}
try {
const response = await fetch(`/api/sites/${siteId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description: description || null })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to update site');
}
// Close modal
bootstrap.Modal.getInstance(document.getElementById('editSiteModal')).hide();
// Reload sites
await loadSites();
showAlert('success', `Site "${name}" updated successfully`);
} catch (error) {
console.error('Error updating site:', error);
document.getElementById('edit-site-error-message').textContent = error.message;
document.getElementById('edit-site-error').style.display = 'block';
}
});
// Confirm delete
async function confirmDelete(siteId, siteName) {
selectedSiteForDeletion = siteId;
document.getElementById('delete-site-name').textContent = siteName;
// Check if site is in use
try {
const response = await fetch(`/api/sites/${siteId}/usage`);
if (response.ok) {
const usage = await response.json();
const warningDiv = document.getElementById('delete-warning');
const warningMsg = document.getElementById('delete-warning-message');
const deleteBtn = document.getElementById('confirm-delete-btn');
if (usage.count > 0) {
warningDiv.style.display = 'block';
warningMsg.textContent = `This site is used in ${usage.count} scan(s) and cannot be deleted.`;
deleteBtn.disabled = true;
deleteBtn.classList.add('disabled');
} else {
warningDiv.style.display = 'none';
deleteBtn.disabled = false;
deleteBtn.classList.remove('disabled');
}
}
} catch (e) {
console.error('Error checking site usage:', e);
}
showModal('deleteModal');
}
// Delete site
async function deleteSite() {
if (!selectedSiteForDeletion) return;
try {
const response = await fetch(`/api/sites/${selectedSiteForDeletion}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
// Hide modal
bootstrap.Modal.getInstance(document.getElementById('deleteModal')).hide();
// Reload sites
await loadSites();
// Show success message
showAlert('success', `Site deleted successfully`);
} catch (error) {
console.error('Error deleting site:', error);
showAlert('danger', `Error deleting site: ${error.message}`);
}
}
// Edit IP settings
function editIp(siteId, ipId, ipAddress, expectedPing, expectedTcpPorts, expectedUdpPorts) {
// Populate modal
document.getElementById('edit-ip-site-id').value = siteId;
document.getElementById('edit-ip-id').value = ipId;
document.getElementById('edit-ip-address').value = ipAddress;
// Set ping value
const pingValue = expectedPing === null ? 'null' : (expectedPing ? 'true' : 'false');
document.getElementById('edit-ip-ping').value = pingValue;
// Set ports
document.getElementById('edit-ip-tcp-ports').value = expectedTcpPorts && expectedTcpPorts.length > 0 ? expectedTcpPorts.join(',') : '';
document.getElementById('edit-ip-udp-ports').value = expectedUdpPorts && expectedUdpPorts.length > 0 ? expectedUdpPorts.join(',') : '';
// Show modal
showModal('editIpModal');
}
// Save IP settings
async function saveIp() {
try {
const siteId = document.getElementById('edit-ip-site-id').value;
const ipId = document.getElementById('edit-ip-id').value;
const ipAddress = document.getElementById('edit-ip-address').value;
const pingValue = document.getElementById('edit-ip-ping').value;
const expectedPing = pingValue === 'null' ? null : (pingValue === 'true');
const expectedTcpPorts = parsePortList(document.getElementById('edit-ip-tcp-ports').value);
const expectedUdpPorts = parsePortList(document.getElementById('edit-ip-udp-ports').value);
const response = await fetch(`/api/sites/${siteId}/ips/${ipId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
expected_ping: expectedPing,
expected_tcp_ports: expectedTcpPorts,
expected_udp_ports: expectedUdpPorts
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
// Hide modal
bootstrap.Modal.getInstance(document.getElementById('editIpModal')).hide();
// Reload the view site modal
await viewSite(siteId);
showAlert('success', `IP ${ipAddress} updated successfully`);
} catch (error) {
console.error('Error saving IP:', error);
showAlert('danger', `Error saving IP: ${error.message}`);
}
}
// Show alert
function showAlert(type, message) {
const alertHtml = `
<div class="alert alert-${type} alert-dismissible fade show mt-3" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
const container = document.querySelector('.container-fluid');
container.insertAdjacentHTML('afterbegin', alertHtml);
// Auto-dismiss after 5 seconds
setTimeout(() => {
const alert = container.querySelector('.alert');
if (alert) {
bootstrap.Alert.getInstance(alert)?.close();
}
}, 5000);
}
// Search filter
document.getElementById('search-input').addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
if (!searchTerm) {
renderSites(sitesData);
return;
}
const filtered = sitesData.filter(site =>
site.name.toLowerCase().includes(searchTerm) ||
(site.description && site.description.toLowerCase().includes(searchTerm))
);
renderSites(filtered);
});
// Setup delete button
document.getElementById('confirm-delete-btn').addEventListener('click', deleteSite);
// Setup save IP button
document.getElementById('save-ip-btn').addEventListener('click', saveIp);
// Add cleanup listeners to all modals
['createSiteModal', 'viewSiteModal', 'editSiteModal', 'deleteModal', 'editIpModal'].forEach(modalId => {
const modalElement = document.getElementById(modalId);
modalElement.addEventListener('hidden.bs.modal', function() {
// Clean up any stray backdrops when modal is fully hidden
setTimeout(cleanupModalBackdrops, 100);
});
});
// Load sites on page load
document.addEventListener('DOMContentLoaded', loadSites);
</script>
<style>
.stat-card {
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
border: 1px solid #475569;
border-radius: 10px;
padding: 20px;
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: #60a5fa;
}
.stat-label {
color: #94a3b8;
font-size: 0.875rem;
margin-top: 5px;
}
.card {
background-color: #1e293b;
border: 1px solid #334155;
}
.card-header {
background-color: #334155;
border-bottom: 1px solid #475569;
}
.table {
color: #e2e8f0;
}
.table thead th {
color: #94a3b8;
border-bottom: 1px solid #475569;
}
.table tbody tr {
border-bottom: 1px solid #334155;
}
.table tbody tr:hover {
background-color: #334155;
}
</style>
{% endblock %}