stage 1 of doing new cidrs/ site setup

This commit is contained in:
2025-11-19 13:39:27 -06:00
parent 4a4c33a10b
commit 034f146fa1
16 changed files with 3998 additions and 609 deletions

View File

@@ -53,6 +53,10 @@
<a class="nav-link {% if request.endpoint and 'schedule' in request.endpoint %}active{% endif %}"
href="{{ url_for('main.schedules') }}">Schedules</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.sites' %}active{% endif %}"
href="{{ url_for('main.sites') }}">Sites</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'config' in request.endpoint %}active{% endif %}"
href="{{ url_for('main.configs') }}">Configs</a>

View File

@@ -1,20 +1,16 @@
{% extends "base.html" %}
{% block title %}Configuration Files - SneakyScanner{% endblock %}
{% block extra_styles %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/config-manager.css') }}">
{% endblock %}
{% block title %}Scan Configurations - 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;">Configuration Files</h1>
<h1 style="color: #60a5fa;">Scan Configurations</h1>
<div>
<a href="{{ url_for('main.upload_config') }}" class="btn btn-primary">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createConfigModal">
<i class="bi bi-plus-circle"></i> Create New Config
</a>
</button>
</div>
</div>
</div>
@@ -30,14 +26,14 @@
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="configs-in-use">-</div>
<div class="stat-label">In Use by Schedules</div>
<div class="stat-value" id="total-sites-used">-</div>
<div class="stat-label">Total Sites Referenced</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="total-size">-</div>
<div class="stat-label">Total Size</div>
<div class="stat-value" id="recent-updates">-</div>
<div class="stat-label">Updated This Week</div>
</div>
</div>
</div>
@@ -68,11 +64,10 @@
<table class="table table-hover">
<thead>
<tr>
<th>Filename</th>
<th>Title</th>
<th>Created</th>
<th>Size</th>
<th>Used By</th>
<th>Description</th>
<th>Sites</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
@@ -82,12 +77,12 @@
</table>
</div>
<div id="empty-state" style="display: none;" class="text-center py-5">
<i class="bi bi-file-earmark-text" style="font-size: 3rem; color: #64748b;"></i>
<h5 class="mt-3 text-muted">No configuration files</h5>
<p class="text-muted">Create your first config to define scan targets</p>
<a href="{{ url_for('main.upload_config') }}" class="btn btn-primary mt-2">
<i class="bi bi-gear" style="font-size: 3rem; color: #64748b;"></i>
<h5 class="mt-3 text-muted">No configurations defined</h5>
<p class="text-muted">Create your first scan configuration</p>
<button class="btn btn-primary mt-2" data-bs-toggle="modal" data-bs-target="#createConfigModal">
<i class="bi bi-plus-circle"></i> Create Config
</a>
</button>
</div>
</div>
</div>
@@ -95,23 +90,141 @@
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<!-- Create Config Modal -->
<div class="modal fade" id="createConfigModal" 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: #f87171;">
<i class="bi bi-exclamation-triangle"></i> Confirm Deletion
<h5 class="modal-title" style="color: #60a5fa;">
<i class="bi bi-plus-circle"></i> Create New Configuration
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p style="color: #e2e8f0;">Are you sure you want to delete the config file:</p>
<p style="color: #60a5fa; font-weight: bold;" id="delete-config-name"></p>
<p style="color: #fbbf24;" id="delete-warning-schedules" style="display: none;">
<i class="bi bi-exclamation-circle"></i>
This config is used by schedules and cannot be deleted.
</p>
<form id="create-config-form">
<div class="mb-3">
<label for="config-title" class="form-label">Title <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="config-title" required
placeholder="e.g., Production Weekly Scan">
</div>
<div class="mb-3">
<label for="config-description" class="form-label">Description</label>
<textarea class="form-control" id="config-description" rows="3"
placeholder="Optional description of this configuration"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Sites <span class="text-danger">*</span></label>
<div id="sites-loading-modal" class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<span class="ms-2 text-muted">Loading available sites...</span>
</div>
<div id="sites-list" style="display: none;">
<!-- Populated by JavaScript -->
</div>
<small class="form-text text-muted">Select at least one site to include in this configuration</small>
</div>
<div class="alert alert-danger" id="create-config-error" style="display: none;">
<span id="create-config-error-message"></span>
</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" id="create-config-btn">
<i class="bi bi-check-circle"></i> Create Configuration
</button>
</div>
</div>
</div>
</div>
<!-- Edit Config Modal -->
<div class="modal fade" id="editConfigModal" 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 Configuration
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="edit-config-form">
<input type="hidden" id="edit-config-id">
<div class="mb-3">
<label for="edit-config-title" class="form-label">Title <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="edit-config-title" required>
</div>
<div class="mb-3">
<label for="edit-config-description" class="form-label">Description</label>
<textarea class="form-control" id="edit-config-description" rows="3"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Sites <span class="text-danger">*</span></label>
<div id="edit-sites-list">
<!-- Populated by JavaScript -->
</div>
</div>
<div class="alert alert-danger" id="edit-config-error" style="display: none;">
<span id="edit-config-error-message"></span>
</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" id="edit-config-btn">
<i class="bi bi-check-circle"></i> Save Changes
</button>
</div>
</div>
</div>
</div>
<!-- View Config Modal -->
<div class="modal fade" id="viewConfigModal" 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-eye"></i> Configuration Details
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="view-config-content">
<!-- Populated by JavaScript -->
</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>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteConfigModal" 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: #ef4444;">
<i class="bi bi-trash"></i> Delete Configuration
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete configuration <strong id="delete-config-name"></strong>?</p>
<p class="text-warning"><i class="bi bi-exclamation-triangle"></i> This action cannot be undone.</p>
<input type="hidden" id="delete-config-id">
</div>
<div class="modal-footer" style="border-top: 1px solid #334155;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
@@ -123,76 +236,94 @@
</div>
</div>
<!-- View Config Modal -->
<div class="modal fade" id="viewModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
<div class="modal-header" style="border-bottom: 1px solid #334155;">
<h5 class="modal-title" style="color: #60a5fa;">
<i class="bi bi-file-earmark-code"></i> Config File Details
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<h6 style="color: #94a3b8;">Filename: <span id="view-filename" style="color: #e2e8f0;"></span></h6>
<h6 class="mt-3" style="color: #94a3b8;">Content:</h6>
<pre style="background-color: #0f172a; border: 1px solid #334155; padding: 15px; border-radius: 5px; max-height: 400px; overflow-y: auto;"><code id="view-content" style="color: #e2e8f0;"></code></pre>
</div>
<div class="modal-footer" style="border-top: 1px solid #334155;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<a id="download-link" href="#" class="btn btn-primary">
<i class="bi bi-download"></i> Download
</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Global variables
let configsData = [];
let selectedConfigForDeletion = null;
// Global state
let allConfigs = [];
let allSites = [];
// Format file size
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
// Load data on page load
document.addEventListener('DOMContentLoaded', function() {
loadSites();
loadConfigs();
});
// Format date
function formatDate(timestamp) {
if (!timestamp) return 'Unknown';
const date = new Date(timestamp);
return date.toLocaleString();
}
// Load configs from API
async function loadConfigs() {
// Load all sites
async function loadSites() {
try {
document.getElementById('configs-loading').style.display = 'block';
document.getElementById('configs-error').style.display = 'none';
document.getElementById('configs-content').style.display = 'none';
const response = await fetch('/api/configs');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const response = await fetch('/api/sites?all=true');
if (!response.ok) throw new Error('Failed to load sites');
const data = await response.json();
configsData = data.configs || [];
allSites = data.sites || [];
renderSitesCheckboxes();
} catch (error) {
console.error('Error loading sites:', error);
document.getElementById('sites-loading-modal').innerHTML =
'<div class="alert alert-danger">Failed to load sites</div>';
}
}
// Render sites checkboxes
function renderSitesCheckboxes(selectedIds = [], isEditMode = false) {
const container = isEditMode ? document.getElementById('edit-sites-list') : document.getElementById('sites-list');
if (!container) return;
if (allSites.length === 0) {
const message = '<div class="alert alert-info">No sites available. <a href="/sites">Create a site first</a>.</div>';
container.innerHTML = message;
if (!isEditMode) {
document.getElementById('sites-loading-modal').style.display = 'none';
container.style.display = 'block';
}
return;
}
const prefix = isEditMode ? 'edit-site' : 'site';
const checkboxClass = isEditMode ? 'edit-site-checkbox' : 'site-checkbox';
let html = '<div style="max-height: 300px; overflow-y: auto;">';
allSites.forEach(site => {
const isChecked = selectedIds.includes(site.id);
html += `
<div class="form-check">
<input class="form-check-input ${checkboxClass}" type="checkbox" value="${site.id}"
id="${prefix}-${site.id}" ${isChecked ? 'checked' : ''}>
<label class="form-check-label" for="${prefix}-${site.id}">
${escapeHtml(site.name)}
<small class="text-muted">(${site.cidr_count || 0} CIDR${site.cidr_count !== 1 ? 's' : ''})</small>
</label>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
if (!isEditMode) {
document.getElementById('sites-loading-modal').style.display = 'none';
container.style.display = 'block';
}
}
// Load all configs
async function loadConfigs() {
try {
const response = await fetch('/api/configs');
if (!response.ok) throw new Error('Failed to load configs');
const data = await response.json();
allConfigs = data.configs || [];
renderConfigs();
updateStats();
renderConfigs(configsData);
document.getElementById('configs-loading').style.display = 'none';
document.getElementById('configs-content').style.display = 'block';
} catch (error) {
console.error('Error loading configs:', error);
document.getElementById('configs-loading').style.display = 'none';
@@ -201,177 +332,249 @@ async function loadConfigs() {
}
}
// Update summary stats
function updateStats() {
const totalConfigs = configsData.length;
const configsInUse = configsData.filter(c => c.used_by_schedules && c.used_by_schedules.length > 0).length;
const totalSize = configsData.reduce((sum, c) => sum + (c.size_bytes || 0), 0);
document.getElementById('total-configs').textContent = totalConfigs;
document.getElementById('configs-in-use').textContent = configsInUse;
document.getElementById('total-size').textContent = formatFileSize(totalSize);
}
// Render configs table
function renderConfigs(configs) {
function renderConfigs(filter = '') {
const tbody = document.getElementById('configs-tbody');
const emptyState = document.getElementById('empty-state');
if (configs.length === 0) {
const filteredConfigs = filter
? allConfigs.filter(c =>
c.title.toLowerCase().includes(filter.toLowerCase()) ||
(c.description && c.description.toLowerCase().includes(filter.toLowerCase()))
)
: allConfigs;
if (filteredConfigs.length === 0) {
tbody.innerHTML = '';
emptyState.style.display = 'block';
return;
}
emptyState.style.display = 'none';
tbody.innerHTML = configs.map(config => {
const usedByBadge = config.used_by_schedules && config.used_by_schedules.length > 0
? `<span class="badge bg-info" title="${config.used_by_schedules.join(', ')}">${config.used_by_schedules.length} schedule(s)</span>`
: '<span class="badge bg-secondary">Not used</span>';
return `
<tr>
<td><code>${config.filename}</code></td>
<td>${config.title || config.filename}</td>
<td>${formatDate(config.created_at)}</td>
<td>${formatFileSize(config.size_bytes || 0)}</td>
<td>${usedByBadge}</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-primary" onclick="viewConfig('${config.filename}')" title="View">
<i class="bi bi-eye"></i>
</button>
<a href="/configs/edit/${config.filename}" class="btn btn-outline-info" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a href="/api/configs/${config.filename}/download" class="btn btn-outline-success" title="Download">
<i class="bi bi-download"></i>
</a>
<button class="btn btn-outline-danger" onclick="confirmDelete('${config.filename}', ${config.used_by_schedules.length > 0})" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
`;
}).join('');
tbody.innerHTML = filteredConfigs.map(config => `
<tr>
<td><strong>${escapeHtml(config.title)}</strong></td>
<td>${config.description ? escapeHtml(config.description) : '<span class="text-muted">-</span>'}</td>
<td>
<span class="badge bg-primary">${config.site_count} site${config.site_count !== 1 ? 's' : ''}</span>
</td>
<td>${formatDate(config.updated_at)}</td>
<td>
<button class="btn btn-sm btn-info" onclick="viewConfig(${config.id})" title="View">
<i class="bi bi-eye"></i>
</button>
<button class="btn btn-sm btn-warning" onclick="editConfig(${config.id})" title="Edit">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="deleteConfig(${config.id}, '${escapeHtml(config.title).replace(/'/g, "\\'")}');" title="Delete">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('');
}
// View config details
async function viewConfig(filename) {
try {
const response = await fetch(`/api/configs/${filename}`);
if (!response.ok) {
throw new Error(`Failed to load config: ${response.statusText}`);
}
// Update stats
function updateStats() {
document.getElementById('total-configs').textContent = allConfigs.length;
const data = await response.json();
const uniqueSites = new Set();
allConfigs.forEach(c => c.sites.forEach(s => uniqueSites.add(s.id)));
document.getElementById('total-sites-used').textContent = uniqueSites.size;
document.getElementById('view-filename').textContent = data.filename;
document.getElementById('view-content').textContent = data.content;
document.getElementById('download-link').href = `/api/configs/${filename}/download`;
new bootstrap.Modal(document.getElementById('viewModal')).show();
} catch (error) {
console.error('Error viewing config:', error);
alert(`Error: ${error.message}`);
}
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
const recentUpdates = allConfigs.filter(c => new Date(c.updated_at) > oneWeekAgo).length;
document.getElementById('recent-updates').textContent = recentUpdates;
}
// Confirm delete
function confirmDelete(filename, isInUse) {
selectedConfigForDeletion = filename;
document.getElementById('delete-config-name').textContent = filename;
const warningDiv = document.getElementById('delete-warning-schedules');
const deleteBtn = document.getElementById('confirm-delete-btn');
if (isInUse) {
warningDiv.style.display = 'block';
deleteBtn.disabled = true;
deleteBtn.classList.add('disabled');
} else {
warningDiv.style.display = 'none';
deleteBtn.disabled = false;
deleteBtn.classList.remove('disabled');
}
new bootstrap.Modal(document.getElementById('deleteModal')).show();
}
// Delete config
async function deleteConfig() {
if (!selectedConfigForDeletion) return;
try {
const response = await fetch(`/api/configs/${selectedConfigForDeletion}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
// Hide modal
bootstrap.Modal.getInstance(document.getElementById('deleteModal')).hide();
// Reload configs
await loadConfigs();
// Show success message
showAlert('success', `Config "${selectedConfigForDeletion}" deleted successfully`);
} catch (error) {
console.error('Error deleting config:', error);
showAlert('danger', `Error deleting config: ${error.message}`);
}
}
// Show alert
function showAlert(type, message) {
const alertHtml = `
<div class="alert alert-${type} alert-dismissible fade show mt-3" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
const container = document.querySelector('.container-fluid');
container.insertAdjacentHTML('afterbegin', alertHtml);
// Auto-dismiss after 5 seconds
setTimeout(() => {
const alert = container.querySelector('.alert');
if (alert) {
bootstrap.Alert.getInstance(alert)?.close();
}
}, 5000);
}
// Search filter
// Search functionality
document.getElementById('search-input').addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
renderConfigs(e.target.value);
});
if (!searchTerm) {
renderConfigs(configsData);
// Create config
document.getElementById('create-config-btn').addEventListener('click', async function() {
const title = document.getElementById('config-title').value.trim();
const description = document.getElementById('config-description').value.trim();
const siteCheckboxes = document.querySelectorAll('.site-checkbox:checked');
const siteIds = Array.from(siteCheckboxes).map(cb => parseInt(cb.value));
if (!title) {
showError('create-config-error', 'Title is required');
return;
}
const filtered = configsData.filter(config =>
config.filename.toLowerCase().includes(searchTerm) ||
(config.title && config.title.toLowerCase().includes(searchTerm))
);
if (siteIds.length === 0) {
showError('create-config-error', 'At least one site must be selected');
return;
}
renderConfigs(filtered);
try {
const response = await fetch('/api/configs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description: description || null, site_ids: siteIds })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to create config');
}
// Close modal and reload
bootstrap.Modal.getInstance(document.getElementById('createConfigModal')).hide();
document.getElementById('create-config-form').reset();
renderSitesCheckboxes(); // Reset checkboxes
await loadConfigs();
} catch (error) {
showError('create-config-error', error.message);
}
});
// Setup delete button
document.getElementById('confirm-delete-btn').addEventListener('click', deleteConfig);
// View config
async function viewConfig(id) {
try {
const response = await fetch(`/api/configs/${id}`);
if (!response.ok) throw new Error('Failed to load config');
// Load configs on page load
document.addEventListener('DOMContentLoaded', loadConfigs);
const config = await response.json();
let html = `
<div class="mb-3">
<strong>Title:</strong> ${escapeHtml(config.title)}
</div>
<div class="mb-3">
<strong>Description:</strong> ${config.description ? escapeHtml(config.description) : '<span class="text-muted">None</span>'}
</div>
<div class="mb-3">
<strong>Sites (${config.site_count}):</strong>
<ul class="mt-2">
${config.sites.map(site => `
<li>${escapeHtml(site.name)} <small class="text-muted">(${site.cidr_count} CIDR${site.cidr_count !== 1 ? 's' : ''})</small></li>
`).join('')}
</ul>
</div>
<div class="mb-3">
<strong>Created:</strong> ${formatDate(config.created_at)}
</div>
<div class="mb-3">
<strong>Last Updated:</strong> ${formatDate(config.updated_at)}
</div>
`;
document.getElementById('view-config-content').innerHTML = html;
new bootstrap.Modal(document.getElementById('viewConfigModal')).show();
} catch (error) {
alert('Error loading config: ' + error.message);
}
}
// Edit config
async function editConfig(id) {
try {
const response = await fetch(`/api/configs/${id}`);
if (!response.ok) throw new Error('Failed to load config');
const config = await response.json();
document.getElementById('edit-config-id').value = config.id;
document.getElementById('edit-config-title').value = config.title;
document.getElementById('edit-config-description').value = config.description || '';
const selectedIds = config.sites.map(s => s.id);
renderSitesCheckboxes(selectedIds, true); // true = isEditMode
new bootstrap.Modal(document.getElementById('editConfigModal')).show();
} catch (error) {
alert('Error loading config: ' + error.message);
}
}
// Save edited config
document.getElementById('edit-config-btn').addEventListener('click', async function() {
const id = document.getElementById('edit-config-id').value;
const title = document.getElementById('edit-config-title').value.trim();
const description = document.getElementById('edit-config-description').value.trim();
const siteCheckboxes = document.querySelectorAll('.edit-site-checkbox:checked');
const siteIds = Array.from(siteCheckboxes).map(cb => parseInt(cb.value));
if (!title) {
showError('edit-config-error', 'Title is required');
return;
}
if (siteIds.length === 0) {
showError('edit-config-error', 'At least one site must be selected');
return;
}
try {
const response = await fetch(`/api/configs/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description: description || null, site_ids: siteIds })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to update config');
}
// Close modal and reload
bootstrap.Modal.getInstance(document.getElementById('editConfigModal')).hide();
await loadConfigs();
} catch (error) {
showError('edit-config-error', error.message);
}
});
// Delete config
function deleteConfig(id, name) {
document.getElementById('delete-config-id').value = id;
document.getElementById('delete-config-name').textContent = name;
new bootstrap.Modal(document.getElementById('deleteConfigModal')).show();
}
// Confirm delete
document.getElementById('confirm-delete-btn').addEventListener('click', async function() {
const id = document.getElementById('delete-config-id').value;
try {
const response = await fetch(`/api/configs/${id}`, { method: 'DELETE' });
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to delete config');
}
// Close modal and reload
bootstrap.Modal.getInstance(document.getElementById('deleteConfigModal')).hide();
await loadConfigs();
} catch (error) {
alert('Error deleting config: ' + error.message);
}
});
// Utility functions
function showError(elementId, message) {
const errorEl = document.getElementById(elementId);
const messageEl = document.getElementById(elementId + '-message');
messageEl.textContent = message;
errorEl.style.display = 'block';
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
</script>
{% endblock %}

View File

@@ -0,0 +1,775 @@
{% 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-cidrs">-</div>
<div class="stat-label">Total CIDRs</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>CIDRs</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 group CIDR ranges</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="2"
placeholder="Optional description"></textarea>
</div>
<div class="mb-3">
<label class="form-label" style="color: #e2e8f0;">CIDR Ranges *</label>
<div id="cidrs-container">
<!-- CIDR inputs will be added here -->
</div>
<button type="button" class="btn btn-sm btn-outline-primary mt-2" onclick="addCidrInput()">
<i class="bi bi-plus"></i> Add CIDR
</button>
</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>
<h6 class="mt-3" style="color: #94a3b8;">CIDR Ranges:</h6>
<div id="view-site-cidrs" class="table-responsive">
<!-- Will be populated -->
</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 edit CIDRs and IP ranges, please delete and recreate 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>
<!-- 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 cidrInputCounter = 0;
// Format date
function formatDate(timestamp) {
if (!timestamp) return 'Unknown';
const date = new Date(timestamp);
return date.toLocaleString();
}
// Add CIDR input field
function addCidrInput(cidr = '', expectedPing = false, expectedTcpPorts = [], expectedUdpPorts = []) {
const container = document.getElementById('cidrs-container');
const id = cidrInputCounter++;
const cidrHtml = `
<div class="cidr-input-group mb-2 p-3" style="background-color: #0f172a; border: 1px solid #334155; border-radius: 5px;" id="cidr-${id}">
<div class="row">
<div class="col-md-6">
<label class="form-label" style="color: #94a3b8; font-size: 0.875rem;">CIDR *</label>
<input type="text" class="form-control form-control-sm cidr-value" placeholder="e.g., 10.0.0.0/24" value="${cidr}" required>
</div>
<div class="col-md-3">
<label class="form-label" style="color: #94a3b8; font-size: 0.875rem;">Expect Ping</label>
<select class="form-select form-select-sm cidr-ping">
<option value="">Default (No)</option>
<option value="true" ${expectedPing ? 'selected' : ''}>Yes</option>
<option value="false" ${expectedPing === false ? 'selected' : ''}>No</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label" style="color: #94a3b8; font-size: 0.875rem;">Actions</label>
<button type="button" class="btn btn-sm btn-outline-danger w-100" onclick="removeCidrInput(${id})">
<i class="bi bi-trash"></i> Remove
</button>
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label class="form-label" style="color: #94a3b8; font-size: 0.875rem;">Expected TCP Ports</label>
<input type="text" class="form-control form-control-sm cidr-tcp-ports" placeholder="e.g., 22,80,443" value="${expectedTcpPorts.join(',')}">
<small style="color: #64748b;">Comma-separated port numbers</small>
</div>
<div class="col-md-6">
<label class="form-label" style="color: #94a3b8; font-size: 0.875rem;">Expected UDP Ports</label>
<input type="text" class="form-control form-control-sm cidr-udp-ports" placeholder="e.g., 53,123" value="${expectedUdpPorts.join(',')}">
<small style="color: #64748b;">Comma-separated port numbers</small>
</div>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', cidrHtml);
}
// Remove CIDR input field
function removeCidrInput(id) {
const element = document.getElementById(`cidr-${id}`);
if (element) {
element.remove();
}
}
// 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 totalCidrs = sitesData.reduce((sum, site) => sum + (site.cidrs?.length || 0), 0);
document.getElementById('total-sites').textContent = totalSites;
document.getElementById('total-cidrs').textContent = totalCidrs;
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 cidrCount = site.cidrs?.length || 0;
const cidrBadge = cidrCount > 0
? `<span class="badge bg-info">${cidrCount} CIDR${cidrCount !== 1 ? 's' : ''}</span>`
: '<span class="badge bg-secondary">No CIDRs</span>';
return `
<tr>
<td><strong style="color: #60a5fa;">${site.name}</strong></td>
<td style="color: #94a3b8;">${site.description || '<em>No description</em>'}</td>
<td>${cidrBadge}</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;
}
// Collect CIDRs
const cidrGroups = document.querySelectorAll('.cidr-input-group');
const cidrs = [];
cidrGroups.forEach(group => {
const cidrValue = group.querySelector('.cidr-value').value.trim();
if (!cidrValue) return;
const pingValue = group.querySelector('.cidr-ping').value;
const tcpPorts = parsePortList(group.querySelector('.cidr-tcp-ports').value);
const udpPorts = parsePortList(group.querySelector('.cidr-udp-ports').value);
cidrs.push({
cidr: cidrValue,
expected_ping: pingValue === 'true' ? true : (pingValue === 'false' ? false : null),
expected_tcp_ports: tcpPorts,
expected_udp_ports: udpPorts
});
});
if (cidrs.length === 0) {
showAlert('warning', 'At least one CIDR is required');
return;
}
const response = await fetch('/api/sites', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: name,
description: description || null,
cidrs: cidrs
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
// Hide modal
bootstrap.Modal.getInstance(document.getElementById('createSiteModal')).hide();
// Reset form
document.getElementById('create-site-form').reset();
document.getElementById('cidrs-container').innerHTML = '';
cidrInputCounter = 0;
// Reload sites
await loadSites();
// Show success message
showAlert('success', `Site "${name}" created successfully`);
} catch (error) {
console.error('Error creating site:', error);
showAlert('danger', `Error creating site: ${error.message}`);
}
}
// View site details
async function viewSite(siteId) {
try {
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';
// Render CIDRs
const cidrsHtml = site.cidrs && site.cidrs.length > 0
? `<table class="table table-sm">
<thead>
<tr>
<th>CIDR</th>
<th>Ping</th>
<th>TCP Ports</th>
<th>UDP Ports</th>
</tr>
</thead>
<tbody>
${site.cidrs.map(cidr => `
<tr>
<td><code>${cidr.cidr}</code></td>
<td>${cidr.expected_ping ? '<span class="badge bg-success">Yes</span>' : '<span class="badge bg-secondary">No</span>'}</td>
<td>${cidr.expected_tcp_ports?.length > 0 ? cidr.expected_tcp_ports.join(', ') : '-'}</td>
<td>${cidr.expected_udp_ports?.length > 0 ? cidr.expected_udp_ports.join(', ') : '-'}</td>
</tr>
`).join('')}
</tbody>
</table>`
: '<p style="color: #94a3b8;"><em>No CIDRs defined</em></p>';
document.getElementById('view-site-cidrs').innerHTML = cidrsHtml;
// 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;
}
new bootstrap.Modal(document.getElementById('viewSiteModal')).show();
} catch (error) {
console.error('Error viewing site:', error);
showAlert('danger', `Error: ${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
new bootstrap.Modal(document.getElementById('editSiteModal')).show();
} 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);
}
new bootstrap.Modal(document.getElementById('deleteModal')).show();
}
// 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}`);
}
}
// 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);
// Initialize modal
document.getElementById('createSiteModal').addEventListener('show.bs.modal', function() {
// Reset form and add one CIDR input
document.getElementById('create-site-form').reset();
document.getElementById('cidrs-container').innerHTML = '';
cidrInputCounter = 0;
addCidrInput();
});
// 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 %}