restructure of dirs, huge docs update
This commit is contained in:
0
app/web/__init__.py
Normal file
0
app/web/__init__.py
Normal file
0
app/web/api/__init__.py
Normal file
0
app/web/api/__init__.py
Normal file
144
app/web/api/alerts.py
Normal file
144
app/web/api/alerts.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Alerts API blueprint.
|
||||
|
||||
Handles endpoints for viewing alert history and managing alert rules.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from web.auth.decorators import api_auth_required
|
||||
|
||||
bp = Blueprint('alerts', __name__)
|
||||
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
@api_auth_required
|
||||
def list_alerts():
|
||||
"""
|
||||
List recent alerts.
|
||||
|
||||
Query params:
|
||||
page: Page number (default: 1)
|
||||
per_page: Items per page (default: 20)
|
||||
alert_type: Filter by alert type
|
||||
severity: Filter by severity (info, warning, critical)
|
||||
start_date: Filter alerts after this date
|
||||
end_date: Filter alerts before this date
|
||||
|
||||
Returns:
|
||||
JSON response with alerts list
|
||||
"""
|
||||
# TODO: Implement in Phase 4
|
||||
return jsonify({
|
||||
'alerts': [],
|
||||
'total': 0,
|
||||
'page': 1,
|
||||
'per_page': 20,
|
||||
'message': 'Alerts list endpoint - to be implemented in Phase 4'
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/rules', methods=['GET'])
|
||||
@api_auth_required
|
||||
def list_alert_rules():
|
||||
"""
|
||||
List all alert rules.
|
||||
|
||||
Returns:
|
||||
JSON response with alert rules
|
||||
"""
|
||||
# TODO: Implement in Phase 4
|
||||
return jsonify({
|
||||
'rules': [],
|
||||
'message': 'Alert rules list endpoint - to be implemented in Phase 4'
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/rules', methods=['POST'])
|
||||
@api_auth_required
|
||||
def create_alert_rule():
|
||||
"""
|
||||
Create a new alert rule.
|
||||
|
||||
Request body:
|
||||
rule_type: Type of alert rule
|
||||
threshold: Threshold value (e.g., days for cert expiry)
|
||||
enabled: Whether rule is active (default: true)
|
||||
email_enabled: Send email for this rule (default: false)
|
||||
|
||||
Returns:
|
||||
JSON response with created rule ID
|
||||
"""
|
||||
# TODO: Implement in Phase 4
|
||||
data = request.get_json() or {}
|
||||
|
||||
return jsonify({
|
||||
'rule_id': None,
|
||||
'status': 'not_implemented',
|
||||
'message': 'Alert rule creation endpoint - to be implemented in Phase 4',
|
||||
'data': data
|
||||
}), 501
|
||||
|
||||
|
||||
@bp.route('/rules/<int:rule_id>', methods=['PUT'])
|
||||
@api_auth_required
|
||||
def update_alert_rule(rule_id):
|
||||
"""
|
||||
Update an existing alert rule.
|
||||
|
||||
Args:
|
||||
rule_id: Alert rule ID to update
|
||||
|
||||
Request body:
|
||||
threshold: Threshold value (optional)
|
||||
enabled: Whether rule is active (optional)
|
||||
email_enabled: Send email for this rule (optional)
|
||||
|
||||
Returns:
|
||||
JSON response with update status
|
||||
"""
|
||||
# TODO: Implement in Phase 4
|
||||
data = request.get_json() or {}
|
||||
|
||||
return jsonify({
|
||||
'rule_id': rule_id,
|
||||
'status': 'not_implemented',
|
||||
'message': 'Alert rule update endpoint - to be implemented in Phase 4',
|
||||
'data': data
|
||||
}), 501
|
||||
|
||||
|
||||
@bp.route('/rules/<int:rule_id>', methods=['DELETE'])
|
||||
@api_auth_required
|
||||
def delete_alert_rule(rule_id):
|
||||
"""
|
||||
Delete an alert rule.
|
||||
|
||||
Args:
|
||||
rule_id: Alert rule ID to delete
|
||||
|
||||
Returns:
|
||||
JSON response with deletion status
|
||||
"""
|
||||
# TODO: Implement in Phase 4
|
||||
return jsonify({
|
||||
'rule_id': rule_id,
|
||||
'status': 'not_implemented',
|
||||
'message': 'Alert rule deletion endpoint - to be implemented in Phase 4'
|
||||
}), 501
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""
|
||||
Health check endpoint for monitoring.
|
||||
|
||||
Returns:
|
||||
JSON response with API health status
|
||||
"""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'api': 'alerts',
|
||||
'version': '1.0.0-phase1'
|
||||
})
|
||||
452
app/web/api/configs.py
Normal file
452
app/web/api/configs.py
Normal file
@@ -0,0 +1,452 @@
|
||||
"""
|
||||
Configs API blueprint.
|
||||
|
||||
Handles endpoints for managing scan configuration files, including CSV/YAML upload,
|
||||
template download, and config management.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import io
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from web.auth.decorators import api_auth_required
|
||||
from web.services.config_service import ConfigService
|
||||
|
||||
bp = Blueprint('configs', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
@api_auth_required
|
||||
def list_configs():
|
||||
"""
|
||||
List all config files with metadata.
|
||||
|
||||
Returns:
|
||||
JSON response with list of configs:
|
||||
{
|
||||
"configs": [
|
||||
{
|
||||
"filename": "prod-scan.yaml",
|
||||
"title": "Prod Scan",
|
||||
"path": "/app/configs/prod-scan.yaml",
|
||||
"created_at": "2025-11-15T10:30:00Z",
|
||||
"size_bytes": 1234,
|
||||
"used_by_schedules": ["Daily Scan"]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
config_service = ConfigService()
|
||||
configs = config_service.list_configs()
|
||||
|
||||
logger.info(f"Listed {len(configs)} config files")
|
||||
|
||||
return jsonify({
|
||||
'configs': configs
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error listing configs: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<filename>', methods=['GET'])
|
||||
@api_auth_required
|
||||
def get_config(filename: str):
|
||||
"""
|
||||
Get config file content and parsed data.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
|
||||
Returns:
|
||||
JSON response with config content:
|
||||
{
|
||||
"filename": "prod-scan.yaml",
|
||||
"content": "title: Prod Scan\n...",
|
||||
"parsed": {"title": "Prod Scan", "sites": [...]}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Sanitize filename
|
||||
filename = secure_filename(filename)
|
||||
|
||||
config_service = ConfigService()
|
||||
config_data = config_service.get_config(filename)
|
||||
|
||||
logger.info(f"Retrieved config file: {filename}")
|
||||
|
||||
return jsonify(config_data)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f"Config file not found: {filename}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': str(e)
|
||||
}), 404
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid config file: {filename} - {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Invalid config',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error getting config {filename}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/create-from-cidr', methods=['POST'])
|
||||
@api_auth_required
|
||||
def create_from_cidr():
|
||||
"""
|
||||
Create config from CIDR range.
|
||||
|
||||
Request:
|
||||
JSON with:
|
||||
{
|
||||
"title": "My Scan",
|
||||
"cidr": "10.0.0.0/24",
|
||||
"site_name": "Production" (optional),
|
||||
"ping_default": false (optional)
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON response with created config info:
|
||||
{
|
||||
"success": true,
|
||||
"filename": "my-scan.yaml",
|
||||
"preview": "title: My Scan\n..."
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'Request body must be JSON'
|
||||
}), 400
|
||||
|
||||
# Validate required fields
|
||||
if 'title' not in data:
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'Missing required field: title'
|
||||
}), 400
|
||||
|
||||
if 'cidr' not in data:
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'Missing required field: cidr'
|
||||
}), 400
|
||||
|
||||
title = data['title']
|
||||
cidr = data['cidr']
|
||||
site_name = data.get('site_name', None)
|
||||
ping_default = data.get('ping_default', False)
|
||||
|
||||
# Validate title
|
||||
if not title or not title.strip():
|
||||
return jsonify({
|
||||
'error': 'Validation error',
|
||||
'message': 'Title cannot be empty'
|
||||
}), 400
|
||||
|
||||
# Create config from CIDR
|
||||
config_service = ConfigService()
|
||||
filename, yaml_preview = config_service.create_from_cidr(
|
||||
title=title,
|
||||
cidr=cidr,
|
||||
site_name=site_name,
|
||||
ping_default=ping_default
|
||||
)
|
||||
|
||||
logger.info(f"Created config from CIDR {cidr}: {filename}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'filename': filename,
|
||||
'preview': yaml_preview
|
||||
})
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"CIDR validation failed: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Validation error',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error creating config from CIDR: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/upload-yaml', methods=['POST'])
|
||||
@api_auth_required
|
||||
def upload_yaml():
|
||||
"""
|
||||
Upload YAML config file directly.
|
||||
|
||||
Request:
|
||||
multipart/form-data with 'file' field containing YAML file
|
||||
Optional 'filename' field for custom filename
|
||||
|
||||
Returns:
|
||||
JSON response with created config info:
|
||||
{
|
||||
"success": true,
|
||||
"filename": "prod-scan.yaml"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Check if file is present
|
||||
if 'file' not in request.files:
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'No file provided'
|
||||
}), 400
|
||||
|
||||
file = request.files['file']
|
||||
|
||||
# Check if file is selected
|
||||
if file.filename == '':
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'No file selected'
|
||||
}), 400
|
||||
|
||||
# Check file extension
|
||||
if not (file.filename.endswith('.yaml') or file.filename.endswith('.yml')):
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'File must be a YAML file (.yaml or .yml extension)'
|
||||
}), 400
|
||||
|
||||
# Read YAML content
|
||||
yaml_content = file.read().decode('utf-8')
|
||||
|
||||
# Get filename (use uploaded filename or custom)
|
||||
filename = request.form.get('filename', file.filename)
|
||||
filename = secure_filename(filename)
|
||||
|
||||
# Create config from YAML
|
||||
config_service = ConfigService()
|
||||
final_filename = config_service.create_from_yaml(filename, yaml_content)
|
||||
|
||||
logger.info(f"Created config from YAML upload: {final_filename}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'filename': final_filename
|
||||
})
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"YAML validation failed: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Validation error',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
except UnicodeDecodeError:
|
||||
logger.warning("YAML file encoding error")
|
||||
return jsonify({
|
||||
'error': 'Encoding error',
|
||||
'message': 'YAML file must be UTF-8 encoded'
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error uploading YAML: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
|
||||
|
||||
@bp.route('/<filename>/download', methods=['GET'])
|
||||
@api_auth_required
|
||||
def download_config(filename: str):
|
||||
"""
|
||||
Download existing config file.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
|
||||
Returns:
|
||||
YAML file download
|
||||
"""
|
||||
try:
|
||||
# Sanitize filename
|
||||
filename = secure_filename(filename)
|
||||
|
||||
config_service = ConfigService()
|
||||
config_data = config_service.get_config(filename)
|
||||
|
||||
# Create file-like object
|
||||
yaml_file = io.BytesIO(config_data['content'].encode('utf-8'))
|
||||
yaml_file.seek(0)
|
||||
|
||||
logger.info(f"Config file downloaded: {filename}")
|
||||
|
||||
# Send file
|
||||
return send_file(
|
||||
yaml_file,
|
||||
mimetype='application/x-yaml',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f"Config file not found: {filename}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': str(e)
|
||||
}), 404
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error downloading config {filename}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<filename>', methods=['PUT'])
|
||||
@api_auth_required
|
||||
def update_config(filename: str):
|
||||
"""
|
||||
Update existing config file with new YAML content.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
|
||||
Request:
|
||||
JSON with:
|
||||
{
|
||||
"content": "title: My Scan\nsites: ..."
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON response with success status:
|
||||
{
|
||||
"success": true,
|
||||
"message": "Config updated successfully"
|
||||
}
|
||||
|
||||
Error responses:
|
||||
- 400: Invalid YAML or config structure
|
||||
- 404: Config file not found
|
||||
- 500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Sanitize filename
|
||||
filename = secure_filename(filename)
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'content' not in data:
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'Missing required field: content'
|
||||
}), 400
|
||||
|
||||
yaml_content = data['content']
|
||||
|
||||
# Update config
|
||||
config_service = ConfigService()
|
||||
config_service.update_config(filename, yaml_content)
|
||||
|
||||
logger.info(f"Updated config file: {filename}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Config updated successfully'
|
||||
})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f"Config file not found: {filename}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': str(e)
|
||||
}), 404
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid config content for {filename}: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Validation error',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error updating config {filename}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<filename>', methods=['DELETE'])
|
||||
@api_auth_required
|
||||
def delete_config(filename: str):
|
||||
"""
|
||||
Delete config file and cascade delete associated schedules.
|
||||
|
||||
When a config is deleted, all schedules using that config (both enabled
|
||||
and disabled) are automatically deleted as well.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
|
||||
Returns:
|
||||
JSON response with success status:
|
||||
{
|
||||
"success": true,
|
||||
"message": "Config deleted successfully"
|
||||
}
|
||||
|
||||
Error responses:
|
||||
- 404: Config file not found
|
||||
- 500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Sanitize filename
|
||||
filename = secure_filename(filename)
|
||||
|
||||
config_service = ConfigService()
|
||||
config_service.delete_config(filename)
|
||||
|
||||
logger.info(f"Deleted config file: {filename}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Config deleted successfully'
|
||||
})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f"Config file not found: {filename}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': str(e)
|
||||
}), 404
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting config {filename}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
338
app/web/api/scans.py
Normal file
338
app/web/api/scans.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
Scans API blueprint.
|
||||
|
||||
Handles endpoints for triggering scans, listing scan history, and retrieving
|
||||
scan results.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from flask import Blueprint, current_app, jsonify, request
|
||||
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
|
||||
from web.utils.pagination import validate_page_params
|
||||
|
||||
bp = Blueprint('scans', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
@api_auth_required
|
||||
def list_scans():
|
||||
"""
|
||||
List all scans with pagination.
|
||||
|
||||
Query params:
|
||||
page: Page number (default: 1)
|
||||
per_page: Items per page (default: 20, max: 100)
|
||||
status: Filter by status (running, completed, failed)
|
||||
|
||||
Returns:
|
||||
JSON response with scans list and pagination info
|
||||
"""
|
||||
try:
|
||||
# Get and validate query parameters
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 20, type=int)
|
||||
status_filter = request.args.get('status', None, type=str)
|
||||
|
||||
# Validate pagination params
|
||||
page, per_page = validate_page_params(page, per_page)
|
||||
|
||||
# Get scans from service
|
||||
scan_service = ScanService(current_app.db_session)
|
||||
paginated_result = scan_service.list_scans(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
status_filter=status_filter
|
||||
)
|
||||
|
||||
logger.info(f"Listed scans: page={page}, per_page={per_page}, status={status_filter}, total={paginated_result.total}")
|
||||
|
||||
return jsonify({
|
||||
'scans': paginated_result.items,
|
||||
'total': paginated_result.total,
|
||||
'page': paginated_result.page,
|
||||
'per_page': paginated_result.per_page,
|
||||
'total_pages': paginated_result.pages,
|
||||
'has_prev': paginated_result.has_prev,
|
||||
'has_next': paginated_result.has_next
|
||||
})
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid request parameters: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Invalid request',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error listing scans: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Database error',
|
||||
'message': 'Failed to retrieve scans'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error listing scans: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:scan_id>', methods=['GET'])
|
||||
@api_auth_required
|
||||
def get_scan(scan_id):
|
||||
"""
|
||||
Get details for a specific scan.
|
||||
|
||||
Args:
|
||||
scan_id: Scan ID
|
||||
|
||||
Returns:
|
||||
JSON response with scan details
|
||||
"""
|
||||
try:
|
||||
# Get scan from service
|
||||
scan_service = ScanService(current_app.db_session)
|
||||
scan = scan_service.get_scan(scan_id)
|
||||
|
||||
if not scan:
|
||||
logger.warning(f"Scan not found: {scan_id}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': f'Scan with ID {scan_id} not found'
|
||||
}), 404
|
||||
|
||||
logger.info(f"Retrieved scan details: {scan_id}")
|
||||
return jsonify(scan)
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error retrieving scan {scan_id}: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Database error',
|
||||
'message': 'Failed to retrieve scan'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error retrieving scan {scan_id}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('', methods=['POST'])
|
||||
@api_auth_required
|
||||
def trigger_scan():
|
||||
"""
|
||||
Trigger a new scan.
|
||||
|
||||
Request body:
|
||||
config_file: Path to YAML config file
|
||||
|
||||
Returns:
|
||||
JSON response with scan_id and status
|
||||
"""
|
||||
try:
|
||||
# Get request data
|
||||
data = request.get_json() or {}
|
||||
config_file = data.get('config_file')
|
||||
|
||||
# Validate required fields
|
||||
if not config_file:
|
||||
logger.warning("Scan trigger request missing config_file")
|
||||
return jsonify({
|
||||
'error': 'Invalid request',
|
||||
'message': 'config_file is required'
|
||||
}), 400
|
||||
|
||||
# Trigger scan via service
|
||||
scan_service = ScanService(current_app.db_session)
|
||||
scan_id = scan_service.trigger_scan(
|
||||
config_file=config_file,
|
||||
triggered_by='api',
|
||||
scheduler=current_app.scheduler
|
||||
)
|
||||
|
||||
logger.info(f"Scan {scan_id} triggered via API: config={config_file}")
|
||||
|
||||
return jsonify({
|
||||
'scan_id': scan_id,
|
||||
'status': 'running',
|
||||
'message': 'Scan queued successfully'
|
||||
}), 201
|
||||
|
||||
except ValueError as e:
|
||||
# Config file validation error
|
||||
error_message = str(e)
|
||||
logger.warning(f"Invalid config file: {error_message}")
|
||||
logger.warning(f"Request data: config_file='{config_file}'")
|
||||
return jsonify({
|
||||
'error': 'Invalid request',
|
||||
'message': error_message
|
||||
}), 400
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error triggering scan: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Database error',
|
||||
'message': 'Failed to create scan'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error triggering scan: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:scan_id>', methods=['DELETE'])
|
||||
@api_auth_required
|
||||
def delete_scan(scan_id):
|
||||
"""
|
||||
Delete a scan and its associated files.
|
||||
|
||||
Args:
|
||||
scan_id: Scan ID to delete
|
||||
|
||||
Returns:
|
||||
JSON response with deletion status
|
||||
"""
|
||||
try:
|
||||
# Delete scan via service
|
||||
scan_service = ScanService(current_app.db_session)
|
||||
scan_service.delete_scan(scan_id)
|
||||
|
||||
logger.info(f"Scan {scan_id} deleted successfully")
|
||||
|
||||
return jsonify({
|
||||
'scan_id': scan_id,
|
||||
'message': 'Scan deleted successfully'
|
||||
}), 200
|
||||
|
||||
except ValueError as e:
|
||||
# Scan not found
|
||||
logger.warning(f"Scan deletion failed: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': str(e)
|
||||
}), 404
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error deleting scan {scan_id}: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Database error',
|
||||
'message': 'Failed to delete scan'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting scan {scan_id}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:scan_id>/status', methods=['GET'])
|
||||
@api_auth_required
|
||||
def get_scan_status(scan_id):
|
||||
"""
|
||||
Get current status of a running scan.
|
||||
|
||||
Args:
|
||||
scan_id: Scan ID
|
||||
|
||||
Returns:
|
||||
JSON response with scan status and progress
|
||||
"""
|
||||
try:
|
||||
# Get scan status from service
|
||||
scan_service = ScanService(current_app.db_session)
|
||||
status = scan_service.get_scan_status(scan_id)
|
||||
|
||||
if not status:
|
||||
logger.warning(f"Scan not found for status check: {scan_id}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': f'Scan with ID {scan_id} not found'
|
||||
}), 404
|
||||
|
||||
logger.debug(f"Retrieved status for scan {scan_id}: {status['status']}")
|
||||
return jsonify(status)
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error retrieving scan status {scan_id}: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Database error',
|
||||
'message': 'Failed to retrieve scan status'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error retrieving scan status {scan_id}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:scan_id1>/compare/<int:scan_id2>', methods=['GET'])
|
||||
@api_auth_required
|
||||
def compare_scans(scan_id1, scan_id2):
|
||||
"""
|
||||
Compare two scans and show differences.
|
||||
|
||||
Compares ports, services, and certificates between two scans,
|
||||
highlighting added, removed, and changed items.
|
||||
|
||||
Args:
|
||||
scan_id1: First (older) scan ID
|
||||
scan_id2: Second (newer) scan ID
|
||||
|
||||
Returns:
|
||||
JSON response with comparison results including:
|
||||
- scan1, scan2: Metadata for both scans
|
||||
- ports: Added, removed, and unchanged ports
|
||||
- services: Added, removed, and changed services
|
||||
- certificates: Added, removed, and changed certificates
|
||||
- drift_score: Overall drift metric (0.0-1.0)
|
||||
"""
|
||||
try:
|
||||
# Compare scans using service
|
||||
scan_service = ScanService(current_app.db_session)
|
||||
comparison = scan_service.compare_scans(scan_id1, scan_id2)
|
||||
|
||||
if not comparison:
|
||||
logger.warning(f"Scan comparison failed: one or both scans not found ({scan_id1}, {scan_id2})")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': 'One or both scans not found'
|
||||
}), 404
|
||||
|
||||
logger.info(f"Compared scans {scan_id1} and {scan_id2}: drift_score={comparison['drift_score']}")
|
||||
return jsonify(comparison), 200
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error comparing scans {scan_id1} and {scan_id2}: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Database error',
|
||||
'message': 'Failed to compare scans'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error comparing scans {scan_id1} and {scan_id2}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""
|
||||
Health check endpoint for monitoring.
|
||||
|
||||
Returns:
|
||||
JSON response with API health status
|
||||
"""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'api': 'scans',
|
||||
'version': '1.0.0-phase1'
|
||||
})
|
||||
331
app/web/api/schedules.py
Normal file
331
app/web/api/schedules.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
Schedules API blueprint.
|
||||
|
||||
Handles endpoints for managing scheduled scans including CRUD operations
|
||||
and manual triggering.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, jsonify, request, current_app
|
||||
|
||||
from web.auth.decorators import api_auth_required
|
||||
from web.services.schedule_service import ScheduleService
|
||||
from web.services.scan_service import ScanService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('schedules', __name__)
|
||||
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
@api_auth_required
|
||||
def list_schedules():
|
||||
"""
|
||||
List all schedules with pagination and filtering.
|
||||
|
||||
Query parameters:
|
||||
page: Page number (default: 1)
|
||||
per_page: Items per page (default: 20)
|
||||
enabled: Filter by enabled status (true/false)
|
||||
|
||||
Returns:
|
||||
JSON response with paginated schedules list
|
||||
"""
|
||||
try:
|
||||
# Parse query parameters
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 20, type=int)
|
||||
enabled_str = request.args.get('enabled', type=str)
|
||||
|
||||
# Parse enabled filter
|
||||
enabled_filter = None
|
||||
if enabled_str is not None:
|
||||
enabled_filter = enabled_str.lower() == 'true'
|
||||
|
||||
# Get schedules
|
||||
schedule_service = ScheduleService(current_app.db_session)
|
||||
result = schedule_service.list_schedules(page, per_page, enabled_filter)
|
||||
|
||||
return jsonify(result), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing schedules: {str(e)}", exc_info=True)
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:schedule_id>', methods=['GET'])
|
||||
@api_auth_required
|
||||
def get_schedule(schedule_id):
|
||||
"""
|
||||
Get details for a specific schedule.
|
||||
|
||||
Args:
|
||||
schedule_id: Schedule ID
|
||||
|
||||
Returns:
|
||||
JSON response with schedule details including execution history
|
||||
"""
|
||||
try:
|
||||
schedule_service = ScheduleService(current_app.db_session)
|
||||
schedule = schedule_service.get_schedule(schedule_id)
|
||||
|
||||
return jsonify(schedule), 200
|
||||
|
||||
except ValueError as e:
|
||||
# Schedule not found
|
||||
return jsonify({'error': str(e)}), 404
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting schedule {schedule_id}: {str(e)}", exc_info=True)
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@bp.route('', methods=['POST'])
|
||||
@api_auth_required
|
||||
def create_schedule():
|
||||
"""
|
||||
Create a new schedule.
|
||||
|
||||
Request body:
|
||||
name: Schedule name (required)
|
||||
config_file: Path to YAML config (required)
|
||||
cron_expression: Cron expression (required, e.g., '0 2 * * *')
|
||||
enabled: Whether schedule is active (optional, default: true)
|
||||
|
||||
Returns:
|
||||
JSON response with created schedule ID
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Validate required fields
|
||||
required = ['name', 'config_file', 'cron_expression']
|
||||
missing = [field for field in required if field not in data]
|
||||
if missing:
|
||||
return jsonify({'error': f'Missing required fields: {", ".join(missing)}'}), 400
|
||||
|
||||
# Create schedule
|
||||
schedule_service = ScheduleService(current_app.db_session)
|
||||
schedule_id = schedule_service.create_schedule(
|
||||
name=data['name'],
|
||||
config_file=data['config_file'],
|
||||
cron_expression=data['cron_expression'],
|
||||
enabled=data.get('enabled', True)
|
||||
)
|
||||
|
||||
# Get the created schedule
|
||||
schedule = schedule_service.get_schedule(schedule_id)
|
||||
|
||||
# Add to APScheduler if enabled
|
||||
if schedule['enabled'] and hasattr(current_app, 'scheduler'):
|
||||
try:
|
||||
current_app.scheduler.add_scheduled_scan(
|
||||
schedule_id=schedule_id,
|
||||
config_file=schedule['config_file'],
|
||||
cron_expression=schedule['cron_expression']
|
||||
)
|
||||
logger.info(f"Schedule {schedule_id} added to APScheduler")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add schedule {schedule_id} to APScheduler: {str(e)}")
|
||||
# Continue anyway - schedule is created in DB
|
||||
|
||||
return jsonify({
|
||||
'schedule_id': schedule_id,
|
||||
'message': 'Schedule created successfully',
|
||||
'schedule': schedule
|
||||
}), 201
|
||||
|
||||
except ValueError as e:
|
||||
# Validation error
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating schedule: {str(e)}", exc_info=True)
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:schedule_id>', methods=['PUT'])
|
||||
@api_auth_required
|
||||
def update_schedule(schedule_id):
|
||||
"""
|
||||
Update an existing schedule.
|
||||
|
||||
Args:
|
||||
schedule_id: Schedule ID to update
|
||||
|
||||
Request body:
|
||||
name: Schedule name (optional)
|
||||
config_file: Path to YAML config (optional)
|
||||
cron_expression: Cron expression (optional)
|
||||
enabled: Whether schedule is active (optional)
|
||||
|
||||
Returns:
|
||||
JSON response with updated schedule
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': 'No update data provided'}), 400
|
||||
|
||||
# Update schedule
|
||||
schedule_service = ScheduleService(current_app.db_session)
|
||||
|
||||
# Store old state to check if scheduler update needed
|
||||
old_schedule = schedule_service.get_schedule(schedule_id)
|
||||
|
||||
# Perform update
|
||||
updated_schedule = schedule_service.update_schedule(schedule_id, **data)
|
||||
|
||||
# Update in APScheduler if needed
|
||||
if hasattr(current_app, 'scheduler'):
|
||||
try:
|
||||
# If cron expression or config changed, or enabled status changed
|
||||
cron_changed = 'cron_expression' in data
|
||||
config_changed = 'config_file' in data
|
||||
enabled_changed = 'enabled' in data
|
||||
|
||||
if enabled_changed:
|
||||
if updated_schedule['enabled']:
|
||||
# Re-add to scheduler (replaces existing)
|
||||
current_app.scheduler.add_scheduled_scan(
|
||||
schedule_id=schedule_id,
|
||||
config_file=updated_schedule['config_file'],
|
||||
cron_expression=updated_schedule['cron_expression']
|
||||
)
|
||||
logger.info(f"Schedule {schedule_id} enabled and added to APScheduler")
|
||||
else:
|
||||
# Remove from scheduler
|
||||
current_app.scheduler.remove_scheduled_scan(schedule_id)
|
||||
logger.info(f"Schedule {schedule_id} disabled and removed from APScheduler")
|
||||
elif (cron_changed or config_changed) and updated_schedule['enabled']:
|
||||
# Reload schedule in APScheduler
|
||||
current_app.scheduler.add_scheduled_scan(
|
||||
schedule_id=schedule_id,
|
||||
config_file=updated_schedule['config_file'],
|
||||
cron_expression=updated_schedule['cron_expression']
|
||||
)
|
||||
logger.info(f"Schedule {schedule_id} reloaded in APScheduler")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update schedule {schedule_id} in APScheduler: {str(e)}")
|
||||
# Continue anyway - schedule is updated in DB
|
||||
|
||||
return jsonify({
|
||||
'message': 'Schedule updated successfully',
|
||||
'schedule': updated_schedule
|
||||
}), 200
|
||||
|
||||
except ValueError as e:
|
||||
# Schedule not found or validation error
|
||||
if 'not found' in str(e):
|
||||
return jsonify({'error': str(e)}), 404
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating schedule {schedule_id}: {str(e)}", exc_info=True)
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:schedule_id>', methods=['DELETE'])
|
||||
@api_auth_required
|
||||
def delete_schedule(schedule_id):
|
||||
"""
|
||||
Delete a schedule.
|
||||
|
||||
Note: Associated scans are NOT deleted (schedule_id becomes null).
|
||||
Active scans will complete normally.
|
||||
|
||||
Args:
|
||||
schedule_id: Schedule ID to delete
|
||||
|
||||
Returns:
|
||||
JSON response with deletion status
|
||||
"""
|
||||
try:
|
||||
# Remove from APScheduler first
|
||||
if hasattr(current_app, 'scheduler'):
|
||||
try:
|
||||
current_app.scheduler.remove_scheduled_scan(schedule_id)
|
||||
logger.info(f"Schedule {schedule_id} removed from APScheduler")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to remove schedule {schedule_id} from APScheduler: {str(e)}")
|
||||
# Continue anyway
|
||||
|
||||
# Delete from database
|
||||
schedule_service = ScheduleService(current_app.db_session)
|
||||
schedule_service.delete_schedule(schedule_id)
|
||||
|
||||
return jsonify({
|
||||
'message': 'Schedule deleted successfully',
|
||||
'schedule_id': schedule_id
|
||||
}), 200
|
||||
|
||||
except ValueError as e:
|
||||
# Schedule not found
|
||||
return jsonify({'error': str(e)}), 404
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting schedule {schedule_id}: {str(e)}", exc_info=True)
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:schedule_id>/trigger', methods=['POST'])
|
||||
@api_auth_required
|
||||
def trigger_schedule(schedule_id):
|
||||
"""
|
||||
Manually trigger a scheduled scan.
|
||||
|
||||
Creates a new scan with the schedule's configuration and queues it
|
||||
for immediate execution.
|
||||
|
||||
Args:
|
||||
schedule_id: Schedule ID to trigger
|
||||
|
||||
Returns:
|
||||
JSON response with triggered scan ID
|
||||
"""
|
||||
try:
|
||||
# Get schedule
|
||||
schedule_service = ScheduleService(current_app.db_session)
|
||||
schedule = schedule_service.get_schedule(schedule_id)
|
||||
|
||||
# Trigger scan
|
||||
scan_service = ScanService(current_app.db_session)
|
||||
|
||||
# Get scheduler if available
|
||||
scheduler = current_app.scheduler if hasattr(current_app, 'scheduler') else None
|
||||
|
||||
scan_id = scan_service.trigger_scan(
|
||||
config_file=schedule['config_file'],
|
||||
triggered_by='manual',
|
||||
schedule_id=schedule_id,
|
||||
scheduler=scheduler
|
||||
)
|
||||
|
||||
logger.info(f"Manual trigger of schedule {schedule_id} created scan {scan_id}")
|
||||
|
||||
return jsonify({
|
||||
'message': 'Scan triggered successfully',
|
||||
'schedule_id': schedule_id,
|
||||
'scan_id': scan_id
|
||||
}), 201
|
||||
|
||||
except ValueError as e:
|
||||
# Schedule not found
|
||||
return jsonify({'error': str(e)}), 404
|
||||
except Exception as e:
|
||||
logger.error(f"Error triggering schedule {schedule_id}: {str(e)}", exc_info=True)
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""
|
||||
Health check endpoint for monitoring.
|
||||
|
||||
Returns:
|
||||
JSON response with API health status
|
||||
"""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'api': 'schedules',
|
||||
'version': '1.0.0-phase1'
|
||||
})
|
||||
271
app/web/api/settings.py
Normal file
271
app/web/api/settings.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
Settings API blueprint.
|
||||
|
||||
Handles endpoints for managing application settings including SMTP configuration,
|
||||
authentication, and system preferences.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, current_app, jsonify, request
|
||||
|
||||
from web.auth.decorators import api_auth_required
|
||||
from web.utils.settings import PasswordManager, SettingsManager
|
||||
|
||||
bp = Blueprint('settings', __name__)
|
||||
|
||||
|
||||
def get_settings_manager():
|
||||
"""Get SettingsManager instance with current DB session."""
|
||||
return SettingsManager(current_app.db_session)
|
||||
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
@api_auth_required
|
||||
def get_settings():
|
||||
"""
|
||||
Get all settings (sanitized - encrypted values masked).
|
||||
|
||||
Returns:
|
||||
JSON response with all settings
|
||||
"""
|
||||
try:
|
||||
settings_manager = get_settings_manager()
|
||||
settings = settings_manager.get_all(decrypt=False, sanitize=True)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'settings': settings
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to retrieve settings: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to retrieve settings'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('', methods=['PUT'])
|
||||
@api_auth_required
|
||||
def update_settings():
|
||||
"""
|
||||
Update multiple settings at once.
|
||||
|
||||
Request body:
|
||||
settings: Dictionary of setting key-value pairs
|
||||
|
||||
Returns:
|
||||
JSON response with update status
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
settings_dict = data.get('settings', {})
|
||||
|
||||
if not settings_dict:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No settings provided'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
settings_manager = get_settings_manager()
|
||||
|
||||
# Update each setting
|
||||
for key, value in settings_dict.items():
|
||||
settings_manager.set(key, value)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'Updated {len(settings_dict)} settings'
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to update settings: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to update settings'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<string:key>', methods=['GET'])
|
||||
@api_auth_required
|
||||
def get_setting(key):
|
||||
"""
|
||||
Get a specific setting by key.
|
||||
|
||||
Args:
|
||||
key: Setting key
|
||||
|
||||
Returns:
|
||||
JSON response with setting value
|
||||
"""
|
||||
try:
|
||||
settings_manager = get_settings_manager()
|
||||
value = settings_manager.get(key)
|
||||
|
||||
if value is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Setting "{key}" not found'
|
||||
}), 404
|
||||
|
||||
# Sanitize if encrypted key
|
||||
if settings_manager._should_encrypt(key):
|
||||
value = '***ENCRYPTED***'
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'value': value
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to retrieve setting {key}: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to retrieve setting'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<string:key>', methods=['PUT'])
|
||||
@api_auth_required
|
||||
def update_setting(key):
|
||||
"""
|
||||
Update a specific setting.
|
||||
|
||||
Args:
|
||||
key: Setting key
|
||||
|
||||
Request body:
|
||||
value: New value for the setting
|
||||
|
||||
Returns:
|
||||
JSON response with update status
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
value = data.get('value')
|
||||
|
||||
if value is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No value provided'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
settings_manager = get_settings_manager()
|
||||
settings_manager.set(key, value)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'Setting "{key}" updated'
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to update setting {key}: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to update setting'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<string:key>', methods=['DELETE'])
|
||||
@api_auth_required
|
||||
def delete_setting(key):
|
||||
"""
|
||||
Delete a setting.
|
||||
|
||||
Args:
|
||||
key: Setting key to delete
|
||||
|
||||
Returns:
|
||||
JSON response with deletion status
|
||||
"""
|
||||
try:
|
||||
settings_manager = get_settings_manager()
|
||||
deleted = settings_manager.delete(key)
|
||||
|
||||
if not deleted:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Setting "{key}" not found'
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'Setting "{key}" deleted'
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to delete setting {key}: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to delete setting'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/password', methods=['POST'])
|
||||
@api_auth_required
|
||||
def set_password():
|
||||
"""
|
||||
Set the application password.
|
||||
|
||||
Request body:
|
||||
password: New password
|
||||
|
||||
Returns:
|
||||
JSON response with status
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
password = data.get('password')
|
||||
|
||||
if not password:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No password provided'
|
||||
}), 400
|
||||
|
||||
if len(password) < 8:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Password must be at least 8 characters'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
settings_manager = get_settings_manager()
|
||||
PasswordManager.set_app_password(settings_manager, password)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Password updated successfully'
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to set password: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to set password'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/test-email', methods=['POST'])
|
||||
@api_auth_required
|
||||
def test_email():
|
||||
"""
|
||||
Test email configuration by sending a test email.
|
||||
|
||||
Returns:
|
||||
JSON response with test result
|
||||
"""
|
||||
# TODO: Implement in Phase 4 (email support)
|
||||
return jsonify({
|
||||
'status': 'not_implemented',
|
||||
'message': 'Email testing endpoint - to be implemented in Phase 4'
|
||||
}), 501
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""
|
||||
Health check endpoint for monitoring.
|
||||
|
||||
Returns:
|
||||
JSON response with API health status
|
||||
"""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'api': 'settings',
|
||||
'version': '1.0.0-phase1'
|
||||
})
|
||||
258
app/web/api/stats.py
Normal file
258
app/web/api/stats.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Stats API blueprint.
|
||||
|
||||
Handles endpoints for dashboard statistics, trending data, and analytics.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, current_app, jsonify, request
|
||||
from sqlalchemy import func, Date
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from web.auth.decorators import api_auth_required
|
||||
from web.models import Scan
|
||||
|
||||
bp = Blueprint('stats', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bp.route('/scan-trend', methods=['GET'])
|
||||
@api_auth_required
|
||||
def scan_trend():
|
||||
"""
|
||||
Get scan activity trend data for charts.
|
||||
|
||||
Query params:
|
||||
days: Number of days to include (default: 30, max: 365)
|
||||
|
||||
Returns:
|
||||
JSON response with labels and values arrays for Chart.js
|
||||
{
|
||||
"labels": ["2025-01-01", "2025-01-02", ...],
|
||||
"values": [5, 3, 7, 2, ...]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Get and validate query parameters
|
||||
days = request.args.get('days', 30, type=int)
|
||||
|
||||
# Validate days parameter
|
||||
if days < 1:
|
||||
return jsonify({'error': 'days parameter must be at least 1'}), 400
|
||||
if days > 365:
|
||||
return jsonify({'error': 'days parameter cannot exceed 365'}), 400
|
||||
|
||||
# Calculate date range
|
||||
end_date = datetime.utcnow().date()
|
||||
start_date = end_date - timedelta(days=days - 1)
|
||||
|
||||
# Query scan counts per day
|
||||
db_session = current_app.db_session
|
||||
scan_counts = (
|
||||
db_session.query(
|
||||
func.date(Scan.timestamp).label('scan_date'),
|
||||
func.count(Scan.id).label('scan_count')
|
||||
)
|
||||
.filter(func.date(Scan.timestamp) >= start_date)
|
||||
.filter(func.date(Scan.timestamp) <= end_date)
|
||||
.group_by(func.date(Scan.timestamp))
|
||||
.order_by('scan_date')
|
||||
.all()
|
||||
)
|
||||
|
||||
# Create a dictionary of date -> count
|
||||
scan_dict = {str(row.scan_date): row.scan_count for row in scan_counts}
|
||||
|
||||
# Generate all dates in range (fill missing dates with 0)
|
||||
labels = []
|
||||
values = []
|
||||
current_date = start_date
|
||||
while current_date <= end_date:
|
||||
date_str = str(current_date)
|
||||
labels.append(date_str)
|
||||
values.append(scan_dict.get(date_str, 0))
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
return jsonify({
|
||||
'labels': labels,
|
||||
'values': values,
|
||||
'start_date': str(start_date),
|
||||
'end_date': str(end_date),
|
||||
'total_scans': sum(values)
|
||||
}), 200
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error in scan_trend: {str(e)}")
|
||||
return jsonify({'error': 'Database error occurred'}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Error in scan_trend: {str(e)}")
|
||||
return jsonify({'error': 'An error occurred'}), 500
|
||||
|
||||
|
||||
@bp.route('/summary', methods=['GET'])
|
||||
@api_auth_required
|
||||
def summary():
|
||||
"""
|
||||
Get dashboard summary statistics.
|
||||
|
||||
Returns:
|
||||
JSON response with summary stats
|
||||
{
|
||||
"total_scans": 150,
|
||||
"completed_scans": 140,
|
||||
"failed_scans": 5,
|
||||
"running_scans": 5,
|
||||
"scans_today": 3,
|
||||
"scans_this_week": 15
|
||||
}
|
||||
"""
|
||||
try:
|
||||
db_session = current_app.db_session
|
||||
|
||||
# Get total counts by status
|
||||
total_scans = db_session.query(func.count(Scan.id)).scalar() or 0
|
||||
completed_scans = db_session.query(func.count(Scan.id)).filter(
|
||||
Scan.status == 'completed'
|
||||
).scalar() or 0
|
||||
failed_scans = db_session.query(func.count(Scan.id)).filter(
|
||||
Scan.status == 'failed'
|
||||
).scalar() or 0
|
||||
running_scans = db_session.query(func.count(Scan.id)).filter(
|
||||
Scan.status == 'running'
|
||||
).scalar() or 0
|
||||
|
||||
# Get scans today
|
||||
today = datetime.utcnow().date()
|
||||
scans_today = db_session.query(func.count(Scan.id)).filter(
|
||||
func.date(Scan.timestamp) == today
|
||||
).scalar() or 0
|
||||
|
||||
# Get scans this week (last 7 days)
|
||||
week_ago = today - timedelta(days=6)
|
||||
scans_this_week = db_session.query(func.count(Scan.id)).filter(
|
||||
func.date(Scan.timestamp) >= week_ago
|
||||
).scalar() or 0
|
||||
|
||||
return jsonify({
|
||||
'total_scans': total_scans,
|
||||
'completed_scans': completed_scans,
|
||||
'failed_scans': failed_scans,
|
||||
'running_scans': running_scans,
|
||||
'scans_today': scans_today,
|
||||
'scans_this_week': scans_this_week
|
||||
}), 200
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error in summary: {str(e)}")
|
||||
return jsonify({'error': 'Database error occurred'}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Error in summary: {str(e)}")
|
||||
return jsonify({'error': 'An error occurred'}), 500
|
||||
|
||||
|
||||
@bp.route('/scan-history/<int:scan_id>', methods=['GET'])
|
||||
@api_auth_required
|
||||
def scan_history(scan_id):
|
||||
"""
|
||||
Get historical trend data for scans with the same config file.
|
||||
|
||||
Returns port counts and other metrics over time for the same
|
||||
configuration/target as the specified scan.
|
||||
|
||||
Args:
|
||||
scan_id: Reference scan ID
|
||||
|
||||
Query params:
|
||||
limit: Maximum number of historical scans to include (default: 10, max: 50)
|
||||
|
||||
Returns:
|
||||
JSON response with historical scan data
|
||||
{
|
||||
"scans": [
|
||||
{
|
||||
"id": 123,
|
||||
"timestamp": "2025-01-01T12:00:00",
|
||||
"title": "Scan title",
|
||||
"port_count": 25,
|
||||
"ip_count": 5
|
||||
},
|
||||
...
|
||||
],
|
||||
"labels": ["2025-01-01", ...],
|
||||
"port_counts": [25, 26, 24, ...]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Get query parameters
|
||||
limit = request.args.get('limit', 10, type=int)
|
||||
if limit > 50:
|
||||
limit = 50
|
||||
|
||||
db_session = current_app.db_session
|
||||
|
||||
# Get the reference scan to find its config file
|
||||
from web.models import ScanPort
|
||||
reference_scan = db_session.query(Scan).filter(Scan.id == scan_id).first()
|
||||
|
||||
if not reference_scan:
|
||||
return jsonify({'error': 'Scan not found'}), 404
|
||||
|
||||
config_file = reference_scan.config_file
|
||||
|
||||
# Query historical scans with the same config file
|
||||
historical_scans = (
|
||||
db_session.query(Scan)
|
||||
.filter(Scan.config_file == config_file)
|
||||
.filter(Scan.status == 'completed')
|
||||
.order_by(Scan.timestamp.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Build result data
|
||||
scans_data = []
|
||||
labels = []
|
||||
port_counts = []
|
||||
|
||||
for scan in reversed(historical_scans): # Reverse to get chronological order
|
||||
# Count ports for this scan
|
||||
port_count = (
|
||||
db_session.query(func.count(ScanPort.id))
|
||||
.filter(ScanPort.scan_id == scan.id)
|
||||
.scalar() or 0
|
||||
)
|
||||
|
||||
# Count unique IPs for this scan
|
||||
from web.models import ScanIP
|
||||
ip_count = (
|
||||
db_session.query(func.count(ScanIP.id))
|
||||
.filter(ScanIP.scan_id == scan.id)
|
||||
.scalar() or 0
|
||||
)
|
||||
|
||||
scans_data.append({
|
||||
'id': scan.id,
|
||||
'timestamp': scan.timestamp.isoformat() if scan.timestamp else None,
|
||||
'title': scan.title,
|
||||
'port_count': port_count,
|
||||
'ip_count': ip_count
|
||||
})
|
||||
|
||||
# For chart data
|
||||
labels.append(scan.timestamp.strftime('%Y-%m-%d %H:%M') if scan.timestamp else '')
|
||||
port_counts.append(port_count)
|
||||
|
||||
return jsonify({
|
||||
'scans': scans_data,
|
||||
'labels': labels,
|
||||
'port_counts': port_counts,
|
||||
'config_file': config_file
|
||||
}), 200
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error in scan_history: {str(e)}")
|
||||
return jsonify({'error': 'Database error occurred'}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Error in scan_history: {str(e)}")
|
||||
return jsonify({'error': 'An error occurred'}), 500
|
||||
599
app/web/app.py
Normal file
599
app/web/app.py
Normal file
@@ -0,0 +1,599 @@
|
||||
"""
|
||||
Flask application factory for SneakyScanner web interface.
|
||||
|
||||
This module creates and configures the Flask application with all necessary
|
||||
extensions, blueprints, and middleware.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, g, jsonify, request
|
||||
from flask_cors import CORS
|
||||
from flask_login import LoginManager, current_user
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import scoped_session, sessionmaker
|
||||
|
||||
from web.models import Base
|
||||
|
||||
|
||||
class RequestIDLogFilter(logging.Filter):
|
||||
"""
|
||||
Logging filter that injects request ID into log records.
|
||||
|
||||
Adds a 'request_id' attribute to each log record. For requests within
|
||||
Flask request context, uses the request ID from g.request_id. For logs
|
||||
outside request context (background jobs, startup), uses 'system'.
|
||||
"""
|
||||
|
||||
def filter(self, record):
|
||||
"""Add request_id to log record."""
|
||||
try:
|
||||
# Try to get request ID from Flask's g object
|
||||
record.request_id = g.get('request_id', 'system')
|
||||
except (RuntimeError, AttributeError):
|
||||
# Outside of request context
|
||||
record.request_id = 'system'
|
||||
return True
|
||||
|
||||
|
||||
def create_app(config: dict = None) -> Flask:
|
||||
"""
|
||||
Create and configure the Flask application.
|
||||
|
||||
Args:
|
||||
config: Optional configuration dictionary to override defaults
|
||||
|
||||
Returns:
|
||||
Configured Flask application instance
|
||||
"""
|
||||
app = Flask(__name__,
|
||||
instance_relative_config=True,
|
||||
static_folder='static',
|
||||
template_folder='templates')
|
||||
|
||||
# Load default configuration
|
||||
app.config.from_mapping(
|
||||
SECRET_KEY=os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production'),
|
||||
SQLALCHEMY_DATABASE_URI=os.environ.get('DATABASE_URL', 'sqlite:///./sneakyscanner.db'),
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS=False,
|
||||
JSON_SORT_KEYS=False, # Preserve order in JSON responses
|
||||
MAX_CONTENT_LENGTH=50 * 1024 * 1024, # 50MB max upload size (supports config files up to ~2MB)
|
||||
)
|
||||
|
||||
# Override with custom config if provided
|
||||
if config:
|
||||
app.config.update(config)
|
||||
|
||||
# Ensure instance folder exists
|
||||
try:
|
||||
os.makedirs(app.instance_path, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Configure logging
|
||||
configure_logging(app)
|
||||
|
||||
# Initialize database
|
||||
init_database(app)
|
||||
|
||||
# Initialize extensions
|
||||
init_extensions(app)
|
||||
|
||||
# Initialize authentication
|
||||
init_authentication(app)
|
||||
|
||||
# Initialize background scheduler
|
||||
init_scheduler(app)
|
||||
|
||||
# Register blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
# Register error handlers
|
||||
register_error_handlers(app)
|
||||
|
||||
# Add request/response handlers
|
||||
register_request_handlers(app)
|
||||
|
||||
app.logger.info("SneakyScanner Flask app initialized")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def configure_logging(app: Flask) -> None:
|
||||
"""
|
||||
Configure application logging with rotation and structured format.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
"""
|
||||
# Set log level from environment or default to INFO
|
||||
log_level = os.environ.get('LOG_LEVEL', 'INFO').upper()
|
||||
app.logger.setLevel(getattr(logging, log_level, logging.INFO))
|
||||
|
||||
# Create logs directory if it doesn't exist
|
||||
log_dir = Path('logs')
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Rotating file handler for application logs
|
||||
# Max 10MB per file, keep 10 backup files (100MB total)
|
||||
app_log_handler = RotatingFileHandler(
|
||||
log_dir / 'sneakyscanner.log',
|
||||
maxBytes=10 * 1024 * 1024, # 10MB
|
||||
backupCount=10
|
||||
)
|
||||
app_log_handler.setLevel(logging.INFO)
|
||||
|
||||
# Structured log format with more context
|
||||
log_formatter = logging.Formatter(
|
||||
'%(asctime)s [%(levelname)s] %(name)s [%(request_id)s] '
|
||||
'%(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
app_log_handler.setFormatter(log_formatter)
|
||||
|
||||
# Add filter to inject request ID into log records
|
||||
app_log_handler.addFilter(RequestIDLogFilter())
|
||||
app.logger.addHandler(app_log_handler)
|
||||
|
||||
# Separate rotating file handler for errors only
|
||||
error_log_handler = RotatingFileHandler(
|
||||
log_dir / 'sneakyscanner_errors.log',
|
||||
maxBytes=10 * 1024 * 1024, # 10MB
|
||||
backupCount=5
|
||||
)
|
||||
error_log_handler.setLevel(logging.ERROR)
|
||||
error_formatter = logging.Formatter(
|
||||
'%(asctime)s [%(levelname)s] %(name)s [%(request_id)s]\n'
|
||||
'Message: %(message)s\n'
|
||||
'Path: %(pathname)s:%(lineno)d\n'
|
||||
'%(stack_info)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
error_log_handler.setFormatter(error_formatter)
|
||||
error_log_handler.addFilter(RequestIDLogFilter())
|
||||
app.logger.addHandler(error_log_handler)
|
||||
|
||||
# Console handler for development
|
||||
if app.debug:
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
console_formatter = logging.Formatter(
|
||||
'%(asctime)s [%(levelname)s] %(name)s [%(request_id)s] %(message)s',
|
||||
datefmt='%H:%M:%S'
|
||||
)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
console_handler.addFilter(RequestIDLogFilter())
|
||||
app.logger.addHandler(console_handler)
|
||||
|
||||
app.logger.info("Logging configured with rotation (10MB per file, 10 backups)")
|
||||
|
||||
|
||||
def init_database(app: Flask) -> None:
|
||||
"""
|
||||
Initialize database connection and session management.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
"""
|
||||
# Determine connect_args based on database type
|
||||
connect_args = {}
|
||||
if 'sqlite' in app.config['SQLALCHEMY_DATABASE_URI']:
|
||||
# SQLite-specific configuration for better concurrency
|
||||
connect_args = {
|
||||
'timeout': 15, # 15 second timeout for database locks
|
||||
'check_same_thread': False # Allow SQLite usage across threads
|
||||
}
|
||||
|
||||
# Create engine
|
||||
engine = create_engine(
|
||||
app.config['SQLALCHEMY_DATABASE_URI'],
|
||||
echo=app.debug, # Log SQL in debug mode
|
||||
pool_pre_ping=True, # Verify connections before using
|
||||
pool_recycle=3600, # Recycle connections after 1 hour
|
||||
connect_args=connect_args
|
||||
)
|
||||
|
||||
# Enable WAL mode for SQLite (better concurrency)
|
||||
if 'sqlite' in app.config['SQLALCHEMY_DATABASE_URI']:
|
||||
@event.listens_for(engine, "connect")
|
||||
def set_sqlite_pragma(dbapi_conn, connection_record):
|
||||
"""Set SQLite pragmas for better performance and concurrency."""
|
||||
cursor = dbapi_conn.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL") # Write-Ahead Logging
|
||||
cursor.execute("PRAGMA synchronous=NORMAL") # Faster writes
|
||||
cursor.execute("PRAGMA busy_timeout=15000") # 15 second busy timeout
|
||||
cursor.close()
|
||||
|
||||
# Create scoped session factory
|
||||
db_session = scoped_session(
|
||||
sessionmaker(
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
bind=engine
|
||||
)
|
||||
)
|
||||
|
||||
# Store session in app for use in views
|
||||
app.db_session = db_session
|
||||
|
||||
# Create tables if they don't exist (for development)
|
||||
# In production, use Alembic migrations instead
|
||||
if app.debug:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
@app.teardown_appcontext
|
||||
def shutdown_session(exception=None):
|
||||
"""
|
||||
Remove database session at end of request.
|
||||
|
||||
Rollback on exception to prevent partial commits.
|
||||
"""
|
||||
if exception:
|
||||
app.logger.warning(f"Request ended with exception, rolling back database session")
|
||||
db_session.rollback()
|
||||
db_session.remove()
|
||||
|
||||
app.logger.info(f"Database initialized: {app.config['SQLALCHEMY_DATABASE_URI']}")
|
||||
|
||||
|
||||
def init_extensions(app: Flask) -> None:
|
||||
"""
|
||||
Initialize Flask extensions.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
"""
|
||||
# CORS support for API
|
||||
CORS(app, resources={
|
||||
r"/api/*": {
|
||||
"origins": os.environ.get('CORS_ORIGINS', '*').split(','),
|
||||
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
"allow_headers": ["Content-Type", "Authorization"],
|
||||
}
|
||||
})
|
||||
|
||||
app.logger.info("Extensions initialized")
|
||||
|
||||
|
||||
def init_authentication(app: Flask) -> None:
|
||||
"""
|
||||
Initialize Flask-Login authentication.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
"""
|
||||
from web.auth.models import User
|
||||
|
||||
# Initialize LoginManager
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(app)
|
||||
|
||||
# Configure login view
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Please log in to access this page.'
|
||||
login_manager.login_message_category = 'info'
|
||||
|
||||
# User loader callback
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
"""Load user by ID for Flask-Login."""
|
||||
return User.get(user_id, app.db_session)
|
||||
|
||||
app.logger.info("Authentication initialized")
|
||||
|
||||
|
||||
def init_scheduler(app: Flask) -> None:
|
||||
"""
|
||||
Initialize background job scheduler.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
"""
|
||||
from web.services.scheduler_service import SchedulerService
|
||||
from web.services.scan_service import ScanService
|
||||
|
||||
# Create and initialize scheduler
|
||||
scheduler = SchedulerService()
|
||||
scheduler.init_scheduler(app)
|
||||
|
||||
# Perform startup tasks with app context for database access
|
||||
with app.app_context():
|
||||
# Clean up any orphaned scans from previous crashes/restarts
|
||||
scan_service = ScanService(app.db_session)
|
||||
orphaned_count = scan_service.cleanup_orphaned_scans()
|
||||
if orphaned_count > 0:
|
||||
app.logger.warning(f"Cleaned up {orphaned_count} orphaned scan(s) on startup")
|
||||
|
||||
# Load all enabled schedules from database
|
||||
scheduler.load_schedules_on_startup()
|
||||
|
||||
# Store in app context for access from routes
|
||||
app.scheduler = scheduler
|
||||
|
||||
app.logger.info("Background scheduler initialized")
|
||||
|
||||
|
||||
def register_blueprints(app: Flask) -> None:
|
||||
"""
|
||||
Register Flask blueprints for different app sections.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
"""
|
||||
# Import blueprints
|
||||
from web.api.scans import bp as scans_bp
|
||||
from web.api.schedules import bp as schedules_bp
|
||||
from web.api.alerts import bp as alerts_bp
|
||||
from web.api.settings import bp as settings_bp
|
||||
from web.api.stats import bp as stats_bp
|
||||
from web.api.configs import bp as configs_bp
|
||||
from web.auth.routes import bp as auth_bp
|
||||
from web.routes.main import bp as main_bp
|
||||
|
||||
# Register authentication blueprint
|
||||
app.register_blueprint(auth_bp, url_prefix='/auth')
|
||||
|
||||
# Register main web routes blueprint
|
||||
app.register_blueprint(main_bp, url_prefix='/')
|
||||
|
||||
# Register API blueprints
|
||||
app.register_blueprint(scans_bp, url_prefix='/api/scans')
|
||||
app.register_blueprint(schedules_bp, url_prefix='/api/schedules')
|
||||
app.register_blueprint(alerts_bp, url_prefix='/api/alerts')
|
||||
app.register_blueprint(settings_bp, url_prefix='/api/settings')
|
||||
app.register_blueprint(stats_bp, url_prefix='/api/stats')
|
||||
app.register_blueprint(configs_bp, url_prefix='/api/configs')
|
||||
|
||||
app.logger.info("Blueprints registered")
|
||||
|
||||
|
||||
def register_error_handlers(app: Flask) -> None:
|
||||
"""
|
||||
Register error handlers for common HTTP errors.
|
||||
|
||||
Handles errors with either JSON responses (for API requests) or
|
||||
HTML templates (for web requests). Ensures database rollback on errors.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
"""
|
||||
from flask import render_template
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
def wants_json():
|
||||
"""Check if client wants JSON response."""
|
||||
# API requests always get JSON
|
||||
if request.path.startswith('/api/'):
|
||||
return True
|
||||
# Check Accept header
|
||||
best = request.accept_mimetypes.best_match(['application/json', 'text/html'])
|
||||
return best == 'application/json' and \
|
||||
request.accept_mimetypes[best] > request.accept_mimetypes['text/html']
|
||||
|
||||
@app.errorhandler(400)
|
||||
def bad_request(error):
|
||||
"""Handle 400 Bad Request errors."""
|
||||
app.logger.warning(f"Bad request: {request.path} - {str(error)}")
|
||||
|
||||
if wants_json():
|
||||
return jsonify({
|
||||
'error': 'Bad Request',
|
||||
'message': str(error) or 'The request was invalid'
|
||||
}), 400
|
||||
return render_template('errors/400.html', error=error), 400
|
||||
|
||||
@app.errorhandler(401)
|
||||
def unauthorized(error):
|
||||
"""Handle 401 Unauthorized errors."""
|
||||
app.logger.warning(f"Unauthorized access attempt: {request.path}")
|
||||
|
||||
if wants_json():
|
||||
return jsonify({
|
||||
'error': 'Unauthorized',
|
||||
'message': 'Authentication required'
|
||||
}), 401
|
||||
return render_template('errors/401.html', error=error), 401
|
||||
|
||||
@app.errorhandler(403)
|
||||
def forbidden(error):
|
||||
"""Handle 403 Forbidden errors."""
|
||||
app.logger.warning(f"Forbidden access: {request.path}")
|
||||
|
||||
if wants_json():
|
||||
return jsonify({
|
||||
'error': 'Forbidden',
|
||||
'message': 'You do not have permission to access this resource'
|
||||
}), 403
|
||||
return render_template('errors/403.html', error=error), 403
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
"""Handle 404 Not Found errors."""
|
||||
app.logger.info(f"Resource not found: {request.path}")
|
||||
|
||||
if wants_json():
|
||||
return jsonify({
|
||||
'error': 'Not Found',
|
||||
'message': 'The requested resource was not found'
|
||||
}), 404
|
||||
return render_template('errors/404.html', error=error), 404
|
||||
|
||||
@app.errorhandler(405)
|
||||
def method_not_allowed(error):
|
||||
"""Handle 405 Method Not Allowed errors."""
|
||||
app.logger.warning(f"Method not allowed: {request.method} {request.path}")
|
||||
|
||||
if wants_json():
|
||||
return jsonify({
|
||||
'error': 'Method Not Allowed',
|
||||
'message': 'The HTTP method is not allowed for this endpoint'
|
||||
}), 405
|
||||
return render_template('errors/405.html', error=error), 405
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_server_error(error):
|
||||
"""
|
||||
Handle 500 Internal Server Error.
|
||||
|
||||
Rolls back database session and logs full traceback.
|
||||
"""
|
||||
# Rollback database session on error
|
||||
try:
|
||||
app.db_session.rollback()
|
||||
except Exception as e:
|
||||
app.logger.error(f"Failed to rollback database session: {str(e)}")
|
||||
|
||||
# Log error with full context
|
||||
app.logger.error(
|
||||
f"Internal server error: {request.method} {request.path} - {str(error)}",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
if wants_json():
|
||||
return jsonify({
|
||||
'error': 'Internal Server Error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
return render_template('errors/500.html', error=error), 500
|
||||
|
||||
@app.errorhandler(SQLAlchemyError)
|
||||
def handle_db_error(error):
|
||||
"""
|
||||
Handle database errors.
|
||||
|
||||
Rolls back transaction and returns appropriate error response.
|
||||
"""
|
||||
# Rollback database session
|
||||
try:
|
||||
app.db_session.rollback()
|
||||
except Exception as e:
|
||||
app.logger.error(f"Failed to rollback database session: {str(e)}")
|
||||
|
||||
# Log database error
|
||||
app.logger.error(
|
||||
f"Database error: {request.method} {request.path} - {str(error)}",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
if wants_json():
|
||||
return jsonify({
|
||||
'error': 'Database Error',
|
||||
'message': 'A database error occurred'
|
||||
}), 500
|
||||
return render_template('errors/500.html', error=error), 500
|
||||
|
||||
|
||||
def register_request_handlers(app: Flask) -> None:
|
||||
"""
|
||||
Register request and response handlers.
|
||||
|
||||
Adds request ID generation, request/response logging with timing,
|
||||
and security headers.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
"""
|
||||
import time
|
||||
|
||||
@app.before_request
|
||||
def before_request_handler():
|
||||
"""
|
||||
Generate request ID and start timing.
|
||||
|
||||
Sets g.request_id and g.request_start_time for use in logging
|
||||
and timing calculations.
|
||||
"""
|
||||
# Generate unique request ID
|
||||
g.request_id = str(uuid.uuid4())[:8] # Short ID for readability
|
||||
g.request_start_time = time.time()
|
||||
|
||||
# Log incoming request with context
|
||||
user_info = 'anonymous'
|
||||
if current_user.is_authenticated:
|
||||
user_info = f'user:{current_user.get_id()}'
|
||||
|
||||
# Log at INFO level for API calls, DEBUG for other requests
|
||||
if request.path.startswith('/api/'):
|
||||
app.logger.info(
|
||||
f"→ {request.method} {request.path} "
|
||||
f"from={request.remote_addr} user={user_info}"
|
||||
)
|
||||
elif app.debug:
|
||||
app.logger.debug(
|
||||
f"→ {request.method} {request.path} "
|
||||
f"from={request.remote_addr}"
|
||||
)
|
||||
|
||||
@app.after_request
|
||||
def after_request_handler(response):
|
||||
"""
|
||||
Log response and add security headers.
|
||||
|
||||
Calculates request duration and logs response status.
|
||||
"""
|
||||
# Calculate request duration
|
||||
if hasattr(g, 'request_start_time'):
|
||||
duration_ms = (time.time() - g.request_start_time) * 1000
|
||||
|
||||
# Log response with duration
|
||||
if request.path.startswith('/api/'):
|
||||
# Log API responses at INFO level
|
||||
app.logger.info(
|
||||
f"← {request.method} {request.path} "
|
||||
f"status={response.status_code} "
|
||||
f"duration={duration_ms:.2f}ms"
|
||||
)
|
||||
elif app.debug:
|
||||
# Log web responses at DEBUG level in debug mode
|
||||
app.logger.debug(
|
||||
f"← {request.method} {request.path} "
|
||||
f"status={response.status_code} "
|
||||
f"duration={duration_ms:.2f}ms"
|
||||
)
|
||||
|
||||
# Add duration header for API responses
|
||||
if request.path.startswith('/api/'):
|
||||
response.headers['X-Request-Duration-Ms'] = f"{duration_ms:.2f}"
|
||||
response.headers['X-Request-ID'] = g.request_id
|
||||
|
||||
# Add security headers to all responses
|
||||
if request.path.startswith('/api/'):
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
response.headers['X-Frame-Options'] = 'DENY'
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
|
||||
return response
|
||||
|
||||
@app.teardown_request
|
||||
def teardown_request_handler(exception=None):
|
||||
"""
|
||||
Log errors that occur during request processing.
|
||||
|
||||
Args:
|
||||
exception: Exception that occurred, if any
|
||||
"""
|
||||
if exception:
|
||||
app.logger.error(
|
||||
f"Request failed: {request.method} {request.path} "
|
||||
f"error={type(exception).__name__}: {str(exception)}",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
|
||||
# Development server entry point
|
||||
def main():
|
||||
"""Run development server."""
|
||||
app = create_app()
|
||||
app.run(
|
||||
host=os.environ.get('FLASK_HOST', '0.0.0.0'),
|
||||
port=int(os.environ.get('FLASK_PORT', 5000)),
|
||||
debug=os.environ.get('FLASK_DEBUG', 'True').lower() == 'true'
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
9
app/web/auth/__init__.py
Normal file
9
app/web/auth/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Authentication package for SneakyScanner.
|
||||
|
||||
Provides Flask-Login based authentication with single-user support.
|
||||
"""
|
||||
|
||||
from web.auth.models import User
|
||||
|
||||
__all__ = ['User']
|
||||
65
app/web/auth/decorators.py
Normal file
65
app/web/auth/decorators.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Authentication decorators for SneakyScanner.
|
||||
|
||||
Provides decorators for protecting web routes and API endpoints.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
|
||||
from flask import jsonify, redirect, request, url_for
|
||||
from flask_login import current_user
|
||||
|
||||
|
||||
def login_required(f: Callable) -> Callable:
|
||||
"""
|
||||
Decorator for web routes that require authentication.
|
||||
|
||||
Redirects to login page if user is not authenticated.
|
||||
This is a wrapper around Flask-Login's login_required that can be
|
||||
customized if needed.
|
||||
|
||||
Args:
|
||||
f: Function to decorate
|
||||
|
||||
Returns:
|
||||
Decorated function
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
# Redirect to login page
|
||||
return redirect(url_for('auth.login', next=request.url))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def api_auth_required(f: Callable) -> Callable:
|
||||
"""
|
||||
Decorator for API endpoints that require authentication.
|
||||
|
||||
Returns 401 JSON response if user is not authenticated.
|
||||
Uses Flask-Login sessions (same as web UI).
|
||||
|
||||
Args:
|
||||
f: Function to decorate
|
||||
|
||||
Returns:
|
||||
Decorated function
|
||||
|
||||
Example:
|
||||
@bp.route('/api/scans', methods=['POST'])
|
||||
@api_auth_required
|
||||
def trigger_scan():
|
||||
# Protected endpoint
|
||||
pass
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return jsonify({
|
||||
'error': 'Authentication required',
|
||||
'message': 'Please authenticate to access this endpoint'
|
||||
}), 401
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
107
app/web/auth/models.py
Normal file
107
app/web/auth/models.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
User model for Flask-Login authentication.
|
||||
|
||||
Simple single-user model that loads credentials from the settings table.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from flask_login import UserMixin
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.utils.settings import PasswordManager, SettingsManager
|
||||
|
||||
|
||||
class User(UserMixin):
|
||||
"""
|
||||
User class for Flask-Login.
|
||||
|
||||
Represents the single application user. Credentials are stored in the
|
||||
settings table (app_password key).
|
||||
"""
|
||||
|
||||
# Single user ID (always 1 for single-user app)
|
||||
USER_ID = '1'
|
||||
|
||||
def __init__(self, user_id: str = USER_ID):
|
||||
"""
|
||||
Initialize user.
|
||||
|
||||
Args:
|
||||
user_id: User ID (always '1' for single-user app)
|
||||
"""
|
||||
self.id = user_id
|
||||
|
||||
def get_id(self) -> str:
|
||||
"""
|
||||
Get user ID for Flask-Login.
|
||||
|
||||
Returns:
|
||||
User ID string
|
||||
"""
|
||||
return self.id
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
"""User is always authenticated if instance exists."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""User is always active."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_anonymous(self) -> bool:
|
||||
"""User is never anonymous."""
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get(user_id: str, db_session: Session = None) -> Optional['User']:
|
||||
"""
|
||||
Get user by ID (Flask-Login user_loader).
|
||||
|
||||
Args:
|
||||
user_id: User ID to load
|
||||
db_session: Database session (unused - kept for compatibility)
|
||||
|
||||
Returns:
|
||||
User instance if ID is valid, None otherwise
|
||||
"""
|
||||
if user_id == User.USER_ID:
|
||||
return User(user_id)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def authenticate(password: str, db_session: Session) -> Optional['User']:
|
||||
"""
|
||||
Authenticate user with password.
|
||||
|
||||
Args:
|
||||
password: Password to verify
|
||||
db_session: Database session for accessing settings
|
||||
|
||||
Returns:
|
||||
User instance if password is correct, None otherwise
|
||||
"""
|
||||
settings_manager = SettingsManager(db_session)
|
||||
|
||||
if PasswordManager.verify_app_password(settings_manager, password):
|
||||
return User(User.USER_ID)
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def has_password_set(db_session: Session) -> bool:
|
||||
"""
|
||||
Check if application password is set.
|
||||
|
||||
Args:
|
||||
db_session: Database session for accessing settings
|
||||
|
||||
Returns:
|
||||
True if password is set, False otherwise
|
||||
"""
|
||||
settings_manager = SettingsManager(db_session)
|
||||
stored_hash = settings_manager.get('app_password', decrypt=False)
|
||||
return bool(stored_hash)
|
||||
120
app/web/auth/routes.py
Normal file
120
app/web/auth/routes.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Authentication routes for SneakyScanner.
|
||||
|
||||
Provides login and logout endpoints for user authentication.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for
|
||||
from flask_login import login_user, logout_user, current_user
|
||||
|
||||
from web.auth.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""
|
||||
Login page and authentication endpoint.
|
||||
|
||||
GET: Render login form
|
||||
POST: Authenticate user and create session
|
||||
|
||||
Returns:
|
||||
GET: Rendered login template
|
||||
POST: Redirect to dashboard on success, login page with error on failure
|
||||
"""
|
||||
# If already logged in, redirect to dashboard
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
# Check if password is set
|
||||
if not User.has_password_set(current_app.db_session):
|
||||
flash('Application password not set. Please contact administrator.', 'error')
|
||||
logger.warning("Login attempted but no password is set")
|
||||
return render_template('login.html', password_not_set=True)
|
||||
|
||||
if request.method == 'POST':
|
||||
password = request.form.get('password', '')
|
||||
|
||||
# Authenticate user
|
||||
user = User.authenticate(password, current_app.db_session)
|
||||
|
||||
if user:
|
||||
# Login successful
|
||||
login_user(user, remember=request.form.get('remember', False))
|
||||
logger.info(f"User logged in successfully from {request.remote_addr}")
|
||||
|
||||
# Redirect to next page or dashboard
|
||||
next_page = request.args.get('next')
|
||||
if next_page:
|
||||
return redirect(next_page)
|
||||
return redirect(url_for('main.dashboard'))
|
||||
else:
|
||||
# Login failed
|
||||
flash('Invalid password', 'error')
|
||||
logger.warning(f"Failed login attempt from {request.remote_addr}")
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
|
||||
@bp.route('/logout')
|
||||
def logout():
|
||||
"""
|
||||
Logout endpoint.
|
||||
|
||||
Destroys the user session and redirects to login page.
|
||||
|
||||
Returns:
|
||||
Redirect to login page
|
||||
"""
|
||||
if current_user.is_authenticated:
|
||||
logger.info(f"User logged out from {request.remote_addr}")
|
||||
logout_user()
|
||||
flash('You have been logged out successfully', 'info')
|
||||
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@bp.route('/setup', methods=['GET', 'POST'])
|
||||
def setup():
|
||||
"""
|
||||
Initial password setup page.
|
||||
|
||||
Only accessible when no password is set. Allows setting the application password.
|
||||
|
||||
Returns:
|
||||
GET: Rendered setup template
|
||||
POST: Redirect to login page on success
|
||||
"""
|
||||
# If password already set, redirect to login
|
||||
if User.has_password_set(current_app.db_session):
|
||||
flash('Password already set. Please login.', 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
if request.method == 'POST':
|
||||
password = request.form.get('password', '')
|
||||
confirm_password = request.form.get('confirm_password', '')
|
||||
|
||||
# Validate passwords
|
||||
if not password:
|
||||
flash('Password is required', 'error')
|
||||
elif len(password) < 8:
|
||||
flash('Password must be at least 8 characters', 'error')
|
||||
elif password != confirm_password:
|
||||
flash('Passwords do not match', 'error')
|
||||
else:
|
||||
# Set password
|
||||
from web.utils.settings import PasswordManager, SettingsManager
|
||||
settings_manager = SettingsManager(current_app.db_session)
|
||||
PasswordManager.set_app_password(settings_manager, password)
|
||||
|
||||
logger.info(f"Application password set from {request.remote_addr}")
|
||||
flash('Password set successfully! You can now login.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('setup.html')
|
||||
6
app/web/jobs/__init__.py
Normal file
6
app/web/jobs/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Background jobs package for SneakyScanner.
|
||||
|
||||
This package contains job definitions for background task execution,
|
||||
including scan jobs and scheduled tasks.
|
||||
"""
|
||||
158
app/web/jobs/scan_job.py
Normal file
158
app/web/jobs/scan_job.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Background scan job execution.
|
||||
|
||||
This module handles the execution of scans in background threads,
|
||||
updating database status and handling errors.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from src.scanner import SneakyScanner
|
||||
from web.models import Scan
|
||||
from web.services.scan_service import ScanService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def execute_scan(scan_id: int, config_file: str, db_url: str):
|
||||
"""
|
||||
Execute a scan in the background.
|
||||
|
||||
This function is designed to run in a background thread via APScheduler.
|
||||
It creates its own database session to avoid conflicts with the main
|
||||
application thread.
|
||||
|
||||
Args:
|
||||
scan_id: ID of the scan record in database
|
||||
config_file: Path to YAML configuration file
|
||||
db_url: Database connection URL
|
||||
|
||||
Workflow:
|
||||
1. Create new database session for this thread
|
||||
2. Update scan status to 'running'
|
||||
3. Execute scanner
|
||||
4. Generate output files (JSON, HTML, ZIP)
|
||||
5. Save results to database
|
||||
6. Update status to 'completed' or 'failed'
|
||||
"""
|
||||
logger.info(f"Starting background scan execution: scan_id={scan_id}, config={config_file}")
|
||||
|
||||
# Create new database session for this thread
|
||||
engine = create_engine(db_url, echo=False)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
try:
|
||||
# Get scan record
|
||||
scan = session.query(Scan).filter_by(id=scan_id).first()
|
||||
if not scan:
|
||||
logger.error(f"Scan {scan_id} not found in database")
|
||||
return
|
||||
|
||||
# Update status to running (in case it wasn't already)
|
||||
scan.status = 'running'
|
||||
scan.started_at = datetime.utcnow()
|
||||
session.commit()
|
||||
|
||||
logger.info(f"Scan {scan_id}: Initializing scanner with config {config_file}")
|
||||
|
||||
# Convert config_file to full path if it's just a filename
|
||||
if not config_file.startswith('/'):
|
||||
config_path = f'/app/configs/{config_file}'
|
||||
else:
|
||||
config_path = config_file
|
||||
|
||||
# Initialize scanner
|
||||
scanner = SneakyScanner(config_path)
|
||||
|
||||
# Execute scan
|
||||
logger.info(f"Scan {scan_id}: Running scanner...")
|
||||
start_time = datetime.utcnow()
|
||||
report, timestamp = scanner.scan()
|
||||
end_time = datetime.utcnow()
|
||||
|
||||
scan_duration = (end_time - start_time).total_seconds()
|
||||
logger.info(f"Scan {scan_id}: Scanner completed in {scan_duration:.2f} seconds")
|
||||
|
||||
# Generate output files (JSON, HTML, ZIP)
|
||||
logger.info(f"Scan {scan_id}: Generating output files...")
|
||||
scanner.generate_outputs(report, timestamp)
|
||||
|
||||
# Save results to database
|
||||
logger.info(f"Scan {scan_id}: Saving results to database...")
|
||||
scan_service = ScanService(session)
|
||||
scan_service._save_scan_to_db(report, scan_id, status='completed')
|
||||
|
||||
logger.info(f"Scan {scan_id}: Completed successfully")
|
||||
|
||||
except FileNotFoundError as e:
|
||||
# Config file not found
|
||||
error_msg = f"Configuration file not found: {str(e)}"
|
||||
logger.error(f"Scan {scan_id}: {error_msg}")
|
||||
|
||||
scan = session.query(Scan).filter_by(id=scan_id).first()
|
||||
if scan:
|
||||
scan.status = 'failed'
|
||||
scan.error_message = error_msg
|
||||
scan.completed_at = datetime.utcnow()
|
||||
session.commit()
|
||||
|
||||
except Exception as e:
|
||||
# Any other error during scan execution
|
||||
error_msg = f"Scan execution failed: {str(e)}"
|
||||
logger.error(f"Scan {scan_id}: {error_msg}")
|
||||
logger.error(f"Scan {scan_id}: Traceback:\n{traceback.format_exc()}")
|
||||
|
||||
try:
|
||||
scan = session.query(Scan).filter_by(id=scan_id).first()
|
||||
if scan:
|
||||
scan.status = 'failed'
|
||||
scan.error_message = error_msg
|
||||
scan.completed_at = datetime.utcnow()
|
||||
session.commit()
|
||||
except Exception as db_error:
|
||||
logger.error(f"Scan {scan_id}: Failed to update error status in database: {str(db_error)}")
|
||||
|
||||
finally:
|
||||
# Always close the session
|
||||
session.close()
|
||||
logger.info(f"Scan {scan_id}: Background job completed, session closed")
|
||||
|
||||
|
||||
def get_scan_status_from_db(scan_id: int, db_url: str) -> dict:
|
||||
"""
|
||||
Helper function to get scan status directly from database.
|
||||
|
||||
Useful for monitoring background jobs without needing Flask app context.
|
||||
|
||||
Args:
|
||||
scan_id: Scan ID to check
|
||||
db_url: Database connection URL
|
||||
|
||||
Returns:
|
||||
Dictionary with scan status information
|
||||
"""
|
||||
engine = create_engine(db_url, echo=False)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
try:
|
||||
scan = session.query(Scan).filter_by(id=scan_id).first()
|
||||
if not scan:
|
||||
return None
|
||||
|
||||
return {
|
||||
'scan_id': scan.id,
|
||||
'status': scan.status,
|
||||
'timestamp': scan.timestamp.isoformat() if scan.timestamp else None,
|
||||
'duration': scan.duration,
|
||||
'error_message': scan.error_message
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
348
app/web/models.py
Normal file
348
app/web/models.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
SQLAlchemy models for SneakyScanner database.
|
||||
|
||||
This module defines all database tables for storing scan results, schedules,
|
||||
alerts, and application settings. The schema supports the full scanning workflow
|
||||
from port discovery through service detection and SSL/TLS analysis.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, relationship
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all SQLAlchemy models."""
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Core Scan Tables
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class Scan(Base):
|
||||
"""
|
||||
Stores metadata about each scan execution.
|
||||
|
||||
This is the parent table that ties together all scan results including
|
||||
sites, IPs, ports, services, certificates, and TLS configuration.
|
||||
"""
|
||||
__tablename__ = 'scans'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
timestamp = Column(DateTime, nullable=False, index=True, comment="Scan start time (UTC)")
|
||||
duration = Column(Float, nullable=True, comment="Total scan duration in seconds")
|
||||
status = Column(String(20), nullable=False, default='running', comment="running, completed, failed")
|
||||
config_file = Column(Text, nullable=True, comment="Path to YAML config used")
|
||||
title = Column(Text, nullable=True, comment="Scan title from config")
|
||||
json_path = Column(Text, nullable=True, comment="Path to JSON report")
|
||||
html_path = Column(Text, nullable=True, comment="Path to HTML report")
|
||||
zip_path = Column(Text, nullable=True, comment="Path to ZIP archive")
|
||||
screenshot_dir = Column(Text, nullable=True, comment="Path to screenshot directory")
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Record creation time")
|
||||
triggered_by = Column(String(50), nullable=False, default='manual', comment="manual, scheduled, api")
|
||||
schedule_id = Column(Integer, ForeignKey('schedules.id'), nullable=True, comment="FK to schedules if triggered by schedule")
|
||||
started_at = Column(DateTime, nullable=True, comment="Scan execution start time")
|
||||
completed_at = Column(DateTime, nullable=True, comment="Scan execution completion time")
|
||||
error_message = Column(Text, nullable=True, comment="Error message if scan failed")
|
||||
|
||||
# Relationships
|
||||
sites = relationship('ScanSite', back_populates='scan', cascade='all, delete-orphan')
|
||||
ips = relationship('ScanIP', back_populates='scan', cascade='all, delete-orphan')
|
||||
ports = relationship('ScanPort', back_populates='scan', cascade='all, delete-orphan')
|
||||
services = relationship('ScanService', back_populates='scan', cascade='all, delete-orphan')
|
||||
certificates = relationship('ScanCertificate', back_populates='scan', cascade='all, delete-orphan')
|
||||
tls_versions = relationship('ScanTLSVersion', back_populates='scan', cascade='all, delete-orphan')
|
||||
alerts = relationship('Alert', back_populates='scan', cascade='all, delete-orphan')
|
||||
schedule = relationship('Schedule', back_populates='scans')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Scan(id={self.id}, title='{self.title}', status='{self.status}')>"
|
||||
|
||||
|
||||
class ScanSite(Base):
|
||||
"""
|
||||
Logical grouping of IPs by site.
|
||||
|
||||
Sites represent logical network segments or locations (e.g., "Production DC",
|
||||
"DMZ", "Branch Office") as defined in the scan configuration.
|
||||
"""
|
||||
__tablename__ = 'scan_sites'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
|
||||
site_name = Column(String(255), nullable=False, comment="Site name from config")
|
||||
|
||||
# Relationships
|
||||
scan = relationship('Scan', back_populates='sites')
|
||||
ips = relationship('ScanIP', back_populates='site', cascade='all, delete-orphan')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ScanSite(id={self.id}, site_name='{self.site_name}')>"
|
||||
|
||||
|
||||
class ScanIP(Base):
|
||||
"""
|
||||
IP addresses scanned in each scan.
|
||||
|
||||
Stores the target IPs and their ping response status (expected vs. actual).
|
||||
"""
|
||||
__tablename__ = 'scan_ips'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
|
||||
site_id = Column(Integer, ForeignKey('scan_sites.id'), nullable=False, index=True)
|
||||
ip_address = Column(String(45), nullable=False, comment="IPv4 or IPv6 address")
|
||||
ping_expected = Column(Boolean, nullable=True, comment="Expected ping response")
|
||||
ping_actual = Column(Boolean, nullable=True, comment="Actual ping response")
|
||||
|
||||
# Relationships
|
||||
scan = relationship('Scan', back_populates='ips')
|
||||
site = relationship('ScanSite', back_populates='ips')
|
||||
ports = relationship('ScanPort', back_populates='ip', cascade='all, delete-orphan')
|
||||
|
||||
# Index for efficient IP lookups within a scan
|
||||
__table_args__ = (
|
||||
UniqueConstraint('scan_id', 'ip_address', name='uix_scan_ip'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ScanIP(id={self.id}, ip_address='{self.ip_address}')>"
|
||||
|
||||
|
||||
class ScanPort(Base):
|
||||
"""
|
||||
Discovered TCP/UDP ports.
|
||||
|
||||
Stores all open ports found during masscan phase, along with expected vs.
|
||||
actual status for drift detection.
|
||||
"""
|
||||
__tablename__ = 'scan_ports'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
|
||||
ip_id = Column(Integer, ForeignKey('scan_ips.id'), nullable=False, index=True)
|
||||
port = Column(Integer, nullable=False, comment="Port number (1-65535)")
|
||||
protocol = Column(String(10), nullable=False, comment="tcp or udp")
|
||||
expected = Column(Boolean, nullable=True, comment="Was this port expected?")
|
||||
state = Column(String(20), nullable=False, default='open', comment="open, closed, filtered")
|
||||
|
||||
# Relationships
|
||||
scan = relationship('Scan', back_populates='ports')
|
||||
ip = relationship('ScanIP', back_populates='ports')
|
||||
services = relationship('ScanService', back_populates='port', cascade='all, delete-orphan')
|
||||
|
||||
# Index for efficient port lookups
|
||||
__table_args__ = (
|
||||
UniqueConstraint('scan_id', 'ip_id', 'port', 'protocol', name='uix_scan_ip_port'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ScanPort(id={self.id}, port={self.port}, protocol='{self.protocol}')>"
|
||||
|
||||
|
||||
class ScanService(Base):
|
||||
"""
|
||||
Detected services on open ports.
|
||||
|
||||
Stores nmap service detection results including product names, versions,
|
||||
and HTTP/HTTPS information with screenshots.
|
||||
"""
|
||||
__tablename__ = 'scan_services'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
|
||||
port_id = Column(Integer, ForeignKey('scan_ports.id'), nullable=False, index=True)
|
||||
service_name = Column(String(100), nullable=True, comment="Service name (e.g., ssh, http)")
|
||||
product = Column(String(255), nullable=True, comment="Product name (e.g., OpenSSH)")
|
||||
version = Column(String(100), nullable=True, comment="Version string")
|
||||
extrainfo = Column(Text, nullable=True, comment="Additional nmap info")
|
||||
ostype = Column(String(100), nullable=True, comment="OS type if detected")
|
||||
http_protocol = Column(String(10), nullable=True, comment="http or https (if web service)")
|
||||
screenshot_path = Column(Text, nullable=True, comment="Relative path to screenshot")
|
||||
|
||||
# Relationships
|
||||
scan = relationship('Scan', back_populates='services')
|
||||
port = relationship('ScanPort', back_populates='services')
|
||||
certificates = relationship('ScanCertificate', back_populates='service', cascade='all, delete-orphan')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ScanService(id={self.id}, service_name='{self.service_name}', product='{self.product}')>"
|
||||
|
||||
|
||||
class ScanCertificate(Base):
|
||||
"""
|
||||
SSL/TLS certificates discovered on HTTPS services.
|
||||
|
||||
Stores certificate details including validity periods, subject/issuer,
|
||||
and flags for self-signed certificates.
|
||||
"""
|
||||
__tablename__ = 'scan_certificates'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
|
||||
service_id = Column(Integer, ForeignKey('scan_services.id'), nullable=False, index=True)
|
||||
subject = Column(Text, nullable=True, comment="Certificate subject (CN)")
|
||||
issuer = Column(Text, nullable=True, comment="Certificate issuer")
|
||||
serial_number = Column(Text, nullable=True, comment="Serial number")
|
||||
not_valid_before = Column(DateTime, nullable=True, comment="Validity start date")
|
||||
not_valid_after = Column(DateTime, nullable=True, comment="Validity end date")
|
||||
days_until_expiry = Column(Integer, nullable=True, comment="Days until expiration")
|
||||
sans = Column(Text, nullable=True, comment="JSON array of SANs")
|
||||
is_self_signed = Column(Boolean, nullable=True, default=False, comment="Self-signed certificate flag")
|
||||
|
||||
# Relationships
|
||||
scan = relationship('Scan', back_populates='certificates')
|
||||
service = relationship('ScanService', back_populates='certificates')
|
||||
tls_versions = relationship('ScanTLSVersion', back_populates='certificate', cascade='all, delete-orphan')
|
||||
|
||||
# Index for certificate expiration queries
|
||||
__table_args__ = (
|
||||
{'comment': 'Index on expiration date for alert queries'},
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ScanCertificate(id={self.id}, subject='{self.subject}', days_until_expiry={self.days_until_expiry})>"
|
||||
|
||||
|
||||
class ScanTLSVersion(Base):
|
||||
"""
|
||||
TLS version support and cipher suites.
|
||||
|
||||
Stores which TLS versions (1.0, 1.1, 1.2, 1.3) are supported by each
|
||||
HTTPS service, along with accepted cipher suites.
|
||||
"""
|
||||
__tablename__ = 'scan_tls_versions'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
|
||||
certificate_id = Column(Integer, ForeignKey('scan_certificates.id'), nullable=False, index=True)
|
||||
tls_version = Column(String(20), nullable=False, comment="TLS 1.0, TLS 1.1, TLS 1.2, TLS 1.3")
|
||||
supported = Column(Boolean, nullable=False, comment="Is this version supported?")
|
||||
cipher_suites = Column(Text, nullable=True, comment="JSON array of cipher suites")
|
||||
|
||||
# Relationships
|
||||
scan = relationship('Scan', back_populates='tls_versions')
|
||||
certificate = relationship('ScanCertificate', back_populates='tls_versions')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ScanTLSVersion(id={self.id}, tls_version='{self.tls_version}', supported={self.supported})>"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Scheduling & Notifications Tables
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class Schedule(Base):
|
||||
"""
|
||||
Scheduled scan configurations.
|
||||
|
||||
Stores cron-like schedules for automated periodic scanning of network
|
||||
infrastructure.
|
||||
"""
|
||||
__tablename__ = 'schedules'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(255), nullable=False, comment="Schedule name (e.g., 'Daily prod scan')")
|
||||
config_file = Column(Text, nullable=False, comment="Path to YAML config")
|
||||
cron_expression = Column(String(100), nullable=False, comment="Cron-like schedule (e.g., '0 2 * * *')")
|
||||
enabled = Column(Boolean, nullable=False, default=True, comment="Is schedule active?")
|
||||
last_run = Column(DateTime, nullable=True, comment="Last execution time")
|
||||
next_run = Column(DateTime, nullable=True, comment="Next scheduled execution")
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Schedule creation time")
|
||||
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow, comment="Last modification time")
|
||||
|
||||
# Relationships
|
||||
scans = relationship('Scan', back_populates='schedule')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Schedule(id={self.id}, name='{self.name}', enabled={self.enabled})>"
|
||||
|
||||
|
||||
class Alert(Base):
|
||||
"""
|
||||
Alert history and notifications sent.
|
||||
|
||||
Stores all alerts generated by the alert rule engine, including severity
|
||||
levels and email notification status.
|
||||
"""
|
||||
__tablename__ = 'alerts'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
|
||||
alert_type = Column(String(50), nullable=False, comment="new_port, cert_expiry, service_change, ping_failed")
|
||||
severity = Column(String(20), nullable=False, comment="info, warning, critical")
|
||||
message = Column(Text, nullable=False, comment="Human-readable alert message")
|
||||
ip_address = Column(String(45), nullable=True, comment="Related IP (optional)")
|
||||
port = Column(Integer, nullable=True, comment="Related port (optional)")
|
||||
email_sent = Column(Boolean, nullable=False, default=False, comment="Was email notification sent?")
|
||||
email_sent_at = Column(DateTime, nullable=True, comment="Email send timestamp")
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Alert creation time")
|
||||
|
||||
# Relationships
|
||||
scan = relationship('Scan', back_populates='alerts')
|
||||
|
||||
# Index for alert queries by type and severity
|
||||
__table_args__ = (
|
||||
{'comment': 'Indexes for alert filtering'},
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Alert(id={self.id}, alert_type='{self.alert_type}', severity='{self.severity}')>"
|
||||
|
||||
|
||||
class AlertRule(Base):
|
||||
"""
|
||||
User-defined alert rules.
|
||||
|
||||
Configurable rules that trigger alerts based on scan results (e.g.,
|
||||
certificates expiring in <30 days, unexpected ports opened).
|
||||
"""
|
||||
__tablename__ = 'alert_rules'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
rule_type = Column(String(50), nullable=False, comment="unexpected_port, cert_expiry, service_down, etc.")
|
||||
enabled = Column(Boolean, nullable=False, default=True, comment="Is rule active?")
|
||||
threshold = Column(Integer, nullable=True, comment="Threshold value (e.g., days for cert expiry)")
|
||||
email_enabled = Column(Boolean, nullable=False, default=False, comment="Send email for this rule?")
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Rule creation time")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AlertRule(id={self.id}, rule_type='{self.rule_type}', enabled={self.enabled})>"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Settings Table
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
"""
|
||||
Application configuration key-value store.
|
||||
|
||||
Stores application settings including SMTP configuration, authentication,
|
||||
and retention policies. Values stored as JSON for complex data types.
|
||||
"""
|
||||
__tablename__ = 'settings'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
key = Column(String(255), nullable=False, unique=True, index=True, comment="Setting key (e.g., smtp_server)")
|
||||
value = Column(Text, nullable=True, comment="Setting value (JSON for complex values)")
|
||||
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow, comment="Last modification time")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Setting(key='{self.key}', value='{self.value[:50] if self.value else None}...')>"
|
||||
5
app/web/routes/__init__.py
Normal file
5
app/web/routes/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Main web routes package for SneakyScanner.
|
||||
|
||||
Provides web UI routes (dashboard, scan views, etc.).
|
||||
"""
|
||||
221
app/web/routes/main.py
Normal file
221
app/web/routes/main.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Main web routes for SneakyScanner.
|
||||
|
||||
Provides dashboard and scan viewing pages.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, current_app, redirect, render_template, url_for
|
||||
|
||||
from web.auth.decorators import login_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def index():
|
||||
"""
|
||||
Root route - redirect to dashboard.
|
||||
|
||||
Returns:
|
||||
Redirect to dashboard
|
||||
"""
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
|
||||
@bp.route('/dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""
|
||||
Dashboard page - shows recent scans and statistics.
|
||||
|
||||
Returns:
|
||||
Rendered dashboard template
|
||||
"""
|
||||
import os
|
||||
|
||||
# Get list of available config files
|
||||
configs_dir = '/app/configs'
|
||||
config_files = []
|
||||
|
||||
try:
|
||||
if os.path.exists(configs_dir):
|
||||
config_files = [f for f in os.listdir(configs_dir) if f.endswith(('.yaml', '.yml'))]
|
||||
config_files.sort()
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing config files: {e}")
|
||||
|
||||
return render_template('dashboard.html', config_files=config_files)
|
||||
|
||||
|
||||
@bp.route('/scans')
|
||||
@login_required
|
||||
def scans():
|
||||
"""
|
||||
Scans list page - shows all scans with pagination.
|
||||
|
||||
Returns:
|
||||
Rendered scans list template
|
||||
"""
|
||||
import os
|
||||
|
||||
# Get list of available config files
|
||||
configs_dir = '/app/configs'
|
||||
config_files = []
|
||||
|
||||
try:
|
||||
if os.path.exists(configs_dir):
|
||||
config_files = [f for f in os.listdir(configs_dir) if f.endswith(('.yaml', '.yml'))]
|
||||
config_files.sort()
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing config files: {e}")
|
||||
|
||||
return render_template('scans.html', config_files=config_files)
|
||||
|
||||
|
||||
@bp.route('/scans/<int:scan_id>')
|
||||
@login_required
|
||||
def scan_detail(scan_id):
|
||||
"""
|
||||
Scan detail page - shows full scan results.
|
||||
|
||||
Args:
|
||||
scan_id: Scan ID to display
|
||||
|
||||
Returns:
|
||||
Rendered scan detail template
|
||||
"""
|
||||
# TODO: Phase 5 - Implement scan detail page
|
||||
return render_template('scan_detail.html', scan_id=scan_id)
|
||||
|
||||
|
||||
@bp.route('/scans/<int:scan_id1>/compare/<int:scan_id2>')
|
||||
@login_required
|
||||
def compare_scans(scan_id1, scan_id2):
|
||||
"""
|
||||
Scan comparison page - shows differences between two scans.
|
||||
|
||||
Args:
|
||||
scan_id1: First (older) scan ID
|
||||
scan_id2: Second (newer) scan ID
|
||||
|
||||
Returns:
|
||||
Rendered comparison template
|
||||
"""
|
||||
return render_template('scan_compare.html', scan_id1=scan_id1, scan_id2=scan_id2)
|
||||
|
||||
|
||||
@bp.route('/schedules')
|
||||
@login_required
|
||||
def schedules():
|
||||
"""
|
||||
Schedules list page - shows all scheduled scans.
|
||||
|
||||
Returns:
|
||||
Rendered schedules list template
|
||||
"""
|
||||
return render_template('schedules.html')
|
||||
|
||||
|
||||
@bp.route('/schedules/create')
|
||||
@login_required
|
||||
def create_schedule():
|
||||
"""
|
||||
Create new schedule form page.
|
||||
|
||||
Returns:
|
||||
Rendered schedule create template with available config files
|
||||
"""
|
||||
import os
|
||||
|
||||
# Get list of available config files
|
||||
configs_dir = '/app/configs'
|
||||
config_files = []
|
||||
|
||||
try:
|
||||
if os.path.exists(configs_dir):
|
||||
config_files = [f for f in os.listdir(configs_dir) if f.endswith('.yaml')]
|
||||
config_files.sort()
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing config files: {e}")
|
||||
|
||||
return render_template('schedule_create.html', config_files=config_files)
|
||||
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/edit')
|
||||
@login_required
|
||||
def edit_schedule(schedule_id):
|
||||
"""
|
||||
Edit existing schedule form page.
|
||||
|
||||
Args:
|
||||
schedule_id: Schedule ID to edit
|
||||
|
||||
Returns:
|
||||
Rendered schedule edit template
|
||||
"""
|
||||
from flask import flash
|
||||
|
||||
# Note: Schedule data is loaded via AJAX in the template
|
||||
# This just renders the page with the schedule_id in the URL
|
||||
return render_template('schedule_edit.html', schedule_id=schedule_id)
|
||||
|
||||
|
||||
@bp.route('/configs')
|
||||
@login_required
|
||||
def configs():
|
||||
"""
|
||||
Configuration files list page - shows all config files.
|
||||
|
||||
Returns:
|
||||
Rendered configs list template
|
||||
"""
|
||||
return render_template('configs.html')
|
||||
|
||||
|
||||
@bp.route('/configs/upload')
|
||||
@login_required
|
||||
def upload_config():
|
||||
"""
|
||||
Config upload page - allows CIDR/YAML upload.
|
||||
|
||||
Returns:
|
||||
Rendered config upload template
|
||||
"""
|
||||
return render_template('config_upload.html')
|
||||
|
||||
|
||||
@bp.route('/configs/edit/<filename>')
|
||||
@login_required
|
||||
def edit_config(filename):
|
||||
"""
|
||||
Config edit page - allows editing YAML configuration.
|
||||
|
||||
Args:
|
||||
filename: Config filename to edit
|
||||
|
||||
Returns:
|
||||
Rendered config edit template
|
||||
"""
|
||||
from web.services.config_service import ConfigService
|
||||
from flask import flash, redirect
|
||||
|
||||
try:
|
||||
config_service = ConfigService()
|
||||
config_data = config_service.get_config(filename)
|
||||
|
||||
return render_template(
|
||||
'config_edit.html',
|
||||
filename=config_data['filename'],
|
||||
content=config_data['content']
|
||||
)
|
||||
except FileNotFoundError:
|
||||
flash(f"Config file '{filename}' not found", 'error')
|
||||
return redirect(url_for('main.configs'))
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading config for edit: {e}")
|
||||
flash(f"Error loading config: {str(e)}", 'error')
|
||||
return redirect(url_for('main.configs'))
|
||||
10
app/web/services/__init__.py
Normal file
10
app/web/services/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Services package for SneakyScanner web application.
|
||||
|
||||
This package contains business logic layer services that orchestrate
|
||||
operations between API endpoints and database models.
|
||||
"""
|
||||
|
||||
from web.services.scan_service import ScanService
|
||||
|
||||
__all__ = ['ScanService']
|
||||
552
app/web/services/config_service.py
Normal file
552
app/web/services/config_service.py
Normal file
@@ -0,0 +1,552 @@
|
||||
"""
|
||||
Config Service - Business logic for config file management
|
||||
|
||||
This service handles all operations related to scan configuration files,
|
||||
including creation, validation, listing, and deletion.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import yaml
|
||||
import ipaddress
|
||||
from typing import Dict, List, Tuple, Any, Optional
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
|
||||
class ConfigService:
|
||||
"""Business logic for config management"""
|
||||
|
||||
def __init__(self, configs_dir: str = '/app/configs'):
|
||||
"""
|
||||
Initialize the config service.
|
||||
|
||||
Args:
|
||||
configs_dir: Directory where config files are stored
|
||||
"""
|
||||
self.configs_dir = configs_dir
|
||||
|
||||
# Ensure configs directory exists
|
||||
os.makedirs(self.configs_dir, exist_ok=True)
|
||||
|
||||
def list_configs(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all config files with metadata.
|
||||
|
||||
Returns:
|
||||
List of config metadata dictionaries:
|
||||
[
|
||||
{
|
||||
"filename": "prod-scan.yaml",
|
||||
"title": "Prod Scan",
|
||||
"path": "/app/configs/prod-scan.yaml",
|
||||
"created_at": "2025-11-15T10:30:00Z",
|
||||
"size_bytes": 1234,
|
||||
"used_by_schedules": ["Daily Scan", "Weekly Audit"]
|
||||
}
|
||||
]
|
||||
"""
|
||||
configs = []
|
||||
|
||||
# Get all YAML files in configs directory
|
||||
if not os.path.exists(self.configs_dir):
|
||||
return configs
|
||||
|
||||
for filename in os.listdir(self.configs_dir):
|
||||
if not filename.endswith(('.yaml', '.yml')):
|
||||
continue
|
||||
|
||||
filepath = os.path.join(self.configs_dir, filename)
|
||||
|
||||
if not os.path.isfile(filepath):
|
||||
continue
|
||||
|
||||
try:
|
||||
# Get file metadata
|
||||
stat_info = os.stat(filepath)
|
||||
created_at = datetime.fromtimestamp(stat_info.st_mtime).isoformat() + 'Z'
|
||||
size_bytes = stat_info.st_size
|
||||
|
||||
# Parse YAML to get title
|
||||
title = None
|
||||
try:
|
||||
with open(filepath, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
if isinstance(data, dict):
|
||||
title = data.get('title', filename)
|
||||
except Exception:
|
||||
title = filename # Fallback to filename if parsing fails
|
||||
|
||||
# Get schedules using this config
|
||||
used_by_schedules = self.get_schedules_using_config(filename)
|
||||
|
||||
configs.append({
|
||||
'filename': filename,
|
||||
'title': title,
|
||||
'path': filepath,
|
||||
'created_at': created_at,
|
||||
'size_bytes': size_bytes,
|
||||
'used_by_schedules': used_by_schedules
|
||||
})
|
||||
except Exception as e:
|
||||
# Skip files that can't be read
|
||||
continue
|
||||
|
||||
# Sort by created_at (most recent first)
|
||||
configs.sort(key=lambda x: x['created_at'], reverse=True)
|
||||
|
||||
return configs
|
||||
|
||||
def get_config(self, filename: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get config file content and parsed data.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
|
||||
Returns:
|
||||
{
|
||||
"filename": "prod-scan.yaml",
|
||||
"content": "title: Prod Scan\n...",
|
||||
"parsed": {"title": "Prod Scan", "sites": [...]}
|
||||
}
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config doesn't exist
|
||||
ValueError: If config content is invalid
|
||||
"""
|
||||
filepath = os.path.join(self.configs_dir, filename)
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
raise FileNotFoundError(f"Config file '{filename}' not found")
|
||||
|
||||
# Read file content
|
||||
with open(filepath, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Parse YAML
|
||||
try:
|
||||
parsed = yaml.safe_load(content)
|
||||
except yaml.YAMLError as e:
|
||||
raise ValueError(f"Invalid YAML syntax: {str(e)}")
|
||||
|
||||
return {
|
||||
'filename': filename,
|
||||
'content': content,
|
||||
'parsed': parsed
|
||||
}
|
||||
|
||||
def create_from_yaml(self, filename: str, content: str) -> str:
|
||||
"""
|
||||
Create config from YAML content.
|
||||
|
||||
Args:
|
||||
filename: Desired filename (will be sanitized)
|
||||
content: YAML content string
|
||||
|
||||
Returns:
|
||||
Final filename (sanitized)
|
||||
|
||||
Raises:
|
||||
ValueError: If content invalid or filename conflict
|
||||
"""
|
||||
# Sanitize filename
|
||||
filename = secure_filename(filename)
|
||||
|
||||
# Ensure .yaml extension
|
||||
if not filename.endswith(('.yaml', '.yml')):
|
||||
filename += '.yaml'
|
||||
|
||||
filepath = os.path.join(self.configs_dir, filename)
|
||||
|
||||
# Check for conflicts
|
||||
if os.path.exists(filepath):
|
||||
raise ValueError(f"Config file '{filename}' already exists")
|
||||
|
||||
# Parse and validate YAML
|
||||
try:
|
||||
parsed = yaml.safe_load(content)
|
||||
except yaml.YAMLError as e:
|
||||
raise ValueError(f"Invalid YAML syntax: {str(e)}")
|
||||
|
||||
# Validate config structure
|
||||
is_valid, error_msg = self.validate_config_content(parsed)
|
||||
if not is_valid:
|
||||
raise ValueError(f"Invalid config structure: {error_msg}")
|
||||
|
||||
# Write file
|
||||
with open(filepath, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
return filename
|
||||
|
||||
def create_from_cidr(
|
||||
self,
|
||||
title: str,
|
||||
cidr: str,
|
||||
site_name: Optional[str] = None,
|
||||
ping_default: bool = False
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Create config from CIDR range.
|
||||
|
||||
Args:
|
||||
title: Scan configuration title
|
||||
cidr: CIDR range (e.g., "10.0.0.0/24")
|
||||
site_name: Optional site name (defaults to "Site 1")
|
||||
ping_default: Default ping expectation for all IPs
|
||||
|
||||
Returns:
|
||||
Tuple of (final_filename, yaml_content)
|
||||
|
||||
Raises:
|
||||
ValueError: If CIDR invalid or other validation errors
|
||||
"""
|
||||
# Validate and parse CIDR
|
||||
try:
|
||||
network = ipaddress.ip_network(cidr, strict=False)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Invalid CIDR range: {str(e)}")
|
||||
|
||||
# Check if network is too large (prevent expansion of huge ranges)
|
||||
if network.num_addresses > 10000:
|
||||
raise ValueError(f"CIDR range too large: {network.num_addresses} addresses. Maximum is 10,000.")
|
||||
|
||||
# Expand CIDR to list of IP addresses
|
||||
ip_list = [str(ip) for ip in network.hosts()]
|
||||
|
||||
# If network has only 1 address (like /32 or /128), hosts() returns empty
|
||||
# In that case, use the network address itself
|
||||
if not ip_list:
|
||||
ip_list = [str(network.network_address)]
|
||||
|
||||
# Build site name
|
||||
if not site_name or not site_name.strip():
|
||||
site_name = "Site 1"
|
||||
|
||||
# Build IP configurations
|
||||
ips = []
|
||||
for ip_address in ip_list:
|
||||
ips.append({
|
||||
'address': ip_address,
|
||||
'expected': {
|
||||
'ping': ping_default,
|
||||
'tcp_ports': [],
|
||||
'udp_ports': []
|
||||
}
|
||||
})
|
||||
|
||||
# Build YAML structure
|
||||
config_data = {
|
||||
'title': title.strip(),
|
||||
'sites': [
|
||||
{
|
||||
'name': site_name.strip(),
|
||||
'ips': ips
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Convert to YAML string
|
||||
yaml_content = yaml.dump(config_data, sort_keys=False, default_flow_style=False)
|
||||
|
||||
# Generate filename from title
|
||||
filename = self.generate_filename_from_title(title)
|
||||
|
||||
filepath = os.path.join(self.configs_dir, filename)
|
||||
|
||||
# Check for conflicts
|
||||
if os.path.exists(filepath):
|
||||
raise ValueError(f"Config file '{filename}' already exists")
|
||||
|
||||
# Write file
|
||||
with open(filepath, 'w') as f:
|
||||
f.write(yaml_content)
|
||||
|
||||
return filename, yaml_content
|
||||
|
||||
def update_config(self, filename: str, yaml_content: str) -> None:
|
||||
"""
|
||||
Update existing config file with new YAML content.
|
||||
|
||||
Args:
|
||||
filename: Config filename to update
|
||||
yaml_content: New YAML content string
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config doesn't exist
|
||||
ValueError: If YAML content is invalid
|
||||
"""
|
||||
filepath = os.path.join(self.configs_dir, filename)
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(filepath):
|
||||
raise FileNotFoundError(f"Config file '{filename}' not found")
|
||||
|
||||
# Parse and validate YAML
|
||||
try:
|
||||
parsed = yaml.safe_load(yaml_content)
|
||||
except yaml.YAMLError as e:
|
||||
raise ValueError(f"Invalid YAML syntax: {str(e)}")
|
||||
|
||||
# Validate config structure
|
||||
is_valid, error_msg = self.validate_config_content(parsed)
|
||||
if not is_valid:
|
||||
raise ValueError(f"Invalid config structure: {error_msg}")
|
||||
|
||||
# Write updated content
|
||||
with open(filepath, 'w') as f:
|
||||
f.write(yaml_content)
|
||||
|
||||
def delete_config(self, filename: str) -> None:
|
||||
"""
|
||||
Delete config file and cascade delete any associated schedules.
|
||||
|
||||
When a config is deleted, all schedules using that config (both enabled
|
||||
and disabled) are automatically deleted as well, since they would be
|
||||
invalid without the config file.
|
||||
|
||||
Args:
|
||||
filename: Config filename to delete
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config doesn't exist
|
||||
"""
|
||||
filepath = os.path.join(self.configs_dir, filename)
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
raise FileNotFoundError(f"Config file '{filename}' not found")
|
||||
|
||||
# Delete any schedules using this config (both enabled and disabled)
|
||||
try:
|
||||
from web.services.schedule_service import ScheduleService
|
||||
from flask import current_app
|
||||
|
||||
# Get database session from Flask app
|
||||
db = current_app.db_session
|
||||
|
||||
# Get all schedules
|
||||
schedule_service = ScheduleService(db)
|
||||
result = schedule_service.list_schedules(page=1, per_page=10000)
|
||||
schedules = result.get('schedules', [])
|
||||
|
||||
# Build full path for comparison
|
||||
config_path = os.path.join(self.configs_dir, filename)
|
||||
|
||||
# Find and delete all schedules using this config (enabled or disabled)
|
||||
deleted_schedules = []
|
||||
for schedule in schedules:
|
||||
schedule_config = schedule.get('config_file', '')
|
||||
|
||||
# Handle both absolute paths and just filenames
|
||||
if schedule_config == filename or schedule_config == config_path:
|
||||
schedule_id = schedule.get('id')
|
||||
schedule_name = schedule.get('name', 'Unknown')
|
||||
try:
|
||||
schedule_service.delete_schedule(schedule_id)
|
||||
deleted_schedules.append(schedule_name)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
f"Failed to delete schedule {schedule_id} ('{schedule_name}'): {e}"
|
||||
)
|
||||
|
||||
if deleted_schedules:
|
||||
import logging
|
||||
logging.getLogger(__name__).info(
|
||||
f"Cascade deleted {len(deleted_schedules)} schedule(s) associated with config '{filename}': {', '.join(deleted_schedules)}"
|
||||
)
|
||||
|
||||
except ImportError:
|
||||
# If ScheduleService doesn't exist yet, skip schedule deletion
|
||||
pass
|
||||
except Exception as e:
|
||||
# Log error but continue with config deletion
|
||||
import logging
|
||||
logging.getLogger(__name__).error(
|
||||
f"Error deleting schedules for config {filename}: {e}", exc_info=True
|
||||
)
|
||||
|
||||
# Delete file
|
||||
os.remove(filepath)
|
||||
|
||||
def validate_config_content(self, content: Dict) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate parsed YAML config structure.
|
||||
|
||||
Args:
|
||||
content: Parsed YAML config as dict
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not isinstance(content, dict):
|
||||
return False, "Config must be a dictionary/object"
|
||||
|
||||
# Check required fields
|
||||
if 'title' not in content:
|
||||
return False, "Missing required field: 'title'"
|
||||
|
||||
if 'sites' not in content:
|
||||
return False, "Missing required field: 'sites'"
|
||||
|
||||
# Validate title
|
||||
if not isinstance(content['title'], str) or not content['title'].strip():
|
||||
return False, "Field 'title' must be a non-empty string"
|
||||
|
||||
# Validate sites
|
||||
sites = content['sites']
|
||||
if not isinstance(sites, list):
|
||||
return False, "Field 'sites' must be a list"
|
||||
|
||||
if len(sites) == 0:
|
||||
return False, "Must have at least one site defined"
|
||||
|
||||
# Validate each site
|
||||
for i, site in enumerate(sites):
|
||||
if not isinstance(site, dict):
|
||||
return False, f"Site {i+1} must be a dictionary/object"
|
||||
|
||||
if 'name' not in site:
|
||||
return False, f"Site {i+1} missing required field: 'name'"
|
||||
|
||||
if 'ips' not in site:
|
||||
return False, f"Site {i+1} missing required field: 'ips'"
|
||||
|
||||
if not isinstance(site['ips'], list):
|
||||
return False, f"Site {i+1} field 'ips' must be a list"
|
||||
|
||||
if len(site['ips']) == 0:
|
||||
return False, f"Site {i+1} must have at least one IP"
|
||||
|
||||
# Validate each IP
|
||||
for j, ip_config in enumerate(site['ips']):
|
||||
if not isinstance(ip_config, dict):
|
||||
return False, f"Site {i+1} IP {j+1} must be a dictionary/object"
|
||||
|
||||
if 'address' not in ip_config:
|
||||
return False, f"Site {i+1} IP {j+1} missing required field: 'address'"
|
||||
|
||||
if 'expected' not in ip_config:
|
||||
return False, f"Site {i+1} IP {j+1} missing required field: 'expected'"
|
||||
|
||||
if not isinstance(ip_config['expected'], dict):
|
||||
return False, f"Site {i+1} IP {j+1} field 'expected' must be a dictionary/object"
|
||||
|
||||
return True, ""
|
||||
|
||||
def get_schedules_using_config(self, filename: str) -> List[str]:
|
||||
"""
|
||||
Get list of schedule names using this config.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
|
||||
Returns:
|
||||
List of schedule names (e.g., ["Daily Scan", "Weekly Audit"])
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
try:
|
||||
from web.services.schedule_service import ScheduleService
|
||||
from flask import current_app
|
||||
|
||||
# Get database session from Flask app
|
||||
db = current_app.db_session
|
||||
|
||||
# Get all schedules (use large per_page to get all)
|
||||
schedule_service = ScheduleService(db)
|
||||
result = schedule_service.list_schedules(page=1, per_page=10000)
|
||||
|
||||
# Extract schedules list from paginated result
|
||||
schedules = result.get('schedules', [])
|
||||
|
||||
# Build full path for comparison
|
||||
config_path = os.path.join(self.configs_dir, filename)
|
||||
|
||||
# Find schedules using this config (only enabled schedules)
|
||||
using_schedules = []
|
||||
for schedule in schedules:
|
||||
schedule_config = schedule.get('config_file', '')
|
||||
|
||||
# Handle both absolute paths and just filenames
|
||||
if schedule_config == filename or schedule_config == config_path:
|
||||
# Only count enabled schedules
|
||||
if schedule.get('enabled', False):
|
||||
using_schedules.append(schedule.get('name', 'Unknown'))
|
||||
|
||||
return using_schedules
|
||||
|
||||
except ImportError:
|
||||
# If ScheduleService doesn't exist yet, return empty list
|
||||
return []
|
||||
except Exception as e:
|
||||
# If any error occurs, return empty list (safer than failing)
|
||||
# Log the error for debugging
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Error getting schedules using config {filename}: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
def generate_filename_from_title(self, title: str) -> str:
|
||||
"""
|
||||
Generate safe filename from scan title.
|
||||
|
||||
Args:
|
||||
title: Scan title string
|
||||
|
||||
Returns:
|
||||
Safe filename (e.g., "Prod Scan 2025" -> "prod-scan-2025.yaml")
|
||||
"""
|
||||
# Convert to lowercase
|
||||
filename = title.lower()
|
||||
|
||||
# Replace spaces with hyphens
|
||||
filename = filename.replace(' ', '-')
|
||||
|
||||
# Remove special characters (keep only alphanumeric, hyphens, underscores)
|
||||
filename = re.sub(r'[^a-z0-9\-_]', '', filename)
|
||||
|
||||
# Remove consecutive hyphens
|
||||
filename = re.sub(r'-+', '-', filename)
|
||||
|
||||
# Remove leading/trailing hyphens
|
||||
filename = filename.strip('-')
|
||||
|
||||
# Limit length (max 200 chars, reserve 5 for .yaml)
|
||||
max_length = 195
|
||||
if len(filename) > max_length:
|
||||
filename = filename[:max_length]
|
||||
|
||||
# Ensure not empty
|
||||
if not filename:
|
||||
filename = 'config'
|
||||
|
||||
# Add .yaml extension
|
||||
filename += '.yaml'
|
||||
|
||||
return filename
|
||||
|
||||
def get_config_path(self, filename: str) -> str:
|
||||
"""
|
||||
Get absolute path for a config file.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
|
||||
Returns:
|
||||
Absolute path to config file
|
||||
"""
|
||||
return os.path.join(self.configs_dir, filename)
|
||||
|
||||
def config_exists(self, filename: str) -> bool:
|
||||
"""
|
||||
Check if a config file exists.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
|
||||
Returns:
|
||||
True if file exists, False otherwise
|
||||
"""
|
||||
filepath = os.path.join(self.configs_dir, filename)
|
||||
return os.path.exists(filepath) and os.path.isfile(filepath)
|
||||
1013
app/web/services/scan_service.py
Normal file
1013
app/web/services/scan_service.py
Normal file
File diff suppressed because it is too large
Load Diff
483
app/web/services/schedule_service.py
Normal file
483
app/web/services/schedule_service.py
Normal file
@@ -0,0 +1,483 @@
|
||||
"""
|
||||
Schedule service for managing scheduled scan operations.
|
||||
|
||||
This service handles the business logic for creating, updating, and managing
|
||||
scheduled scans with cron expressions.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from croniter import croniter
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.models import Schedule, Scan
|
||||
from web.utils.pagination import paginate, PaginatedResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScheduleService:
|
||||
"""
|
||||
Service for managing scheduled scans.
|
||||
|
||||
Handles schedule lifecycle: creation, validation, updating,
|
||||
and cron expression processing.
|
||||
"""
|
||||
|
||||
def __init__(self, db_session: Session):
|
||||
"""
|
||||
Initialize schedule service.
|
||||
|
||||
Args:
|
||||
db_session: SQLAlchemy database session
|
||||
"""
|
||||
self.db = db_session
|
||||
|
||||
def create_schedule(
|
||||
self,
|
||||
name: str,
|
||||
config_file: str,
|
||||
cron_expression: str,
|
||||
enabled: bool = True
|
||||
) -> int:
|
||||
"""
|
||||
Create a new schedule.
|
||||
|
||||
Args:
|
||||
name: Human-readable schedule name
|
||||
config_file: Path to YAML configuration file
|
||||
cron_expression: Cron expression (e.g., '0 2 * * *')
|
||||
enabled: Whether schedule is active
|
||||
|
||||
Returns:
|
||||
Schedule ID of the created schedule
|
||||
|
||||
Raises:
|
||||
ValueError: If cron expression is invalid or config file doesn't exist
|
||||
"""
|
||||
# Validate cron expression
|
||||
is_valid, error_msg = self.validate_cron_expression(cron_expression)
|
||||
if not is_valid:
|
||||
raise ValueError(f"Invalid cron expression: {error_msg}")
|
||||
|
||||
# Validate config file exists
|
||||
# If config_file is just a filename, prepend the configs directory
|
||||
if not config_file.startswith('/'):
|
||||
config_file_path = os.path.join('/app/configs', config_file)
|
||||
else:
|
||||
config_file_path = config_file
|
||||
|
||||
if not os.path.isfile(config_file_path):
|
||||
raise ValueError(f"Config file not found: {config_file}")
|
||||
|
||||
# Calculate next run time
|
||||
next_run = self.calculate_next_run(cron_expression) if enabled else None
|
||||
|
||||
# Create schedule record
|
||||
schedule = Schedule(
|
||||
name=name,
|
||||
config_file=config_file,
|
||||
cron_expression=cron_expression,
|
||||
enabled=enabled,
|
||||
last_run=None,
|
||||
next_run=next_run,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
self.db.add(schedule)
|
||||
self.db.commit()
|
||||
self.db.refresh(schedule)
|
||||
|
||||
logger.info(f"Schedule {schedule.id} created: '{name}' with cron '{cron_expression}'")
|
||||
|
||||
return schedule.id
|
||||
|
||||
def get_schedule(self, schedule_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Get schedule details by ID.
|
||||
|
||||
Args:
|
||||
schedule_id: Schedule ID
|
||||
|
||||
Returns:
|
||||
Schedule dictionary with details and execution history
|
||||
|
||||
Raises:
|
||||
ValueError: If schedule not found
|
||||
"""
|
||||
schedule = self.db.query(Schedule).filter(Schedule.id == schedule_id).first()
|
||||
|
||||
if not schedule:
|
||||
raise ValueError(f"Schedule {schedule_id} not found")
|
||||
|
||||
# Convert to dict and include history
|
||||
schedule_dict = self._schedule_to_dict(schedule)
|
||||
schedule_dict['history'] = self.get_schedule_history(schedule_id, limit=10)
|
||||
|
||||
return schedule_dict
|
||||
|
||||
def list_schedules(
|
||||
self,
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
enabled_filter: Optional[bool] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
List all schedules with pagination and filtering.
|
||||
|
||||
Args:
|
||||
page: Page number (1-indexed)
|
||||
per_page: Items per page
|
||||
enabled_filter: Filter by enabled status (None = all)
|
||||
|
||||
Returns:
|
||||
Dictionary with paginated schedules:
|
||||
{
|
||||
'schedules': [...],
|
||||
'total': int,
|
||||
'page': int,
|
||||
'per_page': int,
|
||||
'pages': int
|
||||
}
|
||||
"""
|
||||
# Build query
|
||||
query = self.db.query(Schedule)
|
||||
|
||||
# Apply filter
|
||||
if enabled_filter is not None:
|
||||
query = query.filter(Schedule.enabled == enabled_filter)
|
||||
|
||||
# Order by next_run (nulls last), then by name
|
||||
query = query.order_by(Schedule.next_run.is_(None), Schedule.next_run, Schedule.name)
|
||||
|
||||
# Paginate
|
||||
result = paginate(query, page=page, per_page=per_page)
|
||||
|
||||
# Convert schedules to dicts
|
||||
schedules = [self._schedule_to_dict(s) for s in result.items]
|
||||
|
||||
return {
|
||||
'schedules': schedules,
|
||||
'total': result.total,
|
||||
'page': result.page,
|
||||
'per_page': result.per_page,
|
||||
'pages': result.pages
|
||||
}
|
||||
|
||||
def update_schedule(
|
||||
self,
|
||||
schedule_id: int,
|
||||
**updates: Any
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update schedule fields.
|
||||
|
||||
Args:
|
||||
schedule_id: Schedule ID
|
||||
**updates: Fields to update (name, config_file, cron_expression, enabled)
|
||||
|
||||
Returns:
|
||||
Updated schedule dictionary
|
||||
|
||||
Raises:
|
||||
ValueError: If schedule not found or invalid updates
|
||||
"""
|
||||
schedule = self.db.query(Schedule).filter(Schedule.id == schedule_id).first()
|
||||
|
||||
if not schedule:
|
||||
raise ValueError(f"Schedule {schedule_id} not found")
|
||||
|
||||
# Validate cron expression if being updated
|
||||
if 'cron_expression' in updates:
|
||||
is_valid, error_msg = self.validate_cron_expression(updates['cron_expression'])
|
||||
if not is_valid:
|
||||
raise ValueError(f"Invalid cron expression: {error_msg}")
|
||||
# Recalculate next_run
|
||||
if schedule.enabled or updates.get('enabled', False):
|
||||
updates['next_run'] = self.calculate_next_run(updates['cron_expression'])
|
||||
|
||||
# Validate config file if being updated
|
||||
if 'config_file' in updates:
|
||||
config_file = updates['config_file']
|
||||
# If config_file is just a filename, prepend the configs directory
|
||||
if not config_file.startswith('/'):
|
||||
config_file_path = os.path.join('/app/configs', config_file)
|
||||
else:
|
||||
config_file_path = config_file
|
||||
|
||||
if not os.path.isfile(config_file_path):
|
||||
raise ValueError(f"Config file not found: {updates['config_file']}")
|
||||
|
||||
# Handle enabled toggle
|
||||
if 'enabled' in updates:
|
||||
if updates['enabled'] and not schedule.enabled:
|
||||
# Being enabled - calculate next_run
|
||||
cron_expr = updates.get('cron_expression', schedule.cron_expression)
|
||||
updates['next_run'] = self.calculate_next_run(cron_expr)
|
||||
elif not updates['enabled'] and schedule.enabled:
|
||||
# Being disabled - clear next_run
|
||||
updates['next_run'] = None
|
||||
|
||||
# Update fields
|
||||
for key, value in updates.items():
|
||||
if hasattr(schedule, key):
|
||||
setattr(schedule, key, value)
|
||||
|
||||
schedule.updated_at = datetime.utcnow()
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(schedule)
|
||||
|
||||
logger.info(f"Schedule {schedule_id} updated: {list(updates.keys())}")
|
||||
|
||||
return self._schedule_to_dict(schedule)
|
||||
|
||||
def delete_schedule(self, schedule_id: int) -> bool:
|
||||
"""
|
||||
Delete a schedule.
|
||||
|
||||
Note: Associated scans are NOT deleted (schedule_id becomes null).
|
||||
|
||||
Args:
|
||||
schedule_id: Schedule ID
|
||||
|
||||
Returns:
|
||||
True if deleted successfully
|
||||
|
||||
Raises:
|
||||
ValueError: If schedule not found
|
||||
"""
|
||||
schedule = self.db.query(Schedule).filter(Schedule.id == schedule_id).first()
|
||||
|
||||
if not schedule:
|
||||
raise ValueError(f"Schedule {schedule_id} not found")
|
||||
|
||||
schedule_name = schedule.name
|
||||
|
||||
self.db.delete(schedule)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Schedule {schedule_id} ('{schedule_name}') deleted")
|
||||
|
||||
return True
|
||||
|
||||
def toggle_enabled(self, schedule_id: int, enabled: bool) -> Dict[str, Any]:
|
||||
"""
|
||||
Enable or disable a schedule.
|
||||
|
||||
Args:
|
||||
schedule_id: Schedule ID
|
||||
enabled: New enabled status
|
||||
|
||||
Returns:
|
||||
Updated schedule dictionary
|
||||
|
||||
Raises:
|
||||
ValueError: If schedule not found
|
||||
"""
|
||||
return self.update_schedule(schedule_id, enabled=enabled)
|
||||
|
||||
def update_run_times(
|
||||
self,
|
||||
schedule_id: int,
|
||||
last_run: datetime,
|
||||
next_run: datetime
|
||||
) -> bool:
|
||||
"""
|
||||
Update last_run and next_run timestamps.
|
||||
|
||||
Called after each execution.
|
||||
|
||||
Args:
|
||||
schedule_id: Schedule ID
|
||||
last_run: Last execution time
|
||||
next_run: Next scheduled execution time
|
||||
|
||||
Returns:
|
||||
True if updated successfully
|
||||
|
||||
Raises:
|
||||
ValueError: If schedule not found
|
||||
"""
|
||||
schedule = self.db.query(Schedule).filter(Schedule.id == schedule_id).first()
|
||||
|
||||
if not schedule:
|
||||
raise ValueError(f"Schedule {schedule_id} not found")
|
||||
|
||||
schedule.last_run = last_run
|
||||
schedule.next_run = next_run
|
||||
schedule.updated_at = datetime.utcnow()
|
||||
|
||||
self.db.commit()
|
||||
|
||||
logger.debug(f"Schedule {schedule_id} run times updated: last={last_run}, next={next_run}")
|
||||
|
||||
return True
|
||||
|
||||
def validate_cron_expression(self, cron_expr: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate a cron expression.
|
||||
|
||||
Args:
|
||||
cron_expr: Cron expression to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
- (True, None) if valid
|
||||
- (False, error_message) if invalid
|
||||
"""
|
||||
try:
|
||||
# Try to create a croniter instance
|
||||
base_time = datetime.utcnow()
|
||||
cron = croniter(cron_expr, base_time)
|
||||
|
||||
# Try to get the next run time (validates the expression)
|
||||
cron.get_next(datetime)
|
||||
|
||||
return (True, None)
|
||||
except (ValueError, KeyError) as e:
|
||||
return (False, str(e))
|
||||
except Exception as e:
|
||||
return (False, f"Unexpected error: {str(e)}")
|
||||
|
||||
def calculate_next_run(
|
||||
self,
|
||||
cron_expr: str,
|
||||
from_time: Optional[datetime] = None
|
||||
) -> datetime:
|
||||
"""
|
||||
Calculate next run time from cron expression.
|
||||
|
||||
Args:
|
||||
cron_expr: Cron expression
|
||||
from_time: Base time (defaults to now UTC)
|
||||
|
||||
Returns:
|
||||
Next run datetime (UTC)
|
||||
|
||||
Raises:
|
||||
ValueError: If cron expression is invalid
|
||||
"""
|
||||
if from_time is None:
|
||||
from_time = datetime.utcnow()
|
||||
|
||||
try:
|
||||
cron = croniter(cron_expr, from_time)
|
||||
return cron.get_next(datetime)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid cron expression '{cron_expr}': {str(e)}")
|
||||
|
||||
def get_schedule_history(
|
||||
self,
|
||||
schedule_id: int,
|
||||
limit: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get recent scans triggered by this schedule.
|
||||
|
||||
Args:
|
||||
schedule_id: Schedule ID
|
||||
limit: Maximum number of scans to return
|
||||
|
||||
Returns:
|
||||
List of scan dictionaries (recent first)
|
||||
"""
|
||||
scans = (
|
||||
self.db.query(Scan)
|
||||
.filter(Scan.schedule_id == schedule_id)
|
||||
.order_by(Scan.timestamp.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
'id': scan.id,
|
||||
'timestamp': scan.timestamp.isoformat() if scan.timestamp else None,
|
||||
'status': scan.status,
|
||||
'title': scan.title,
|
||||
'config_file': scan.config_file
|
||||
}
|
||||
for scan in scans
|
||||
]
|
||||
|
||||
def _schedule_to_dict(self, schedule: Schedule) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert Schedule model to dictionary.
|
||||
|
||||
Args:
|
||||
schedule: Schedule model instance
|
||||
|
||||
Returns:
|
||||
Dictionary representation
|
||||
"""
|
||||
return {
|
||||
'id': schedule.id,
|
||||
'name': schedule.name,
|
||||
'config_file': schedule.config_file,
|
||||
'cron_expression': schedule.cron_expression,
|
||||
'enabled': schedule.enabled,
|
||||
'last_run': schedule.last_run.isoformat() if schedule.last_run else None,
|
||||
'next_run': schedule.next_run.isoformat() if schedule.next_run else None,
|
||||
'next_run_relative': self._get_relative_time(schedule.next_run) if schedule.next_run else None,
|
||||
'created_at': schedule.created_at.isoformat() if schedule.created_at else None,
|
||||
'updated_at': schedule.updated_at.isoformat() if schedule.updated_at else None
|
||||
}
|
||||
|
||||
def _get_relative_time(self, dt: Optional[datetime]) -> Optional[str]:
|
||||
"""
|
||||
Format datetime as relative time.
|
||||
|
||||
Args:
|
||||
dt: Datetime to format (UTC)
|
||||
|
||||
Returns:
|
||||
Human-readable relative time (e.g., "in 2 hours", "yesterday")
|
||||
"""
|
||||
if dt is None:
|
||||
return None
|
||||
|
||||
now = datetime.utcnow()
|
||||
diff = dt - now
|
||||
|
||||
# Future times
|
||||
if diff.total_seconds() > 0:
|
||||
seconds = int(diff.total_seconds())
|
||||
|
||||
if seconds < 60:
|
||||
return "in less than a minute"
|
||||
elif seconds < 3600:
|
||||
minutes = seconds // 60
|
||||
return f"in {minutes} minute{'s' if minutes != 1 else ''}"
|
||||
elif seconds < 86400:
|
||||
hours = seconds // 3600
|
||||
return f"in {hours} hour{'s' if hours != 1 else ''}"
|
||||
elif seconds < 604800:
|
||||
days = seconds // 86400
|
||||
return f"in {days} day{'s' if days != 1 else ''}"
|
||||
else:
|
||||
weeks = seconds // 604800
|
||||
return f"in {weeks} week{'s' if weeks != 1 else ''}"
|
||||
|
||||
# Past times
|
||||
else:
|
||||
seconds = int(-diff.total_seconds())
|
||||
|
||||
if seconds < 60:
|
||||
return "less than a minute ago"
|
||||
elif seconds < 3600:
|
||||
minutes = seconds // 60
|
||||
return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
|
||||
elif seconds < 86400:
|
||||
hours = seconds // 3600
|
||||
return f"{hours} hour{'s' if hours != 1 else ''} ago"
|
||||
elif seconds < 604800:
|
||||
days = seconds // 86400
|
||||
return f"{days} day{'s' if days != 1 else ''} ago"
|
||||
else:
|
||||
weeks = seconds // 604800
|
||||
return f"{weeks} week{'s' if weeks != 1 else ''} ago"
|
||||
356
app/web/services/scheduler_service.py
Normal file
356
app/web/services/scheduler_service.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""
|
||||
Scheduler service for managing background jobs and scheduled scans.
|
||||
|
||||
This service integrates APScheduler with Flask to enable background
|
||||
scan execution and future scheduled scanning capabilities.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.executors.pool import ThreadPoolExecutor
|
||||
from flask import Flask
|
||||
|
||||
from web.jobs.scan_job import execute_scan
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SchedulerService:
|
||||
"""
|
||||
Service for managing background job scheduling.
|
||||
|
||||
Uses APScheduler's BackgroundScheduler to run scans asynchronously
|
||||
without blocking HTTP requests.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize scheduler service (scheduler not started yet)."""
|
||||
self.scheduler: Optional[BackgroundScheduler] = None
|
||||
self.db_url: Optional[str] = None
|
||||
|
||||
def init_scheduler(self, app: Flask):
|
||||
"""
|
||||
Initialize and start APScheduler with Flask app.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
|
||||
Configuration:
|
||||
- BackgroundScheduler: Runs in separate thread
|
||||
- ThreadPoolExecutor: Allows concurrent scan execution
|
||||
- Max workers: 3 (configurable via SCHEDULER_MAX_WORKERS)
|
||||
"""
|
||||
if self.scheduler:
|
||||
logger.warning("Scheduler already initialized")
|
||||
return
|
||||
|
||||
# Store database URL for passing to background jobs
|
||||
self.db_url = app.config['SQLALCHEMY_DATABASE_URI']
|
||||
|
||||
# Configure executor for concurrent jobs
|
||||
max_workers = app.config.get('SCHEDULER_MAX_WORKERS', 3)
|
||||
executors = {
|
||||
'default': ThreadPoolExecutor(max_workers=max_workers)
|
||||
}
|
||||
|
||||
# Configure job defaults
|
||||
job_defaults = {
|
||||
'coalesce': True, # Combine multiple pending instances into one
|
||||
'max_instances': app.config.get('SCHEDULER_MAX_INSTANCES', 3),
|
||||
'misfire_grace_time': 60 # Allow 60 seconds for delayed starts
|
||||
}
|
||||
|
||||
# Create scheduler with local system timezone
|
||||
# This allows users to schedule jobs using their local time
|
||||
# APScheduler will automatically use the system's local timezone
|
||||
self.scheduler = BackgroundScheduler(
|
||||
executors=executors,
|
||||
job_defaults=job_defaults
|
||||
# timezone defaults to local system timezone
|
||||
)
|
||||
|
||||
# Start scheduler
|
||||
self.scheduler.start()
|
||||
logger.info(f"APScheduler started with {max_workers} max workers")
|
||||
|
||||
# Register shutdown handler
|
||||
import atexit
|
||||
atexit.register(lambda: self.shutdown())
|
||||
|
||||
def shutdown(self):
|
||||
"""
|
||||
Shutdown scheduler gracefully.
|
||||
|
||||
Waits for running jobs to complete before shutting down.
|
||||
"""
|
||||
if self.scheduler:
|
||||
logger.info("Shutting down APScheduler...")
|
||||
self.scheduler.shutdown(wait=True)
|
||||
logger.info("APScheduler shutdown complete")
|
||||
self.scheduler = None
|
||||
|
||||
def load_schedules_on_startup(self):
|
||||
"""
|
||||
Load all enabled schedules from database and register with APScheduler.
|
||||
|
||||
Should be called after init_scheduler() to restore scheduled jobs
|
||||
that were active when the application last shutdown.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If scheduler not initialized
|
||||
"""
|
||||
if not self.scheduler:
|
||||
raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.")
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from web.models import Schedule
|
||||
|
||||
try:
|
||||
# Create database session
|
||||
engine = create_engine(self.db_url)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
try:
|
||||
# Query all enabled schedules
|
||||
enabled_schedules = (
|
||||
session.query(Schedule)
|
||||
.filter(Schedule.enabled == True)
|
||||
.all()
|
||||
)
|
||||
|
||||
logger.info(f"Loading {len(enabled_schedules)} enabled schedules on startup")
|
||||
|
||||
# Register each schedule with APScheduler
|
||||
for schedule in enabled_schedules:
|
||||
try:
|
||||
self.add_scheduled_scan(
|
||||
schedule_id=schedule.id,
|
||||
config_file=schedule.config_file,
|
||||
cron_expression=schedule.cron_expression
|
||||
)
|
||||
logger.info(f"Loaded schedule {schedule.id}: '{schedule.name}'")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to load schedule {schedule.id} ('{schedule.name}'): {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
logger.info("Schedule loading complete")
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading schedules on startup: {str(e)}", exc_info=True)
|
||||
|
||||
def queue_scan(self, scan_id: int, config_file: str) -> str:
|
||||
"""
|
||||
Queue a scan for immediate background execution.
|
||||
|
||||
Args:
|
||||
scan_id: Database ID of the scan
|
||||
config_file: Path to YAML configuration file
|
||||
|
||||
Returns:
|
||||
Job ID from APScheduler
|
||||
|
||||
Raises:
|
||||
RuntimeError: If scheduler not initialized
|
||||
"""
|
||||
if not self.scheduler:
|
||||
raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.")
|
||||
|
||||
# Add job to run immediately
|
||||
job = self.scheduler.add_job(
|
||||
func=execute_scan,
|
||||
args=[scan_id, config_file, self.db_url],
|
||||
id=f'scan_{scan_id}',
|
||||
name=f'Scan {scan_id}',
|
||||
replace_existing=True,
|
||||
misfire_grace_time=300 # 5 minutes
|
||||
)
|
||||
|
||||
logger.info(f"Queued scan {scan_id} for background execution (job_id={job.id})")
|
||||
return job.id
|
||||
|
||||
def add_scheduled_scan(self, schedule_id: int, config_file: str,
|
||||
cron_expression: str) -> str:
|
||||
"""
|
||||
Add a recurring scheduled scan.
|
||||
|
||||
Args:
|
||||
schedule_id: Database ID of the schedule
|
||||
config_file: Path to YAML configuration file
|
||||
cron_expression: Cron expression (e.g., "0 2 * * *" for 2am daily)
|
||||
|
||||
Returns:
|
||||
Job ID from APScheduler
|
||||
|
||||
Raises:
|
||||
RuntimeError: If scheduler not initialized
|
||||
ValueError: If cron expression is invalid
|
||||
"""
|
||||
if not self.scheduler:
|
||||
raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.")
|
||||
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
# Create cron trigger from expression using local timezone
|
||||
# This allows users to specify times in their local timezone
|
||||
try:
|
||||
trigger = CronTrigger.from_crontab(cron_expression)
|
||||
# timezone defaults to local system timezone
|
||||
except (ValueError, KeyError) as e:
|
||||
raise ValueError(f"Invalid cron expression '{cron_expression}': {str(e)}")
|
||||
|
||||
# Add cron job
|
||||
job = self.scheduler.add_job(
|
||||
func=self._trigger_scheduled_scan,
|
||||
args=[schedule_id],
|
||||
trigger=trigger,
|
||||
id=f'schedule_{schedule_id}',
|
||||
name=f'Schedule {schedule_id}',
|
||||
replace_existing=True,
|
||||
max_instances=1 # Only one instance per schedule
|
||||
)
|
||||
|
||||
logger.info(f"Added scheduled scan {schedule_id} with cron '{cron_expression}' (job_id={job.id})")
|
||||
return job.id
|
||||
|
||||
def remove_scheduled_scan(self, schedule_id: int):
|
||||
"""
|
||||
Remove a scheduled scan job.
|
||||
|
||||
Args:
|
||||
schedule_id: Database ID of the schedule
|
||||
|
||||
Raises:
|
||||
RuntimeError: If scheduler not initialized
|
||||
"""
|
||||
if not self.scheduler:
|
||||
raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.")
|
||||
|
||||
job_id = f'schedule_{schedule_id}'
|
||||
|
||||
try:
|
||||
self.scheduler.remove_job(job_id)
|
||||
logger.info(f"Removed scheduled scan job: {job_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to remove scheduled scan job {job_id}: {str(e)}")
|
||||
|
||||
def _trigger_scheduled_scan(self, schedule_id: int):
|
||||
"""
|
||||
Internal method to trigger a scan from a schedule.
|
||||
|
||||
Creates a new scan record and queues it for execution.
|
||||
|
||||
Args:
|
||||
schedule_id: Database ID of the schedule
|
||||
"""
|
||||
logger.info(f"Scheduled scan triggered: schedule_id={schedule_id}")
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from web.services.schedule_service import ScheduleService
|
||||
from web.services.scan_service import ScanService
|
||||
|
||||
try:
|
||||
# Create database session
|
||||
engine = create_engine(self.db_url)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
try:
|
||||
# Get schedule details
|
||||
schedule_service = ScheduleService(session)
|
||||
schedule = schedule_service.get_schedule(schedule_id)
|
||||
|
||||
if not schedule:
|
||||
logger.error(f"Schedule {schedule_id} not found")
|
||||
return
|
||||
|
||||
if not schedule['enabled']:
|
||||
logger.warning(f"Schedule {schedule_id} is disabled, skipping execution")
|
||||
return
|
||||
|
||||
# Create and trigger scan
|
||||
scan_service = ScanService(session)
|
||||
scan_id = scan_service.trigger_scan(
|
||||
config_file=schedule['config_file'],
|
||||
triggered_by='scheduled',
|
||||
schedule_id=schedule_id,
|
||||
scheduler=None # Don't pass scheduler to avoid recursion
|
||||
)
|
||||
|
||||
# Queue the scan for execution
|
||||
self.queue_scan(scan_id, schedule['config_file'])
|
||||
|
||||
# Update schedule's last_run and next_run
|
||||
from croniter import croniter
|
||||
next_run = croniter(schedule['cron_expression'], datetime.utcnow()).get_next(datetime)
|
||||
|
||||
schedule_service.update_run_times(
|
||||
schedule_id=schedule_id,
|
||||
last_run=datetime.utcnow(),
|
||||
next_run=next_run
|
||||
)
|
||||
|
||||
logger.info(f"Scheduled scan completed: schedule_id={schedule_id}, scan_id={scan_id}")
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error triggering scheduled scan {schedule_id}: {str(e)}", exc_info=True)
|
||||
|
||||
def get_job_status(self, job_id: str) -> Optional[dict]:
|
||||
"""
|
||||
Get status of a scheduled job.
|
||||
|
||||
Args:
|
||||
job_id: APScheduler job ID
|
||||
|
||||
Returns:
|
||||
Dictionary with job information, or None if not found
|
||||
"""
|
||||
if not self.scheduler:
|
||||
return None
|
||||
|
||||
job = self.scheduler.get_job(job_id)
|
||||
if not job:
|
||||
return None
|
||||
|
||||
return {
|
||||
'id': job.id,
|
||||
'name': job.name,
|
||||
'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None,
|
||||
'trigger': str(job.trigger)
|
||||
}
|
||||
|
||||
def list_jobs(self) -> list:
|
||||
"""
|
||||
List all scheduled jobs.
|
||||
|
||||
Returns:
|
||||
List of job information dictionaries
|
||||
"""
|
||||
if not self.scheduler:
|
||||
return []
|
||||
|
||||
jobs = self.scheduler.get_jobs()
|
||||
return [
|
||||
{
|
||||
'id': job.id,
|
||||
'name': job.name,
|
||||
'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None,
|
||||
'trigger': str(job.trigger)
|
||||
}
|
||||
for job in jobs
|
||||
]
|
||||
507
app/web/static/css/config-manager.css
Normal file
507
app/web/static/css/config-manager.css
Normal file
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* Config Manager Styles
|
||||
* Phase 4: Config Creator - CSS styling for config management UI
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
Dropzone Styling
|
||||
============================================ */
|
||||
|
||||
.dropzone {
|
||||
border: 2px dashed #6c757d;
|
||||
border-radius: 8px;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background-color: #1e293b;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dropzone:hover {
|
||||
border-color: #0d6efd;
|
||||
background-color: #2d3748;
|
||||
}
|
||||
|
||||
.dropzone.dragover {
|
||||
border-color: #0d6efd;
|
||||
background-color: #1a365d;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.dropzone i {
|
||||
font-size: 48px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropzone p {
|
||||
color: #cbd5e0;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.dropzone:hover i {
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Preview Pane Styling
|
||||
============================================ */
|
||||
|
||||
#yaml-preview {
|
||||
background-color: #1e293b;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
#yaml-preview pre {
|
||||
background-color: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#yaml-preview pre code {
|
||||
color: #e2e8f0;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
#preview-placeholder {
|
||||
background-color: #1e293b;
|
||||
border: 2px dashed #475569;
|
||||
border-radius: 8px;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
#preview-placeholder i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Config Table Styling
|
||||
============================================ */
|
||||
|
||||
#configs-table {
|
||||
background-color: #1e293b;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#configs-table thead {
|
||||
background-color: #0f172a;
|
||||
border-bottom: 2px solid #334155;
|
||||
}
|
||||
|
||||
#configs-table thead th {
|
||||
color: #cbd5e0;
|
||||
font-weight: 600;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#configs-table tbody tr {
|
||||
border-bottom: 1px solid #334155;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
#configs-table tbody tr:hover {
|
||||
background-color: #2d3748;
|
||||
}
|
||||
|
||||
#configs-table tbody td {
|
||||
padding: 12px 16px;
|
||||
color: #e2e8f0;
|
||||
vertical-align: middle;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#configs-table tbody td code {
|
||||
background-color: #0f172a;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: #60a5fa;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Action Buttons
|
||||
============================================ */
|
||||
|
||||
.config-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-actions .btn {
|
||||
margin-right: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.config-actions .btn:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.config-actions .btn i {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Disabled button styling */
|
||||
.config-actions .btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Schedule Badge
|
||||
============================================ */
|
||||
|
||||
.schedule-badge {
|
||||
display: inline-block;
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.schedule-badge:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Search Box
|
||||
============================================ */
|
||||
|
||||
#search {
|
||||
background-color: #1e293b;
|
||||
border: 1px solid #475569;
|
||||
color: #e2e8f0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
#search:focus {
|
||||
background-color: #0f172a;
|
||||
border-color: #3b82f6;
|
||||
color: #e2e8f0;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
#search::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Alert Messages
|
||||
============================================ */
|
||||
|
||||
.alert {
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background-color: #7f1d1d;
|
||||
border: 1px solid #991b1b;
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #14532d;
|
||||
border: 1px solid #166534;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.alert i {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Card Styling
|
||||
============================================ */
|
||||
|
||||
.card {
|
||||
background-color: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card h5 {
|
||||
color: #cbd5e0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card .text-muted {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Tab Navigation
|
||||
============================================ */
|
||||
|
||||
.nav-tabs {
|
||||
border-bottom: 2px solid #334155;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
color: #94a3b8;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 12px 20px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link:hover {
|
||||
color: #cbd5e0;
|
||||
background-color: #2d3748;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
color: #60a5fa;
|
||||
background-color: transparent;
|
||||
border-color: transparent transparent #60a5fa transparent;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Buttons
|
||||
============================================ */
|
||||
|
||||
.btn {
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #22c55e;
|
||||
border-color: #22c55e;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #16a34a;
|
||||
border-color: #16a34a;
|
||||
}
|
||||
|
||||
.btn-outline-secondary {
|
||||
color: #94a3b8;
|
||||
border-color: #475569;
|
||||
}
|
||||
|
||||
.btn-outline-secondary:hover {
|
||||
background-color: #475569;
|
||||
border-color: #475569;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
color: #60a5fa;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-outline-danger {
|
||||
color: #f87171;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-outline-danger:hover {
|
||||
background-color: #dc2626;
|
||||
border-color: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Modal Styling
|
||||
============================================ */
|
||||
|
||||
.modal-content {
|
||||
background-color: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
border-top: 1px solid #334155;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
color: #cbd5e0;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Spinner/Loading
|
||||
============================================ */
|
||||
|
||||
.spinner-border {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Responsive Adjustments
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#configs-table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
#configs-table thead th,
|
||||
#configs-table tbody td {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.config-actions .btn {
|
||||
padding: 2px 6px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.config-actions .btn i {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
padding: 30px 15px;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.dropzone i {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
#yaml-preview pre {
|
||||
max-height: 300px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
/* Stack table cells on very small screens */
|
||||
#configs-table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#configs-table tbody tr {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
#configs-table tbody td {
|
||||
display: block;
|
||||
text-align: left;
|
||||
padding: 6px 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#configs-table tbody td:before {
|
||||
content: attr(data-label);
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
display: inline-block;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Utility Classes
|
||||
============================================ */
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.py-4 {
|
||||
padding-top: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.py-5 {
|
||||
padding-top: 3rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mt-3 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.mb-3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Result Count Display
|
||||
============================================ */
|
||||
|
||||
#result-count {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
334
app/web/static/css/styles.css
Normal file
334
app/web/static/css/styles.css
Normal file
@@ -0,0 +1,334 @@
|
||||
/* CSS Variables */
|
||||
:root {
|
||||
/* Custom Variables */
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--bg-quaternary: #475569;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--border-color: #334155;
|
||||
--accent-blue: #60a5fa;
|
||||
--success-bg: #065f46;
|
||||
--success-text: #6ee7b7;
|
||||
--success-border: #10b981;
|
||||
--warning-bg: #78350f;
|
||||
--warning-text: #fcd34d;
|
||||
--warning-border: #f59e0b;
|
||||
--danger-bg: #7f1d1d;
|
||||
--danger-text: #fca5a5;
|
||||
--danger-border: #ef4444;
|
||||
--info-bg: #1e3a8a;
|
||||
--info-text: #93c5fd;
|
||||
--info-border: #3b82f6;
|
||||
|
||||
/* Bootstrap 5 Variable Overrides for Dark Theme */
|
||||
--bs-body-bg: #0f172a;
|
||||
--bs-body-color: #e2e8f0;
|
||||
--bs-border-color: #334155;
|
||||
--bs-border-color-translucent: rgba(51, 65, 85, 0.5);
|
||||
|
||||
/* Table Variables */
|
||||
--bs-table-bg: #1e293b;
|
||||
--bs-table-color: #e2e8f0;
|
||||
--bs-table-border-color: #334155;
|
||||
--bs-table-striped-bg: #1e293b;
|
||||
--bs-table-striped-color: #e2e8f0;
|
||||
--bs-table-active-bg: #334155;
|
||||
--bs-table-active-color: #e2e8f0;
|
||||
--bs-table-hover-bg: #334155;
|
||||
--bs-table-hover-color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Global Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
.navbar-custom {
|
||||
background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
|
||||
border-bottom: 1px solid var(--bg-quaternary);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-blue) !important;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-secondary) !important;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link.active {
|
||||
color: var(--accent-blue) !important;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container-fluid {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--bg-quaternary);
|
||||
padding: 15px 20px;
|
||||
border-radius: 12px 12px 0 0 !important;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 25px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: var(--accent-blue);
|
||||
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: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.badge-unexpected,
|
||||
.badge-critical,
|
||||
.badge-danger {
|
||||
background-color: var(--danger-bg);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.badge-missing,
|
||||
.badge-warning {
|
||||
background-color: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: var(--info-bg);
|
||||
color: var(--info-text);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--bg-quaternary);
|
||||
border-color: var(--bg-quaternary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--danger-bg);
|
||||
border-color: var(--danger-bg);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #991b1b;
|
||||
border-color: #991b1b;
|
||||
}
|
||||
|
||||
/* Tables - Fix for dynamically created table rows (white row bug) */
|
||||
.table {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.table thead {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.table tbody tr,
|
||||
.table tbody tr.scan-row {
|
||||
background-color: var(--bg-secondary) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background-color: var(--bg-tertiary) !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 12px;
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
border-radius: 8px;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: var(--success-bg);
|
||||
border-color: var(--success-border);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background-color: var(--danger-bg);
|
||||
border-color: var(--danger-border);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: var(--warning-bg);
|
||||
border-color: var(--warning-border);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: var(--info-bg);
|
||||
border-color: var(--info-border);
|
||||
color: var(--info-text);
|
||||
}
|
||||
|
||||
/* Form Controls */
|
||||
.form-control,
|
||||
.form-select {
|
||||
background-color: var(--bg-secondary);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
background-color: var(--bg-secondary);
|
||||
border-color: var(--accent-blue);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 0 0 0.2rem rgba(96, 165, 250, 0.25);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Stats Cards */
|
||||
.stat-card {
|
||||
background-color: var(--bg-primary);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
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 var(--border-color);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.text-muted {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--success-border) !important;
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--warning-border) !important;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--danger-border) !important;
|
||||
}
|
||||
|
||||
.text-info {
|
||||
color: var(--accent-blue) !important;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Spinner for loading states */
|
||||
.spinner-border-sm {
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
/* Chart.js Dark Theme Styles */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
canvas {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
633
app/web/static/js/config-manager.js
Normal file
633
app/web/static/js/config-manager.js
Normal file
@@ -0,0 +1,633 @@
|
||||
/**
|
||||
* Config Manager - Handles configuration file upload, management, and display
|
||||
* Phase 4: Config Creator
|
||||
*/
|
||||
|
||||
class ConfigManager {
|
||||
constructor() {
|
||||
this.apiBase = '/api/configs';
|
||||
this.currentPreview = null;
|
||||
this.currentFilename = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all configurations and populate the table
|
||||
*/
|
||||
async loadConfigs() {
|
||||
try {
|
||||
const response = await fetch(this.apiBase);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.renderConfigsTable(data.configs || []);
|
||||
return data.configs;
|
||||
} catch (error) {
|
||||
console.error('Error loading configs:', error);
|
||||
this.showError('Failed to load configurations: ' + error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific configuration file
|
||||
*/
|
||||
async getConfig(filename) {
|
||||
try {
|
||||
const response = await fetch(`${this.apiBase}/${encodeURIComponent(filename)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error getting config:', error);
|
||||
this.showError('Failed to load configuration: ' + error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload CSV file and convert to YAML
|
||||
*/
|
||||
async uploadCSV(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.apiBase}/upload-csv`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error uploading CSV:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload YAML file directly
|
||||
*/
|
||||
async uploadYAML(file, filename = null) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
if (filename) {
|
||||
formData.append('filename', filename);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.apiBase}/upload-yaml`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error uploading YAML:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a configuration file
|
||||
*/
|
||||
async deleteConfig(filename) {
|
||||
try {
|
||||
const response = await fetch(`${this.apiBase}/${encodeURIComponent(filename)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error deleting config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download CSV template
|
||||
*/
|
||||
downloadTemplate() {
|
||||
window.location.href = `${this.apiBase}/template`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a specific config file
|
||||
*/
|
||||
downloadConfig(filename) {
|
||||
window.location.href = `${this.apiBase}/${encodeURIComponent(filename)}/download`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show YAML preview in the preview pane
|
||||
*/
|
||||
showPreview(yamlContent, filename = null) {
|
||||
this.currentPreview = yamlContent;
|
||||
this.currentFilename = filename;
|
||||
|
||||
const previewElement = document.getElementById('yaml-preview');
|
||||
const contentElement = document.getElementById('yaml-content');
|
||||
const placeholderElement = document.getElementById('preview-placeholder');
|
||||
|
||||
if (contentElement) {
|
||||
contentElement.textContent = yamlContent;
|
||||
}
|
||||
|
||||
if (previewElement) {
|
||||
previewElement.style.display = 'block';
|
||||
}
|
||||
|
||||
if (placeholderElement) {
|
||||
placeholderElement.style.display = 'none';
|
||||
}
|
||||
|
||||
// Enable save button
|
||||
const saveBtn = document.getElementById('save-config-btn');
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide YAML preview
|
||||
*/
|
||||
hidePreview() {
|
||||
this.currentPreview = null;
|
||||
this.currentFilename = null;
|
||||
|
||||
const previewElement = document.getElementById('yaml-preview');
|
||||
const placeholderElement = document.getElementById('preview-placeholder');
|
||||
|
||||
if (previewElement) {
|
||||
previewElement.style.display = 'none';
|
||||
}
|
||||
|
||||
if (placeholderElement) {
|
||||
placeholderElement.style.display = 'block';
|
||||
}
|
||||
|
||||
// Disable save button
|
||||
const saveBtn = document.getElementById('save-config-btn');
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render configurations table
|
||||
*/
|
||||
renderConfigsTable(configs) {
|
||||
const tbody = document.querySelector('#configs-table tbody');
|
||||
|
||||
if (!tbody) {
|
||||
console.warn('Configs table body not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing rows
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (configs.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted py-4">
|
||||
<i class="bi bi-inbox" style="font-size: 2rem;"></i>
|
||||
<p class="mt-2">No configuration files found. Create your first config!</p>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate table
|
||||
configs.forEach(config => {
|
||||
const row = document.createElement('tr');
|
||||
row.dataset.filename = config.filename;
|
||||
|
||||
// Format date
|
||||
const createdDate = config.created_at ?
|
||||
new Date(config.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}) : 'Unknown';
|
||||
|
||||
// Format file size
|
||||
const fileSize = config.size_bytes ?
|
||||
this.formatFileSize(config.size_bytes) : 'Unknown';
|
||||
|
||||
// Schedule usage badge
|
||||
const scheduleCount = config.used_by_schedules ? config.used_by_schedules.length : 0;
|
||||
const scheduleBadge = scheduleCount > 0 ?
|
||||
`<span class="schedule-badge" title="${config.used_by_schedules.join(', ')}">${scheduleCount}</span>` :
|
||||
'<span class="text-muted">None</span>';
|
||||
|
||||
row.innerHTML = `
|
||||
<td><code>${this.escapeHtml(config.filename)}</code></td>
|
||||
<td>${this.escapeHtml(config.title || 'Untitled')}</td>
|
||||
<td>${createdDate}</td>
|
||||
<td>${fileSize}</td>
|
||||
<td>${scheduleBadge}</td>
|
||||
<td class="config-actions">
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
onclick="configManager.viewConfig('${this.escapeHtml(config.filename)}')"
|
||||
title="View config">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
onclick="configManager.downloadConfig('${this.escapeHtml(config.filename)}')"
|
||||
title="Download config">
|
||||
<i class="bi bi-download"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
onclick="configManager.confirmDelete('${this.escapeHtml(config.filename)}', ${scheduleCount})"
|
||||
title="Delete config"
|
||||
${scheduleCount > 0 ? 'disabled' : ''}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
// Update result count
|
||||
this.updateResultCount(configs.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* View/preview a configuration file
|
||||
*/
|
||||
async viewConfig(filename) {
|
||||
try {
|
||||
const config = await this.getConfig(filename);
|
||||
|
||||
// Show modal with config content
|
||||
const modalHtml = `
|
||||
<div class="modal fade" id="viewConfigModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${this.escapeHtml(filename)}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre><code class="language-yaml">${this.escapeHtml(config.content)}</code></pre>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary"
|
||||
onclick="configManager.downloadConfig('${this.escapeHtml(filename)}')">
|
||||
<i class="bi bi-download"></i> Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Remove existing modal if any
|
||||
const existingModal = document.getElementById('viewConfigModal');
|
||||
if (existingModal) {
|
||||
existingModal.remove();
|
||||
}
|
||||
|
||||
// Add modal to page
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
// Show modal
|
||||
const modal = new bootstrap.Modal(document.getElementById('viewConfigModal'));
|
||||
modal.show();
|
||||
|
||||
// Clean up on close
|
||||
document.getElementById('viewConfigModal').addEventListener('hidden.bs.modal', function() {
|
||||
this.remove();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.showError('Failed to view configuration: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm deletion of a configuration
|
||||
*/
|
||||
confirmDelete(filename, scheduleCount) {
|
||||
if (scheduleCount > 0) {
|
||||
this.showError(`Cannot delete "${filename}" - it is used by ${scheduleCount} schedule(s)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`Are you sure you want to delete "${filename}"?\n\nThis action cannot be undone.`)) {
|
||||
this.performDelete(filename);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the actual deletion
|
||||
*/
|
||||
async performDelete(filename) {
|
||||
try {
|
||||
await this.deleteConfig(filename);
|
||||
this.showSuccess(`Configuration "${filename}" deleted successfully`);
|
||||
|
||||
// Reload configs table
|
||||
await this.loadConfigs();
|
||||
} catch (error) {
|
||||
this.showError('Failed to delete configuration: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter configs table by search term
|
||||
*/
|
||||
filterConfigs(searchTerm) {
|
||||
const term = searchTerm.toLowerCase().trim();
|
||||
const rows = document.querySelectorAll('#configs-table tbody tr');
|
||||
let visibleCount = 0;
|
||||
|
||||
rows.forEach(row => {
|
||||
// Skip empty state row
|
||||
if (row.querySelector('td[colspan]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = row.cells[0]?.textContent.toLowerCase() || '';
|
||||
const title = row.cells[1]?.textContent.toLowerCase() || '';
|
||||
|
||||
const matches = filename.includes(term) || title.includes(term);
|
||||
|
||||
row.style.display = matches ? '' : 'none';
|
||||
if (matches) visibleCount++;
|
||||
});
|
||||
|
||||
this.updateResultCount(visibleCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update result count display
|
||||
*/
|
||||
updateResultCount(count) {
|
||||
const countElement = document.getElementById('result-count');
|
||||
if (countElement) {
|
||||
countElement.textContent = `${count} config${count !== 1 ? 's' : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error message
|
||||
*/
|
||||
showError(message, elementId = 'error-display') {
|
||||
const errorElement = document.getElementById(elementId);
|
||||
if (errorElement) {
|
||||
errorElement.innerHTML = `
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle"></i> ${this.escapeHtml(message)}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
`;
|
||||
errorElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
} else {
|
||||
console.error('Error:', message);
|
||||
alert('Error: ' + message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show success message
|
||||
*/
|
||||
showSuccess(message, elementId = 'success-display') {
|
||||
const successElement = document.getElementById(elementId);
|
||||
if (successElement) {
|
||||
successElement.innerHTML = `
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-check-circle"></i> ${this.escapeHtml(message)}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
`;
|
||||
successElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
} else {
|
||||
console.log('Success:', message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all messages
|
||||
*/
|
||||
clearMessages() {
|
||||
const elements = ['error-display', 'success-display', 'csv-errors', 'yaml-errors'];
|
||||
elements.forEach(id => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.innerHTML = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display
|
||||
*/
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize global config manager instance
|
||||
const configManager = new ConfigManager();
|
||||
|
||||
/**
|
||||
* Setup drag-and-drop zone for file uploads
|
||||
*/
|
||||
function setupDropzone(dropzoneId, fileInputId, fileType, onUploadCallback) {
|
||||
const dropzone = document.getElementById(dropzoneId);
|
||||
const fileInput = document.getElementById(fileInputId);
|
||||
|
||||
if (!dropzone || !fileInput) {
|
||||
console.warn(`Dropzone setup failed: missing elements (${dropzoneId}, ${fileInputId})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Click to browse
|
||||
dropzone.addEventListener('click', () => {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
// Drag over
|
||||
dropzone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropzone.classList.add('dragover');
|
||||
});
|
||||
|
||||
// Drag leave
|
||||
dropzone.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropzone.classList.remove('dragover');
|
||||
});
|
||||
|
||||
// Drop
|
||||
dropzone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropzone.classList.remove('dragover');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFileUpload(files[0], fileType, onUploadCallback);
|
||||
}
|
||||
});
|
||||
|
||||
// File input change
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
const files = e.target.files;
|
||||
if (files.length > 0) {
|
||||
handleFileUpload(files[0], fileType, onUploadCallback);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file upload (CSV or YAML)
|
||||
*/
|
||||
async function handleFileUpload(file, fileType, callback) {
|
||||
configManager.clearMessages();
|
||||
|
||||
// Validate file type
|
||||
const extension = file.name.split('.').pop().toLowerCase();
|
||||
|
||||
if (fileType === 'csv' && extension !== 'csv') {
|
||||
configManager.showError('Please upload a CSV file (.csv)', 'csv-errors');
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileType === 'yaml' && !['yaml', 'yml'].includes(extension)) {
|
||||
configManager.showError('Please upload a YAML file (.yaml or .yml)', 'yaml-errors');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (2MB limit for configs)
|
||||
const maxSize = 2 * 1024 * 1024; // 2MB
|
||||
if (file.size > maxSize) {
|
||||
const errorId = fileType === 'csv' ? 'csv-errors' : 'yaml-errors';
|
||||
configManager.showError(`File too large (${configManager.formatFileSize(file.size)}). Maximum size is 2MB.`, errorId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the provided callback
|
||||
if (callback) {
|
||||
try {
|
||||
await callback(file);
|
||||
} catch (error) {
|
||||
const errorId = fileType === 'csv' ? 'csv-errors' : 'yaml-errors';
|
||||
configManager.showError(error.message, errorId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle CSV upload and preview
|
||||
*/
|
||||
async function handleCSVUpload(file) {
|
||||
try {
|
||||
// Show loading state
|
||||
const previewPlaceholder = document.getElementById('preview-placeholder');
|
||||
if (previewPlaceholder) {
|
||||
previewPlaceholder.innerHTML = '<div class="spinner-border" role="status"><span class="visually-hidden">Loading...</span></div>';
|
||||
}
|
||||
|
||||
// Upload CSV
|
||||
const result = await configManager.uploadCSV(file);
|
||||
|
||||
// Show preview
|
||||
configManager.showPreview(result.preview, result.filename);
|
||||
|
||||
// Show success message
|
||||
configManager.showSuccess(`CSV uploaded successfully! Preview the generated YAML below.`, 'csv-errors');
|
||||
|
||||
} catch (error) {
|
||||
configManager.hidePreview();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle YAML upload
|
||||
*/
|
||||
async function handleYAMLUpload(file) {
|
||||
try {
|
||||
// Upload YAML
|
||||
const result = await configManager.uploadYAML(file);
|
||||
|
||||
// Show success and redirect
|
||||
configManager.showSuccess(`Configuration "${result.filename}" uploaded successfully!`, 'yaml-errors');
|
||||
|
||||
// Redirect to configs list after 2 seconds
|
||||
setTimeout(() => {
|
||||
window.location.href = '/configs';
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the previewed configuration (after CSV upload)
|
||||
*/
|
||||
async function savePreviewedConfig() {
|
||||
if (!configManager.currentPreview || !configManager.currentFilename) {
|
||||
configManager.showError('No configuration to save', 'csv-errors');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// The config is already saved during CSV upload, just redirect
|
||||
configManager.showSuccess(`Configuration "${configManager.currentFilename}" saved successfully!`, 'csv-errors');
|
||||
|
||||
// Redirect to configs list after 2 seconds
|
||||
setTimeout(() => {
|
||||
window.location.href = '/configs';
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
configManager.showError('Failed to save configuration: ' + error.message, 'csv-errors');
|
||||
}
|
||||
}
|
||||
95
app/web/templates/base.html
Normal file
95
app/web/templates/base.html
Normal file
@@ -0,0 +1,95 @@
|
||||
<!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>
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
|
||||
<!-- Custom CSS (extracted from inline) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
|
||||
<!-- Chart.js for visualizations -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
|
||||
<!-- Chart.js Dark Theme Configuration -->
|
||||
<script>
|
||||
// Configure Chart.js defaults for dark theme
|
||||
if (typeof Chart !== 'undefined') {
|
||||
Chart.defaults.color = '#e2e8f0';
|
||||
Chart.defaults.borderColor = '#334155';
|
||||
Chart.defaults.backgroundColor = '#1e293b';
|
||||
}
|
||||
</script>
|
||||
|
||||
{% block extra_styles %}{% endblock %}
|
||||
</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>
|
||||
<li class="nav-item">
|
||||
<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 and 'config' in request.endpoint %}active{% endif %}"
|
||||
href="{{ url_for('main.configs') }}">Configs</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 3 In Progress
|
||||
</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>
|
||||
263
app/web/templates/config_edit.html
Normal file
263
app/web/templates/config_edit.html
Normal file
@@ -0,0 +1,263 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit Config - SneakyScanner{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<!-- CodeMirror CSS -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/dracula.min.css">
|
||||
<style>
|
||||
.config-editor-container {
|
||||
background: #1e293b;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
height: 600px;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.validation-feedback {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.validation-feedback.success {
|
||||
background: #065f46;
|
||||
border: 1px solid #10b981;
|
||||
color: #d1fae5;
|
||||
}
|
||||
|
||||
.validation-feedback.error {
|
||||
background: #7f1d1d;
|
||||
border: 1px solid #ef4444;
|
||||
color: #fee2e2;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-lg mt-4">
|
||||
<a href="{{ url_for('main.configs') }}" class="back-link">
|
||||
<i class="bi bi-arrow-left"></i> Back to Configs
|
||||
</a>
|
||||
|
||||
<h2>Edit Configuration</h2>
|
||||
<p class="text-muted">Edit the YAML configuration for <strong>{{ filename }}</strong></p>
|
||||
|
||||
<div class="config-editor-container">
|
||||
<div class="editor-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-file-earmark-code"></i> YAML Editor
|
||||
</h5>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="validateConfig()">
|
||||
<i class="bi bi-check-circle"></i> Validate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea id="yaml-editor">{{ content }}</textarea>
|
||||
|
||||
<div class="validation-feedback" id="validation-feedback"></div>
|
||||
|
||||
<div class="editor-actions">
|
||||
<button type="button" class="btn btn-primary" onclick="saveConfig()">
|
||||
<i class="bi bi-save"></i> Save Changes
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="resetEditor()">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> Reset
|
||||
</button>
|
||||
<a href="{{ url_for('main.configs') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Modal -->
|
||||
<div class="modal fade" id="successModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-success text-white">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-check-circle-fill"></i> Success
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Configuration updated successfully!
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a href="{{ url_for('main.configs') }}" class="btn btn-success">
|
||||
Back to Configs
|
||||
</a>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
Continue Editing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- CodeMirror JS -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/mode/yaml/yaml.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Initialize CodeMirror editor
|
||||
const editor = CodeMirror.fromTextArea(document.getElementById('yaml-editor'), {
|
||||
mode: 'yaml',
|
||||
theme: 'dracula',
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
indentUnit: 2,
|
||||
tabSize: 2,
|
||||
indentWithTabs: false,
|
||||
extraKeys: {
|
||||
"Tab": function(cm) {
|
||||
cm.replaceSelection(" ", "end");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Store original content for reset
|
||||
const originalContent = editor.getValue();
|
||||
|
||||
// Validation function
|
||||
async function validateConfig() {
|
||||
const feedback = document.getElementById('validation-feedback');
|
||||
const content = editor.getValue();
|
||||
|
||||
try {
|
||||
// Basic YAML syntax check (client-side)
|
||||
// Just check for common YAML issues
|
||||
if (content.trim() === '') {
|
||||
showFeedback('error', 'Configuration cannot be empty');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for basic structure
|
||||
if (!content.includes('title:')) {
|
||||
showFeedback('error', 'Missing required field: title');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!content.includes('sites:')) {
|
||||
showFeedback('error', 'Missing required field: sites');
|
||||
return false;
|
||||
}
|
||||
|
||||
showFeedback('success', 'Configuration appears valid. Click "Save Changes" to save.');
|
||||
return true;
|
||||
} catch (error) {
|
||||
showFeedback('error', 'Validation error: ' + error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
async function saveConfig() {
|
||||
const content = editor.getValue();
|
||||
const filename = '{{ filename }}';
|
||||
|
||||
// Show loading state
|
||||
const saveBtn = event.target;
|
||||
const originalText = saveBtn.innerHTML;
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Saving...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/configs/${filename}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content: content })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Show success modal
|
||||
const modal = new bootstrap.Modal(document.getElementById('successModal'));
|
||||
modal.show();
|
||||
} else {
|
||||
// Show error feedback
|
||||
showFeedback('error', data.message || 'Failed to save configuration');
|
||||
}
|
||||
} catch (error) {
|
||||
showFeedback('error', 'Network error: ' + error.message);
|
||||
} finally {
|
||||
// Restore button state
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset editor to original content
|
||||
function resetEditor() {
|
||||
if (confirm('Are you sure you want to reset all changes?')) {
|
||||
editor.setValue(originalContent);
|
||||
hideFeedback();
|
||||
}
|
||||
}
|
||||
|
||||
// Show validation feedback
|
||||
function showFeedback(type, message) {
|
||||
const feedback = document.getElementById('validation-feedback');
|
||||
feedback.className = `validation-feedback ${type}`;
|
||||
feedback.innerHTML = `
|
||||
<i class="bi bi-${type === 'success' ? 'check-circle-fill' : 'exclamation-triangle-fill'}"></i>
|
||||
${message}
|
||||
`;
|
||||
feedback.style.display = 'block';
|
||||
}
|
||||
|
||||
// Hide validation feedback
|
||||
function hideFeedback() {
|
||||
const feedback = document.getElementById('validation-feedback');
|
||||
feedback.style.display = 'none';
|
||||
}
|
||||
|
||||
// Auto-validate on content change (debounced)
|
||||
let validationTimeout;
|
||||
editor.on('change', function() {
|
||||
clearTimeout(validationTimeout);
|
||||
hideFeedback();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
415
app/web/templates/config_upload.html
Normal file
415
app/web/templates/config_upload.html
Normal file
@@ -0,0 +1,415 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create Configuration - SneakyScanner{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/config-manager.css') }}">
|
||||
<style>
|
||||
.file-info {
|
||||
background-color: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
padding: 10px 15px;
|
||||
border-radius: 5px;
|
||||
margin-top: 15px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-info-name {
|
||||
color: #60a5fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.file-info-size {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
{% 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;">Create New Configuration</h1>
|
||||
<a href="{{ url_for('main.configs') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Configs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Tabs -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<ul class="nav nav-tabs mb-4" id="uploadTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="cidr-tab" data-bs-toggle="tab" data-bs-target="#cidr"
|
||||
type="button" role="tab" style="color: #60a5fa;">
|
||||
<i class="bi bi-diagram-3"></i> Create from CIDR
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="yaml-tab" data-bs-toggle="tab" data-bs-target="#yaml"
|
||||
type="button" role="tab" style="color: #60a5fa;">
|
||||
<i class="bi bi-filetype-yml"></i> Upload YAML
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="uploadTabsContent">
|
||||
<!-- CIDR Form Tab -->
|
||||
<div class="tab-pane fade show active" id="cidr" role="tabpanel">
|
||||
<div class="row">
|
||||
<div class="col-lg-8 offset-lg-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">
|
||||
<i class="bi bi-diagram-3"></i> Create Configuration from CIDR Range
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
Specify a CIDR range to automatically generate a configuration for all IPs in that range.
|
||||
You can edit the configuration afterwards to add expected ports and services.
|
||||
</p>
|
||||
|
||||
<form id="cidr-form">
|
||||
<div class="mb-3">
|
||||
<label for="config-title" class="form-label" style="color: #94a3b8;">
|
||||
Config Title <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="config-title"
|
||||
placeholder="e.g., Production Infrastructure Scan" required>
|
||||
<div class="form-text">A descriptive title for your scan configuration</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="cidr-range" class="form-label" style="color: #94a3b8;">
|
||||
CIDR Range <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="cidr-range"
|
||||
placeholder="e.g., 10.0.0.0/24 or 192.168.1.0/28" required>
|
||||
<div class="form-text">
|
||||
Enter a CIDR range (e.g., 10.0.0.0/24 for 254 hosts).
|
||||
Maximum 10,000 addresses per range.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="site-name" class="form-label" style="color: #94a3b8;">
|
||||
Site Name (optional)
|
||||
</label>
|
||||
<input type="text" class="form-control" id="site-name"
|
||||
placeholder="e.g., Production Servers">
|
||||
<div class="form-text">
|
||||
Logical grouping name for these IPs (default: "Site 1")
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="ping-default">
|
||||
<label class="form-check-label" for="ping-default" style="color: #94a3b8;">
|
||||
Expect ping response by default
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Sets the default expectation for ICMP ping responses from these IPs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cidr-errors" class="alert alert-danger" style="display:none;">
|
||||
<strong>Error:</strong> <span id="cidr-error-message"></span>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-plus-circle"></i> Create Configuration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="cidr-success" class="alert alert-success mt-3" style="display:none;">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<strong>Success!</strong> Configuration created: <span id="cidr-created-filename"></span>
|
||||
<div class="mt-2">
|
||||
<a href="#" id="edit-new-config-link" class="btn btn-sm btn-outline-success">
|
||||
<i class="bi bi-pencil"></i> Edit Now
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- YAML Upload Tab -->
|
||||
<div class="tab-pane fade" id="yaml" role="tabpanel">
|
||||
<div class="row">
|
||||
<div class="col-lg-8 offset-lg-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">
|
||||
<i class="bi bi-cloud-upload"></i> Upload YAML Configuration
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
For advanced users: upload a YAML config file directly.
|
||||
</p>
|
||||
|
||||
<div id="yaml-dropzone" class="dropzone">
|
||||
<i class="bi bi-cloud-upload"></i>
|
||||
<p>Drag & drop YAML file here or click to browse</p>
|
||||
<input type="file" id="yaml-file-input" accept=".yaml,.yml" hidden>
|
||||
</div>
|
||||
|
||||
<div id="yaml-file-info" class="file-info">
|
||||
<div class="file-info-name" id="yaml-filename"></div>
|
||||
<div class="file-info-size" id="yaml-filesize"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label for="yaml-custom-filename" class="form-label" style="color: #94a3b8;">
|
||||
Custom Filename (optional):
|
||||
</label>
|
||||
<input type="text" id="yaml-custom-filename" class="form-control"
|
||||
placeholder="Leave empty to use uploaded filename">
|
||||
</div>
|
||||
|
||||
<button id="upload-yaml-btn" class="btn btn-primary mt-3" disabled>
|
||||
<i class="bi bi-upload"></i> Upload YAML
|
||||
</button>
|
||||
|
||||
<div id="yaml-errors" class="alert alert-danger mt-3" style="display:none;">
|
||||
<strong>Error:</strong> <span id="yaml-error-message"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Modal -->
|
||||
<div class="modal fade" id="successModal" 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: #10b981;">
|
||||
<i class="bi bi-check-circle"></i> Success
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="color: #e2e8f0;">Configuration saved successfully!</p>
|
||||
<p style="color: #60a5fa; font-weight: bold;" id="success-filename"></p>
|
||||
</div>
|
||||
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
||||
<a href="{{ url_for('main.configs') }}" class="btn btn-primary">
|
||||
<i class="bi bi-list"></i> View All Configs
|
||||
</a>
|
||||
<button type="button" class="btn btn-success" onclick="location.reload()">
|
||||
<i class="bi bi-plus-circle"></i> Create Another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Global variables
|
||||
let yamlFile = null;
|
||||
|
||||
// ============== CIDR Form Submission ==============
|
||||
|
||||
document.getElementById('cidr-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const title = document.getElementById('config-title').value.trim();
|
||||
const cidr = document.getElementById('cidr-range').value.trim();
|
||||
const siteName = document.getElementById('site-name').value.trim();
|
||||
const pingDefault = document.getElementById('ping-default').checked;
|
||||
|
||||
// Validate inputs
|
||||
if (!title) {
|
||||
showError('cidr', 'Config title is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cidr) {
|
||||
showError('cidr', 'CIDR range is required');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
const submitBtn = e.target.querySelector('button[type="submit"]');
|
||||
const originalText = submitBtn.innerHTML;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/configs/create-from-cidr', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: title,
|
||||
cidr: cidr,
|
||||
site_name: siteName || null,
|
||||
ping_default: pingDefault
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Hide error, show success
|
||||
document.getElementById('cidr-errors').style.display = 'none';
|
||||
document.getElementById('cidr-created-filename').textContent = data.filename;
|
||||
|
||||
// Set edit link
|
||||
document.getElementById('edit-new-config-link').href = `/configs/edit/${data.filename}`;
|
||||
|
||||
document.getElementById('cidr-success').style.display = 'block';
|
||||
|
||||
// Reset form
|
||||
e.target.reset();
|
||||
|
||||
// Show success modal
|
||||
showSuccess(data.filename);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating config from CIDR:', error);
|
||||
showError('cidr', error.message);
|
||||
} finally {
|
||||
// Restore button state
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
}
|
||||
});
|
||||
|
||||
// ============== YAML Upload ==============
|
||||
|
||||
// Setup YAML dropzone
|
||||
const yamlDropzone = document.getElementById('yaml-dropzone');
|
||||
const yamlFileInput = document.getElementById('yaml-file-input');
|
||||
|
||||
yamlDropzone.addEventListener('click', () => yamlFileInput.click());
|
||||
|
||||
yamlDropzone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
yamlDropzone.classList.add('dragover');
|
||||
});
|
||||
|
||||
yamlDropzone.addEventListener('dragleave', () => {
|
||||
yamlDropzone.classList.remove('dragover');
|
||||
});
|
||||
|
||||
yamlDropzone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
yamlDropzone.classList.remove('dragover');
|
||||
const file = e.dataTransfer.files[0];
|
||||
handleYAMLFile(file);
|
||||
});
|
||||
|
||||
yamlFileInput.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
handleYAMLFile(file);
|
||||
});
|
||||
|
||||
// Handle YAML file selection
|
||||
function handleYAMLFile(file) {
|
||||
if (!file) return;
|
||||
|
||||
// Check file extension
|
||||
if (!file.name.endsWith('.yaml') && !file.name.endsWith('.yml')) {
|
||||
showError('yaml', 'Please select a YAML file (.yaml or .yml)');
|
||||
return;
|
||||
}
|
||||
|
||||
yamlFile = file;
|
||||
|
||||
// Show file info
|
||||
document.getElementById('yaml-filename').textContent = file.name;
|
||||
document.getElementById('yaml-filesize').textContent = formatFileSize(file.size);
|
||||
document.getElementById('yaml-file-info').style.display = 'block';
|
||||
|
||||
// Enable upload button
|
||||
document.getElementById('upload-yaml-btn').disabled = false;
|
||||
document.getElementById('yaml-errors').style.display = 'none';
|
||||
}
|
||||
|
||||
// Upload YAML file
|
||||
async function uploadYAMLFile() {
|
||||
if (!yamlFile) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', yamlFile);
|
||||
|
||||
const customFilename = document.getElementById('yaml-custom-filename').value.trim();
|
||||
if (customFilename) {
|
||||
formData.append('filename', customFilename);
|
||||
}
|
||||
|
||||
const response = await fetch('/api/configs/upload-yaml', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
showSuccess(data.filename);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error uploading YAML:', error);
|
||||
showError('yaml', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('upload-yaml-btn').addEventListener('click', uploadYAMLFile);
|
||||
|
||||
// ============== Helper Functions ==============
|
||||
|
||||
// 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];
|
||||
}
|
||||
|
||||
// Show error
|
||||
function showError(type, message) {
|
||||
const errorDiv = document.getElementById(`${type}-errors`);
|
||||
const errorMsg = document.getElementById(`${type}-error-message`);
|
||||
errorMsg.textContent = message;
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
// Show success
|
||||
function showSuccess(filename) {
|
||||
document.getElementById('success-filename').textContent = filename;
|
||||
new bootstrap.Modal(document.getElementById('successModal')).show();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
377
app/web/templates/configs.html
Normal file
377
app/web/templates/configs.html
Normal file
@@ -0,0 +1,377 @@
|
||||
{% 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 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>
|
||||
<div>
|
||||
<a href="{{ url_for('main.upload_config') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Create New Config
|
||||
</a>
|
||||
</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-configs">-</div>
|
||||
<div class="stat-label">Total Configs</div>
|
||||
</div>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configs 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 Configurations</h5>
|
||||
<input type="text" id="search-input" class="form-control" style="max-width: 300px;"
|
||||
placeholder="Search configs...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="configs-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 configurations...</p>
|
||||
</div>
|
||||
<div id="configs-error" style="display: none;" class="alert alert-danger">
|
||||
<strong>Error:</strong> <span id="error-message"></span>
|
||||
</div>
|
||||
<div id="configs-content" style="display: none;">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Title</th>
|
||||
<th>Created</th>
|
||||
<th>Size</th>
|
||||
<th>Used By</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="configs-tbody">
|
||||
<!-- Populated by JavaScript -->
|
||||
</tbody>
|
||||
</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-plus-circle"></i> Create Config
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</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 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>
|
||||
</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>
|
||||
|
||||
<!-- 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;
|
||||
|
||||
// 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];
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(timestamp) {
|
||||
if (!timestamp) return 'Unknown';
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
// Load configs from API
|
||||
async function loadConfigs() {
|
||||
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 data = await response.json();
|
||||
configsData = data.configs || [];
|
||||
|
||||
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';
|
||||
document.getElementById('configs-error').style.display = 'block';
|
||||
document.getElementById('error-message').textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const tbody = document.getElementById('configs-tbody');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
|
||||
if (configs.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('');
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
document.getElementById('search-input').addEventListener('input', function(e) {
|
||||
const searchTerm = e.target.value.toLowerCase();
|
||||
|
||||
if (!searchTerm) {
|
||||
renderConfigs(configsData);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = configsData.filter(config =>
|
||||
config.filename.toLowerCase().includes(searchTerm) ||
|
||||
(config.title && config.title.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
|
||||
renderConfigs(filtered);
|
||||
});
|
||||
|
||||
// Setup delete button
|
||||
document.getElementById('confirm-delete-btn').addEventListener('click', deleteConfig);
|
||||
|
||||
// Load configs on page load
|
||||
document.addEventListener('DOMContentLoaded', loadConfigs);
|
||||
</script>
|
||||
{% endblock %}
|
||||
580
app/web/templates/dashboard.html
Normal file
580
app/web/templates/dashboard.html
Normal file
@@ -0,0 +1,580 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - SneakyScanner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<h1 class="mb-4" style="color: #60a5fa;">Dashboard</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary btn-lg ms-2">
|
||||
<i class="bi bi-calendar-plus"></i> Manage Schedules
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scan Activity Chart -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">Scan Activity (Last 30 Days)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="chart-loading" class="text-center py-4">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="scanTrendChart" height="100" style="display: none;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedules Widget -->
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">Upcoming Schedules</h5>
|
||||
<a href="{{ url_for('main.schedules') }}" class="btn btn-sm btn-secondary">Manage</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="schedules-loading" class="text-center py-4">
|
||||
<div class="spinner-border spinner-border-sm" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="schedules-content" style="display: none;"></div>
|
||||
<div id="schedules-empty" class="text-muted text-center py-4" style="display: none;">
|
||||
No schedules configured yet.
|
||||
<br><br>
|
||||
<a href="{{ url_for('main.create_schedule') }}" class="btn btn-sm btn-primary">Create Schedule</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<select class="form-select" id="config-file" name="config_file" required {% if not config_files %}disabled{% endif %}>
|
||||
<option value="">Select a config file...</option>
|
||||
{% for config in config_files %}
|
||||
<option value="{{ config }}">{{ config }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if config_files %}
|
||||
<div class="form-text text-muted">
|
||||
Select a scan configuration file
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning mt-2 mb-0" role="alert">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<strong>No configurations available</strong>
|
||||
<p class="mb-2 mt-2">You need to create a configuration file before you can trigger a scan.</p>
|
||||
<a href="{{ url_for('main.upload_config') }}" class="btn btn-sm btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Create Configuration
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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()" {% if not config_files %}disabled{% endif %}>
|
||||
<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();
|
||||
loadScanTrend();
|
||||
loadSchedules();
|
||||
|
||||
// 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);
|
||||
|
||||
// Refresh schedules every 30 seconds
|
||||
setInterval(loadSchedules, 30000);
|
||||
});
|
||||
|
||||
// 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');
|
||||
row.classList.add('scan-row'); // Fix white row bug
|
||||
|
||||
// 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.message || data.error || 'Failed to trigger scan');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Hide error before closing modal to prevent flash
|
||||
errorEl.style.display = 'none';
|
||||
|
||||
// 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>
|
||||
`;
|
||||
// Insert at the beginning of container-fluid
|
||||
const container = document.querySelector('.container-fluid');
|
||||
container.insertBefore(alertDiv, container.firstChild);
|
||||
|
||||
// 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';
|
||||
}
|
||||
}
|
||||
|
||||
// Load scan trend chart
|
||||
async function loadScanTrend() {
|
||||
const chartLoading = document.getElementById('chart-loading');
|
||||
const canvas = document.getElementById('scanTrendChart');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/stats/scan-trend?days=30');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load trend data');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Hide loading, show chart
|
||||
chartLoading.style.display = 'none';
|
||||
canvas.style.display = 'block';
|
||||
|
||||
// Create chart
|
||||
const ctx = canvas.getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: 'Scans per Day',
|
||||
data: data.values,
|
||||
borderColor: '#60a5fa',
|
||||
backgroundColor: 'rgba(96, 165, 250, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: function(context) {
|
||||
return new Date(context[0].label).toLocaleDateString();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
color: '#94a3b8'
|
||||
},
|
||||
grid: {
|
||||
color: '#334155'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 10
|
||||
},
|
||||
grid: {
|
||||
color: '#334155'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading chart:', error);
|
||||
chartLoading.innerHTML = '<p class="text-muted">Failed to load chart data</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Load upcoming schedules
|
||||
async function loadSchedules() {
|
||||
const loadingEl = document.getElementById('schedules-loading');
|
||||
const contentEl = document.getElementById('schedules-content');
|
||||
const emptyEl = document.getElementById('schedules-empty');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/schedules?per_page=5');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load schedules');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const schedules = data.schedules || [];
|
||||
|
||||
loadingEl.style.display = 'none';
|
||||
|
||||
if (schedules.length === 0) {
|
||||
emptyEl.style.display = 'block';
|
||||
} else {
|
||||
contentEl.style.display = 'block';
|
||||
|
||||
// Filter enabled schedules and sort by next_run
|
||||
const enabledSchedules = schedules
|
||||
.filter(s => s.enabled && s.next_run)
|
||||
.sort((a, b) => new Date(a.next_run) - new Date(b.next_run))
|
||||
.slice(0, 3);
|
||||
|
||||
if (enabledSchedules.length === 0) {
|
||||
contentEl.innerHTML = '<p class="text-muted">No enabled schedules</p>';
|
||||
} else {
|
||||
contentEl.innerHTML = enabledSchedules.map(schedule => {
|
||||
const nextRun = new Date(schedule.next_run);
|
||||
const now = new Date();
|
||||
const diffMs = nextRun - now;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
let timeStr;
|
||||
if (diffMins < 1) {
|
||||
timeStr = 'In less than 1 minute';
|
||||
} else if (diffMins < 60) {
|
||||
timeStr = `In ${diffMins} minute${diffMins === 1 ? '' : 's'}`;
|
||||
} else if (diffHours < 24) {
|
||||
timeStr = `In ${diffHours} hour${diffHours === 1 ? '' : 's'}`;
|
||||
} else if (diffDays < 7) {
|
||||
timeStr = `In ${diffDays} day${diffDays === 1 ? '' : 's'}`;
|
||||
} else {
|
||||
timeStr = nextRun.toLocaleDateString();
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="mb-3 pb-3 border-bottom">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<strong>${schedule.name}</strong>
|
||||
<br>
|
||||
<small class="text-muted">${timeStr}</small>
|
||||
<br>
|
||||
<small class="text-muted mono">${schedule.cron_expression}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading schedules:', error);
|
||||
loadingEl.style.display = 'none';
|
||||
contentEl.style.display = 'block';
|
||||
contentEl.innerHTML = '<p class="text-muted">Failed to load schedules</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// 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 %}
|
||||
84
app/web/templates/errors/400.html
Normal file
84
app/web/templates/errors/400.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>400 - Bad Request | SneakyScanner</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #0f172a;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 8rem;
|
||||
font-weight: 700;
|
||||
color: #f59e0b;
|
||||
line-height: 1;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 0 0 20px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
border: none;
|
||||
padding: 0.75rem 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<div class="error-code">400</div>
|
||||
<h1 class="error-title">Bad Request</h1>
|
||||
<p class="error-message">
|
||||
The request could not be understood or was missing required parameters.
|
||||
<br>
|
||||
Please check your input and try again.
|
||||
</p>
|
||||
<a href="/" class="btn btn-primary">Go to Dashboard</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
84
app/web/templates/errors/401.html
Normal file
84
app/web/templates/errors/401.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>401 - Unauthorized | SneakyScanner</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #0f172a;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 8rem;
|
||||
font-weight: 700;
|
||||
color: #f59e0b;
|
||||
line-height: 1;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 0 0 20px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
border: none;
|
||||
padding: 0.75rem 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="error-icon">🔒</div>
|
||||
<div class="error-code">401</div>
|
||||
<h1 class="error-title">Unauthorized</h1>
|
||||
<p class="error-message">
|
||||
You need to be authenticated to access this page.
|
||||
<br>
|
||||
Please log in to continue.
|
||||
</p>
|
||||
<a href="/auth/login" class="btn btn-primary">Go to Login</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
84
app/web/templates/errors/403.html
Normal file
84
app/web/templates/errors/403.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>403 - Forbidden | SneakyScanner</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #0f172a;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 8rem;
|
||||
font-weight: 700;
|
||||
color: #ef4444;
|
||||
line-height: 1;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
border: none;
|
||||
padding: 0.75rem 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="error-icon">🚫</div>
|
||||
<div class="error-code">403</div>
|
||||
<h1 class="error-title">Forbidden</h1>
|
||||
<p class="error-message">
|
||||
You don't have permission to access this resource.
|
||||
<br>
|
||||
If you think this is an error, please contact the administrator.
|
||||
</p>
|
||||
<a href="/" class="btn btn-primary">Go to Dashboard</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
84
app/web/templates/errors/404.html
Normal file
84
app/web/templates/errors/404.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>404 - Page Not Found | SneakyScanner</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #0f172a;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 8rem;
|
||||
font-weight: 700;
|
||||
color: #60a5fa;
|
||||
line-height: 1;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 0 0 20px rgba(96, 165, 250, 0.3);
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
border: none;
|
||||
padding: 0.75rem 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="error-icon">🔍</div>
|
||||
<div class="error-code">404</div>
|
||||
<h1 class="error-title">Page Not Found</h1>
|
||||
<p class="error-message">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
<br>
|
||||
Let's get you back on track.
|
||||
</p>
|
||||
<a href="/" class="btn btn-primary">Go to Dashboard</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
84
app/web/templates/errors/405.html
Normal file
84
app/web/templates/errors/405.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>405 - Method Not Allowed | SneakyScanner</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #0f172a;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 8rem;
|
||||
font-weight: 700;
|
||||
color: #f59e0b;
|
||||
line-height: 1;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 0 0 20px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
border: none;
|
||||
padding: 0.75rem 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="error-icon">🚧</div>
|
||||
<div class="error-code">405</div>
|
||||
<h1 class="error-title">Method Not Allowed</h1>
|
||||
<p class="error-message">
|
||||
The HTTP method used is not allowed for this endpoint.
|
||||
<br>
|
||||
Please check the API documentation for valid methods.
|
||||
</p>
|
||||
<a href="/" class="btn btn-primary">Go to Dashboard</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
114
app/web/templates/errors/500.html
Normal file
114
app/web/templates/errors/500.html
Normal file
@@ -0,0 +1,114 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>500 - Internal Server Error | SneakyScanner</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #0f172a;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 8rem;
|
||||
font-weight: 700;
|
||||
color: #ef4444;
|
||||
line-height: 1;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
border: none;
|
||||
padding: 0.75rem 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.error-details-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.error-details-text {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<div class="error-code">500</div>
|
||||
<h1 class="error-title">Internal Server Error</h1>
|
||||
<p class="error-message">
|
||||
Something went wrong on our end. We've logged the error and will look into it.
|
||||
<br>
|
||||
Please try again in a few moments.
|
||||
</p>
|
||||
|
||||
<a href="/" class="btn btn-primary">Go to Dashboard</a>
|
||||
|
||||
<div class="error-details">
|
||||
<div class="error-details-title">Error Information:</div>
|
||||
<div class="error-details-text">
|
||||
An unexpected error occurred while processing your request. Our team has been notified and is working to resolve the issue.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
48
app/web/templates/login.html
Normal file
48
app/web/templates/login.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - SneakyScanner{% 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="brand-subtitle">Network Security Scanner</p>
|
||||
</div>
|
||||
|
||||
{% if password_not_set %}
|
||||
<div class="alert alert-warning">
|
||||
<strong>Setup Required:</strong> Please set an application password first.
|
||||
<a href="{{ url_for('auth.setup') }}" class="alert-link">Go to Setup</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('auth.login') }}">
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password"
|
||||
class="form-control form-control-lg"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
autofocus
|
||||
placeholder="Enter your password">
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
id="remember"
|
||||
name="remember">
|
||||
<label class="form-check-label" for="remember">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100">
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
545
app/web/templates/scan_compare.html
Normal file
545
app/web/templates/scan_compare.html
Normal file
@@ -0,0 +1,545 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Compare Scans - SneakyScanner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<a href="{{ url_for('main.scans') }}" class="text-muted text-decoration-none mb-2 d-inline-block">
|
||||
← Back to All Scans
|
||||
</a>
|
||||
<h1 style="color: #60a5fa;">Scan Comparison</h1>
|
||||
<p class="text-muted">Comparing Scan #{{ scan_id1 }} vs Scan #{{ scan_id2 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div id="comparison-loading" class="text-center py-5">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p class="mt-3 text-muted">Loading comparison...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div id="comparison-error" class="alert alert-danger" style="display: none;"></div>
|
||||
|
||||
<!-- Config Warning -->
|
||||
<div id="config-warning" class="alert alert-warning" style="display: none;">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<strong>Different Configurations Detected</strong>
|
||||
<p class="mb-0" id="config-warning-message"></p>
|
||||
</div>
|
||||
|
||||
<!-- Comparison Content -->
|
||||
<div id="comparison-content" style="display: none;">
|
||||
<!-- Drift Score Card -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">Infrastructure Drift Analysis</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<div class="display-4 mb-2" id="drift-score" style="color: #60a5fa;">-</div>
|
||||
<div class="text-muted">Drift Score</div>
|
||||
<small class="text-muted d-block mt-1">(0.0 = identical, 1.0 = completely different)</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted">Older Scan (#<span id="scan1-id"></span>)</label>
|
||||
<div id="scan1-title" class="fw-bold">-</div>
|
||||
<small class="text-muted d-block" id="scan1-timestamp">-</small>
|
||||
<small class="text-muted d-block"><i class="bi bi-file-earmark-text"></i> <span id="scan1-config">-</span></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted">Newer Scan (#<span id="scan2-id"></span>)</label>
|
||||
<div id="scan2-title" class="fw-bold">-</div>
|
||||
<small class="text-muted d-block" id="scan2-timestamp">-</small>
|
||||
<small class="text-muted d-block"><i class="bi bi-file-earmark-text"></i> <span id="scan2-config">-</span></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted">Quick Actions</label>
|
||||
<div>
|
||||
<a href="/scans/{{ scan_id1 }}" class="btn btn-sm btn-secondary">View Scan #{{ scan_id1 }}</a>
|
||||
<a href="/scans/{{ scan_id2 }}" class="btn btn-sm btn-secondary">View Scan #{{ scan_id2 }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ports Comparison -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">
|
||||
<i class="bi bi-hdd-network"></i> Port Changes
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="stat-card" style="background-color: #065f46; border-color: #6ee7b7;">
|
||||
<div class="stat-value" id="ports-added-count">0</div>
|
||||
<div class="stat-label">Ports Added</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="stat-card" style="background-color: #7f1d1d; border-color: #fca5a5;">
|
||||
<div class="stat-value" id="ports-removed-count">0</div>
|
||||
<div class="stat-label">Ports Removed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="ports-unchanged-count">0</div>
|
||||
<div class="stat-label">Ports Unchanged</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Added Ports -->
|
||||
<div id="ports-added-section" style="display: none;">
|
||||
<h6 class="text-success mb-2"><i class="bi bi-plus-circle"></i> Added Ports</h6>
|
||||
<div class="table-responsive mb-3">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP Address</th>
|
||||
<th>Port</th>
|
||||
<th>Protocol</th>
|
||||
<th>State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ports-added-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Removed Ports -->
|
||||
<div id="ports-removed-section" style="display: none;">
|
||||
<h6 class="text-danger mb-2"><i class="bi bi-dash-circle"></i> Removed Ports</h6>
|
||||
<div class="table-responsive mb-3">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP Address</th>
|
||||
<th>Port</th>
|
||||
<th>Protocol</th>
|
||||
<th>State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ports-removed-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Services Comparison -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">
|
||||
<i class="bi bi-gear"></i> Service Changes
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="stat-card" style="background-color: #065f46; border-color: #6ee7b7;">
|
||||
<div class="stat-value" id="services-added-count">0</div>
|
||||
<div class="stat-label">Services Added</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="stat-card" style="background-color: #7f1d1d; border-color: #fca5a5;">
|
||||
<div class="stat-value" id="services-removed-count">0</div>
|
||||
<div class="stat-label">Services Removed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="stat-card" style="background-color: #78350f; border-color: #fcd34d;">
|
||||
<div class="stat-value" id="services-changed-count">0</div>
|
||||
<div class="stat-label">Services Changed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Changed Services -->
|
||||
<div id="services-changed-section" style="display: none;">
|
||||
<h6 class="text-warning mb-2"><i class="bi bi-arrow-left-right"></i> Changed Services</h6>
|
||||
<div class="table-responsive mb-3">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP:Port</th>
|
||||
<th>Old Service</th>
|
||||
<th>New Service</th>
|
||||
<th>Old Version</th>
|
||||
<th>New Version</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="services-changed-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Added Services -->
|
||||
<div id="services-added-section" style="display: none;">
|
||||
<h6 class="text-success mb-2"><i class="bi bi-plus-circle"></i> Added Services</h6>
|
||||
<div class="table-responsive mb-3">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP Address</th>
|
||||
<th>Port</th>
|
||||
<th>Service</th>
|
||||
<th>Product</th>
|
||||
<th>Version</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="services-added-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Removed Services -->
|
||||
<div id="services-removed-section" style="display: none;">
|
||||
<h6 class="text-danger mb-2"><i class="bi bi-dash-circle"></i> Removed Services</h6>
|
||||
<div class="table-responsive mb-3">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP Address</th>
|
||||
<th>Port</th>
|
||||
<th>Service</th>
|
||||
<th>Product</th>
|
||||
<th>Version</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="services-removed-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Certificates Comparison -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">
|
||||
<i class="bi bi-shield-lock"></i> Certificate Changes
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="stat-card" style="background-color: #065f46; border-color: #6ee7b7;">
|
||||
<div class="stat-value" id="certs-added-count">0</div>
|
||||
<div class="stat-label">Certificates Added</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="stat-card" style="background-color: #7f1d1d; border-color: #fca5a5;">
|
||||
<div class="stat-value" id="certs-removed-count">0</div>
|
||||
<div class="stat-label">Certificates Removed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="stat-card" style="background-color: #78350f; border-color: #fcd34d;">
|
||||
<div class="stat-value" id="certs-changed-count">0</div>
|
||||
<div class="stat-label">Certificates Changed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Changed Certificates -->
|
||||
<div id="certs-changed-section" style="display: none;">
|
||||
<h6 class="text-warning mb-2"><i class="bi bi-arrow-left-right"></i> Changed Certificates</h6>
|
||||
<div class="table-responsive mb-3">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP:Port</th>
|
||||
<th>Old Subject</th>
|
||||
<th>New Subject</th>
|
||||
<th>Old Expiry</th>
|
||||
<th>New Expiry</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="certs-changed-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Added/Removed Certificates (shown if any) -->
|
||||
<div id="certs-added-removed-info" style="display: none;">
|
||||
<p class="text-muted mb-0">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
Additional certificate additions and removals correspond to the port changes shown above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const scanId1 = {{ scan_id1 }};
|
||||
const scanId2 = {{ scan_id2 }};
|
||||
|
||||
// Load comparison data
|
||||
async function loadComparison() {
|
||||
const loadingDiv = document.getElementById('comparison-loading');
|
||||
const errorDiv = document.getElementById('comparison-error');
|
||||
const contentDiv = document.getElementById('comparison-content');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/scans/${scanId1}/compare/${scanId2}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load comparison');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Hide loading, show content
|
||||
loadingDiv.style.display = 'none';
|
||||
contentDiv.style.display = 'block';
|
||||
|
||||
// Populate comparison UI
|
||||
populateComparison(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading comparison:', error);
|
||||
loadingDiv.style.display = 'none';
|
||||
errorDiv.textContent = `Error: ${error.message}`;
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function populateComparison(data) {
|
||||
// Show config warning if configs differ
|
||||
if (data.config_warning) {
|
||||
const warningDiv = document.getElementById('config-warning');
|
||||
const warningMessage = document.getElementById('config-warning-message');
|
||||
warningMessage.textContent = data.config_warning;
|
||||
warningDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
// Drift score
|
||||
const driftScore = data.drift_score || 0;
|
||||
document.getElementById('drift-score').textContent = driftScore.toFixed(3);
|
||||
|
||||
// Color code drift score
|
||||
const driftElement = document.getElementById('drift-score');
|
||||
if (driftScore < 0.1) {
|
||||
driftElement.style.color = '#6ee7b7'; // Green - minimal drift
|
||||
} else if (driftScore < 0.3) {
|
||||
driftElement.style.color = '#fcd34d'; // Yellow - moderate drift
|
||||
} else {
|
||||
driftElement.style.color = '#fca5a5'; // Red - significant drift
|
||||
}
|
||||
|
||||
// Scan metadata
|
||||
document.getElementById('scan1-id').textContent = data.scan1.id;
|
||||
document.getElementById('scan1-title').textContent = data.scan1.title || 'Untitled Scan';
|
||||
document.getElementById('scan1-timestamp').textContent = new Date(data.scan1.timestamp).toLocaleString();
|
||||
document.getElementById('scan1-config').textContent = data.scan1.config_file || 'Unknown';
|
||||
|
||||
document.getElementById('scan2-id').textContent = data.scan2.id;
|
||||
document.getElementById('scan2-title').textContent = data.scan2.title || 'Untitled Scan';
|
||||
document.getElementById('scan2-timestamp').textContent = new Date(data.scan2.timestamp).toLocaleString();
|
||||
document.getElementById('scan2-config').textContent = data.scan2.config_file || 'Unknown';
|
||||
|
||||
// Ports comparison
|
||||
populatePortsComparison(data.ports);
|
||||
|
||||
// Services comparison
|
||||
populateServicesComparison(data.services);
|
||||
|
||||
// Certificates comparison
|
||||
populateCertificatesComparison(data.certificates);
|
||||
}
|
||||
|
||||
function populatePortsComparison(ports) {
|
||||
const addedCount = ports.added.length;
|
||||
const removedCount = ports.removed.length;
|
||||
const unchangedCount = ports.unchanged.length;
|
||||
|
||||
document.getElementById('ports-added-count').textContent = addedCount;
|
||||
document.getElementById('ports-removed-count').textContent = removedCount;
|
||||
document.getElementById('ports-unchanged-count').textContent = unchangedCount;
|
||||
|
||||
// Show added ports
|
||||
if (addedCount > 0) {
|
||||
document.getElementById('ports-added-section').style.display = 'block';
|
||||
const tbody = document.getElementById('ports-added-tbody');
|
||||
tbody.innerHTML = '';
|
||||
ports.added.forEach(port => {
|
||||
const row = document.createElement('tr');
|
||||
row.classList.add('scan-row');
|
||||
row.innerHTML = `
|
||||
<td>${port.ip}</td>
|
||||
<td class="mono">${port.port}</td>
|
||||
<td>${port.protocol.toUpperCase()}</td>
|
||||
<td>${port.state}</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Show removed ports
|
||||
if (removedCount > 0) {
|
||||
document.getElementById('ports-removed-section').style.display = 'block';
|
||||
const tbody = document.getElementById('ports-removed-tbody');
|
||||
tbody.innerHTML = '';
|
||||
ports.removed.forEach(port => {
|
||||
const row = document.createElement('tr');
|
||||
row.classList.add('scan-row');
|
||||
row.innerHTML = `
|
||||
<td>${port.ip}</td>
|
||||
<td class="mono">${port.port}</td>
|
||||
<td>${port.protocol.toUpperCase()}</td>
|
||||
<td>${port.state}</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function populateServicesComparison(services) {
|
||||
const addedCount = services.added.length;
|
||||
const removedCount = services.removed.length;
|
||||
const changedCount = services.changed.length;
|
||||
|
||||
document.getElementById('services-added-count').textContent = addedCount;
|
||||
document.getElementById('services-removed-count').textContent = removedCount;
|
||||
document.getElementById('services-changed-count').textContent = changedCount;
|
||||
|
||||
// Show changed services
|
||||
if (changedCount > 0) {
|
||||
document.getElementById('services-changed-section').style.display = 'block';
|
||||
const tbody = document.getElementById('services-changed-tbody');
|
||||
tbody.innerHTML = '';
|
||||
services.changed.forEach(svc => {
|
||||
const row = document.createElement('tr');
|
||||
row.classList.add('scan-row');
|
||||
row.innerHTML = `
|
||||
<td class="mono">${svc.ip}:${svc.port}</td>
|
||||
<td>${svc.old.service_name || '-'}</td>
|
||||
<td class="text-warning">${svc.new.service_name || '-'}</td>
|
||||
<td>${svc.old.version || '-'}</td>
|
||||
<td class="text-warning">${svc.new.version || '-'}</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Show added services
|
||||
if (addedCount > 0) {
|
||||
document.getElementById('services-added-section').style.display = 'block';
|
||||
const tbody = document.getElementById('services-added-tbody');
|
||||
tbody.innerHTML = '';
|
||||
services.added.forEach(svc => {
|
||||
const row = document.createElement('tr');
|
||||
row.classList.add('scan-row');
|
||||
row.innerHTML = `
|
||||
<td>${svc.ip}</td>
|
||||
<td class="mono">${svc.port}</td>
|
||||
<td>${svc.service_name || '-'}</td>
|
||||
<td>${svc.product || '-'}</td>
|
||||
<td>${svc.version || '-'}</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Show removed services
|
||||
if (removedCount > 0) {
|
||||
document.getElementById('services-removed-section').style.display = 'block';
|
||||
const tbody = document.getElementById('services-removed-tbody');
|
||||
tbody.innerHTML = '';
|
||||
services.removed.forEach(svc => {
|
||||
const row = document.createElement('tr');
|
||||
row.classList.add('scan-row');
|
||||
row.innerHTML = `
|
||||
<td>${svc.ip}</td>
|
||||
<td class="mono">${svc.port}</td>
|
||||
<td>${svc.service_name || '-'}</td>
|
||||
<td>${svc.product || '-'}</td>
|
||||
<td>${svc.version || '-'}</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function populateCertificatesComparison(certs) {
|
||||
const addedCount = certs.added.length;
|
||||
const removedCount = certs.removed.length;
|
||||
const changedCount = certs.changed.length;
|
||||
|
||||
document.getElementById('certs-added-count').textContent = addedCount;
|
||||
document.getElementById('certs-removed-count').textContent = removedCount;
|
||||
document.getElementById('certs-changed-count').textContent = changedCount;
|
||||
|
||||
// Show changed certificates
|
||||
if (changedCount > 0) {
|
||||
document.getElementById('certs-changed-section').style.display = 'block';
|
||||
const tbody = document.getElementById('certs-changed-tbody');
|
||||
tbody.innerHTML = '';
|
||||
certs.changed.forEach(cert => {
|
||||
const row = document.createElement('tr');
|
||||
row.classList.add('scan-row');
|
||||
row.innerHTML = `
|
||||
<td class="mono">${cert.ip}:${cert.port}</td>
|
||||
<td>${cert.old.subject || '-'}</td>
|
||||
<td class="text-warning">${cert.new.subject || '-'}</td>
|
||||
<td>${cert.old.not_valid_after ? new Date(cert.old.not_valid_after).toLocaleDateString() : '-'}</td>
|
||||
<td class="text-warning">${cert.new.not_valid_after ? new Date(cert.new.not_valid_after).toLocaleDateString() : '-'}</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Show info if there are added/removed certs
|
||||
if (addedCount > 0 || removedCount > 0) {
|
||||
document.getElementById('certs-added-removed-info').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Load comparison on page load
|
||||
loadComparison();
|
||||
</script>
|
||||
{% endblock %}
|
||||
605
app/web/templates/scan_detail.html
Normal file
605
app/web/templates/scan_detail.html
Normal file
@@ -0,0 +1,605 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Scan #{{ scan_id }} - SneakyScanner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<a href="{{ url_for('main.scans') }}" class="text-muted text-decoration-none mb-2 d-inline-block">
|
||||
← Back to All Scans
|
||||
</a>
|
||||
<h1 style="color: #60a5fa;">Scan #<span id="scan-id">{{ scan_id }}</span></h1>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-primary" onclick="compareWithPrevious()" id="compare-btn" style="display: none;">
|
||||
<i class="bi bi-arrow-left-right"></i> Compare with Previous
|
||||
</button>
|
||||
<button class="btn btn-secondary ms-2" onclick="refreshScan()">
|
||||
<span id="refresh-text">Refresh</span>
|
||||
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<!-- Historical Trend Chart -->
|
||||
<div class="row mb-4" id="historical-chart-row" style="display: none;">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">
|
||||
<i class="bi bi-graph-up"></i> Port Count History
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-3">
|
||||
Historical port count trend for scans using the same configuration
|
||||
</p>
|
||||
<div style="position: relative; height: 300px;">
|
||||
<canvas id="historyChart"></canvas>
|
||||
</div>
|
||||
</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;
|
||||
let historyChart = null; // Store chart instance to prevent duplicates
|
||||
|
||||
// Load scan on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadScan().then(() => {
|
||||
findPreviousScan();
|
||||
loadHistoricalChart();
|
||||
});
|
||||
|
||||
// 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 class="scan-row"><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.classList.add('scan-row'); // Fix white row bug
|
||||
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;
|
||||
}
|
||||
|
||||
// Disable delete button to prevent double-clicks
|
||||
const deleteBtn = document.getElementById('delete-btn');
|
||||
deleteBtn.disabled = true;
|
||||
deleteBtn.textContent = 'Deleting...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/scans/${scanId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Check status code first
|
||||
if (!response.ok) {
|
||||
// Try to get error message from response
|
||||
let errorMessage = `HTTP ${response.status}: Failed to delete scan`;
|
||||
try {
|
||||
const data = await response.json();
|
||||
errorMessage = data.message || errorMessage;
|
||||
} catch (e) {
|
||||
// Ignore JSON parse errors for error responses
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// For successful responses, try to parse JSON but don't fail if it doesn't work
|
||||
try {
|
||||
await response.json();
|
||||
} catch (e) {
|
||||
console.warn('Response is not valid JSON, but deletion succeeded');
|
||||
}
|
||||
|
||||
// Wait 2 seconds to ensure deletion completes fully
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Redirect to scans list
|
||||
window.location.href = '{{ url_for("main.scans") }}';
|
||||
} catch (error) {
|
||||
console.error('Error deleting scan:', error);
|
||||
alert(`Failed to delete scan: ${error.message}`);
|
||||
|
||||
// Re-enable button on error
|
||||
deleteBtn.disabled = false;
|
||||
deleteBtn.textContent = 'Delete Scan';
|
||||
}
|
||||
}
|
||||
|
||||
// Find previous scan and show compare button
|
||||
let previousScanId = null;
|
||||
let currentConfigFile = null;
|
||||
async function findPreviousScan() {
|
||||
try {
|
||||
// Get current scan details first to know which config it used
|
||||
const currentScanResponse = await fetch(`/api/scans/${scanId}`);
|
||||
const currentScanData = await currentScanResponse.json();
|
||||
currentConfigFile = currentScanData.config_file;
|
||||
|
||||
// Get list of completed scans
|
||||
const response = await fetch('/api/scans?per_page=100&status=completed');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.scans && data.scans.length > 0) {
|
||||
// Find the current scan
|
||||
const currentScanIndex = data.scans.findIndex(s => s.id === scanId);
|
||||
|
||||
if (currentScanIndex !== -1) {
|
||||
// Look for the most recent previous scan with the SAME config file
|
||||
for (let i = currentScanIndex + 1; i < data.scans.length; i++) {
|
||||
const previousScan = data.scans[i];
|
||||
|
||||
// Check if this scan uses the same config
|
||||
if (previousScan.config_file === currentConfigFile) {
|
||||
previousScanId = previousScan.id;
|
||||
|
||||
// Show the compare button
|
||||
const compareBtn = document.getElementById('compare-btn');
|
||||
if (compareBtn) {
|
||||
compareBtn.style.display = 'inline-block';
|
||||
compareBtn.title = `Compare with Scan #${previousScanId} (same config)`;
|
||||
}
|
||||
break; // Found the most recent matching scan
|
||||
}
|
||||
}
|
||||
|
||||
// If no matching config found, don't show compare button
|
||||
if (!previousScanId) {
|
||||
console.log('No previous scans found with the same configuration');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error finding previous scan:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Compare with previous scan
|
||||
function compareWithPrevious() {
|
||||
if (previousScanId) {
|
||||
window.location.href = `/scans/${previousScanId}/compare/${scanId}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Load historical trend chart
|
||||
async function loadHistoricalChart() {
|
||||
try {
|
||||
const response = await fetch(`/api/stats/scan-history/${scanId}?limit=20`);
|
||||
const data = await response.json();
|
||||
|
||||
// Only show chart if there are multiple scans
|
||||
if (data.scans && data.scans.length > 1) {
|
||||
document.getElementById('historical-chart-row').style.display = 'block';
|
||||
|
||||
// Destroy existing chart to prevent canvas growth bug
|
||||
if (historyChart) {
|
||||
historyChart.destroy();
|
||||
}
|
||||
|
||||
const ctx = document.getElementById('historyChart').getContext('2d');
|
||||
historyChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: 'Open Ports',
|
||||
data: data.port_counts,
|
||||
borderColor: '#60a5fa',
|
||||
backgroundColor: 'rgba(96, 165, 250, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointBackgroundColor: '#60a5fa',
|
||||
pointBorderColor: '#1e293b',
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#1e293b',
|
||||
titleColor: '#e2e8f0',
|
||||
bodyColor: '#e2e8f0',
|
||||
borderColor: '#334155',
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
afterLabel: function(context) {
|
||||
const scan = data.scans[context.dataIndex];
|
||||
return `Scan ID: ${scan.id}\nIPs: ${scan.ip_count}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
color: '#94a3b8'
|
||||
},
|
||||
grid: {
|
||||
color: '#334155'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
maxRotation: 45,
|
||||
minRotation: 45
|
||||
},
|
||||
grid: {
|
||||
color: '#334155'
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick: (event, elements) => {
|
||||
if (elements.length > 0) {
|
||||
const index = elements[0].index;
|
||||
const scan = data.scans[index];
|
||||
window.location.href = `/scans/${scan.id}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading historical chart:', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
487
app/web/templates/scans.html
Normal file
487
app/web/templates/scans.html
Normal file
@@ -0,0 +1,487 @@
|
||||
{% 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>
|
||||
</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>
|
||||
<select class="form-select" id="config-file" name="config_file" required {% if not config_files %}disabled{% endif %}>
|
||||
<option value="">Select a config file...</option>
|
||||
{% for config in config_files %}
|
||||
<option value="{{ config }}">{{ config }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if config_files %}
|
||||
<div class="form-text text-muted">
|
||||
Select a scan configuration file
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning mt-2 mb-0" role="alert">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<strong>No configurations available</strong>
|
||||
<p class="mb-2 mt-2">You need to create a configuration file before you can trigger a scan.</p>
|
||||
<a href="{{ url_for('main.upload_config') }}" class="btn btn-sm btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Create Configuration
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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()" {% if not config_files %}disabled{% endif %}>
|
||||
<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');
|
||||
row.classList.add('scan-row'); // Fix white row bug
|
||||
|
||||
// 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();
|
||||
|
||||
// Hide error before closing modal to prevent flash
|
||||
errorEl.style.display = 'none';
|
||||
|
||||
// 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>
|
||||
`;
|
||||
// Insert at the beginning of container-fluid
|
||||
const container = document.querySelector('.container-fluid');
|
||||
container.insertBefore(alertDiv, container.firstChild);
|
||||
|
||||
// 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 %}
|
||||
442
app/web/templates/schedule_create.html
Normal file
442
app/web/templates/schedule_create.html
Normal file
@@ -0,0 +1,442 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create Schedule - 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;">Create Schedule</h1>
|
||||
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Schedules
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<form id="create-schedule-form">
|
||||
<!-- Basic Information Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">Basic Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Schedule Name -->
|
||||
<div class="mb-3">
|
||||
<label for="schedule-name" class="form-label">Schedule Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="schedule-name" name="name"
|
||||
placeholder="e.g., Daily Infrastructure Scan"
|
||||
required>
|
||||
<small class="form-text text-muted">A descriptive name for this schedule</small>
|
||||
</div>
|
||||
|
||||
<!-- Config File -->
|
||||
<div class="mb-3">
|
||||
<label for="config-file" class="form-label">Configuration File <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="config-file" name="config_file" required>
|
||||
<option value="">Select a configuration file...</option>
|
||||
{% for config in config_files %}
|
||||
<option value="{{ config }}">{{ config }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">The scan configuration to use for this schedule</small>
|
||||
</div>
|
||||
|
||||
<!-- Enable/Disable -->
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="schedule-enabled"
|
||||
name="enabled" checked>
|
||||
<label class="form-check-label" for="schedule-enabled">
|
||||
Enable schedule immediately
|
||||
</label>
|
||||
</div>
|
||||
<small class="form-text text-muted">If disabled, the schedule will be created but not executed</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cron Expression Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">Schedule Configuration</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Quick Templates -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Quick Templates:</label>
|
||||
<div class="btn-group-vertical btn-group-sm w-100" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * *')">
|
||||
<strong>Daily at Midnight (local)</strong> <code class="float-end">0 0 * * *</code>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 2 * * *')">
|
||||
<strong>Daily at 2 AM (local)</strong> <code class="float-end">0 2 * * *</code>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 */6 * * *')">
|
||||
<strong>Every 6 Hours</strong> <code class="float-end">0 */6 * * *</code>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * 0')">
|
||||
<strong>Weekly (Sunday at Midnight)</strong> <code class="float-end">0 0 * * 0</code>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 1 * *')">
|
||||
<strong>Monthly (1st at Midnight)</strong> <code class="float-end">0 0 1 * *</code>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Cron Entry -->
|
||||
<div class="mb-3">
|
||||
<label for="cron-expression" class="form-label">
|
||||
Cron Expression <span class="text-danger">*</span>
|
||||
<span class="badge bg-info">LOCAL TIME</span>
|
||||
</label>
|
||||
<input type="text" class="form-control font-monospace" id="cron-expression"
|
||||
name="cron_expression" placeholder="0 2 * * *"
|
||||
oninput="validateCron()" required>
|
||||
<small class="form-text text-muted">
|
||||
Format: <code>minute hour day month weekday</code><br>
|
||||
<strong class="text-info">ℹ All times use your local timezone (CST/UTC-6)</strong>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Cron Validation Feedback -->
|
||||
<div id="cron-feedback" class="alert" style="display: none;"></div>
|
||||
|
||||
<!-- Human-Readable Description -->
|
||||
<div id="cron-description-container" style="display: none;">
|
||||
<div class="alert alert-info">
|
||||
<strong>Description:</strong>
|
||||
<div id="cron-description" class="mt-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next Run Times Preview -->
|
||||
<div id="next-runs-container" style="display: none;">
|
||||
<label class="form-label">Next 5 execution times (local time):</label>
|
||||
<ul id="next-runs-list" class="list-group">
|
||||
<!-- Populated by JavaScript -->
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary" id="submit-btn">
|
||||
<i class="bi bi-plus-circle"></i> Create Schedule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Help Sidebar -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card sticky-top" style="top: 20px;">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">Cron Expression Help</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h6>Field Format:</h6>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Values</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Minute</td>
|
||||
<td>0-59</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Hour</td>
|
||||
<td>0-23</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Day</td>
|
||||
<td>1-31</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Month</td>
|
||||
<td>1-12</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Weekday</td>
|
||||
<td>0-6 (0=Sunday)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h6 class="mt-3">Special Characters:</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li><code>*</code> - Any value</li>
|
||||
<li><code>*/n</code> - Every n units</li>
|
||||
<li><code>1,2,3</code> - Specific values</li>
|
||||
<li><code>1-5</code> - Range of values</li>
|
||||
</ul>
|
||||
|
||||
<h6 class="mt-3">Examples:</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li><code>0 0 * * *</code> - Daily at midnight</li>
|
||||
<li><code>*/15 * * * *</code> - Every 15 minutes</li>
|
||||
<li><code>0 9-17 * * 1-5</code> - Hourly, 9am-5pm, Mon-Fri</li>
|
||||
</ul>
|
||||
|
||||
<div class="alert alert-info mt-3">
|
||||
<strong><i class="bi bi-info-circle"></i> Timezone Information:</strong><br>
|
||||
All cron expressions use your <strong>local system time</strong>.<br><br>
|
||||
<strong>Current local time:</strong> <span id="user-local-time"></span><br>
|
||||
<strong>Your timezone:</strong> <span id="timezone-offset"></span><br><br>
|
||||
<small>Schedules will run at the specified time in your local timezone.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Update local time and timezone info every second
|
||||
function updateServerTime() {
|
||||
const now = new Date();
|
||||
const localTime = now.toLocaleTimeString();
|
||||
const offset = -now.getTimezoneOffset() / 60;
|
||||
const offsetStr = `CST (UTC${offset >= 0 ? '+' : ''}${offset})`;
|
||||
|
||||
if (document.getElementById('user-local-time')) {
|
||||
document.getElementById('user-local-time').textContent = localTime;
|
||||
}
|
||||
if (document.getElementById('timezone-offset')) {
|
||||
document.getElementById('timezone-offset').textContent = offsetStr;
|
||||
}
|
||||
}
|
||||
updateServerTime();
|
||||
setInterval(updateServerTime, 1000);
|
||||
|
||||
// Set cron expression from template button
|
||||
function setCron(expression) {
|
||||
document.getElementById('cron-expression').value = expression;
|
||||
validateCron();
|
||||
}
|
||||
|
||||
// Validate cron expression (client-side basic validation)
|
||||
function validateCron() {
|
||||
const input = document.getElementById('cron-expression');
|
||||
const expression = input.value.trim();
|
||||
const feedback = document.getElementById('cron-feedback');
|
||||
const descContainer = document.getElementById('cron-description-container');
|
||||
const description = document.getElementById('cron-description');
|
||||
const nextRunsContainer = document.getElementById('next-runs-container');
|
||||
|
||||
if (!expression) {
|
||||
feedback.style.display = 'none';
|
||||
descContainer.style.display = 'none';
|
||||
nextRunsContainer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic validation: should have 5 fields
|
||||
const parts = expression.split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
feedback.className = 'alert alert-danger';
|
||||
feedback.textContent = 'Invalid format: Cron expression must have exactly 5 fields (minute hour day month weekday)';
|
||||
feedback.style.display = 'block';
|
||||
descContainer.style.display = 'none';
|
||||
nextRunsContainer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic field validation
|
||||
const [minute, hour, day, month, weekday] = parts;
|
||||
|
||||
const errors = [];
|
||||
|
||||
if (!isValidCronField(minute, 0, 59)) errors.push('minute (0-59)');
|
||||
if (!isValidCronField(hour, 0, 23)) errors.push('hour (0-23)');
|
||||
if (!isValidCronField(day, 1, 31)) errors.push('day (1-31)');
|
||||
if (!isValidCronField(month, 1, 12)) errors.push('month (1-12)');
|
||||
if (!isValidCronField(weekday, 0, 6)) errors.push('weekday (0-6)');
|
||||
|
||||
if (errors.length > 0) {
|
||||
feedback.className = 'alert alert-danger';
|
||||
feedback.textContent = 'Invalid fields: ' + errors.join(', ');
|
||||
feedback.style.display = 'block';
|
||||
descContainer.style.display = 'none';
|
||||
nextRunsContainer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Valid expression
|
||||
feedback.className = 'alert alert-success';
|
||||
feedback.textContent = 'Valid cron expression';
|
||||
feedback.style.display = 'block';
|
||||
|
||||
// Show human-readable description
|
||||
description.textContent = describeCron(parts);
|
||||
descContainer.style.display = 'block';
|
||||
|
||||
// Calculate and show next run times
|
||||
calculateNextRuns(expression);
|
||||
nextRunsContainer.style.display = 'block';
|
||||
}
|
||||
|
||||
// Validate individual cron field
|
||||
function isValidCronField(field, min, max) {
|
||||
if (field === '*') return true;
|
||||
|
||||
// Handle ranges: 1-5
|
||||
if (field.includes('-')) {
|
||||
const [start, end] = field.split('-').map(Number);
|
||||
return start >= min && end <= max && start <= end;
|
||||
}
|
||||
|
||||
// Handle steps: */5 or 1-10/2
|
||||
if (field.includes('/')) {
|
||||
const [range, step] = field.split('/');
|
||||
if (range === '*') return Number(step) > 0;
|
||||
return isValidCronField(range, min, max) && Number(step) > 0;
|
||||
}
|
||||
|
||||
// Handle lists: 1,2,3
|
||||
if (field.includes(',')) {
|
||||
return field.split(',').every(v => {
|
||||
const num = Number(v);
|
||||
return !isNaN(num) && num >= min && num <= max;
|
||||
});
|
||||
}
|
||||
|
||||
// Single number
|
||||
const num = Number(field);
|
||||
return !isNaN(num) && num >= min && num <= max;
|
||||
}
|
||||
|
||||
// Generate human-readable description
|
||||
function describeCron(parts) {
|
||||
const [minute, hour, day, month, weekday] = parts;
|
||||
|
||||
// Common patterns
|
||||
if (minute === '0' && hour === '0' && day === '*' && month === '*' && weekday === '*') {
|
||||
return 'Runs daily at midnight (local time)';
|
||||
}
|
||||
if (minute === '0' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
|
||||
return `Runs daily at ${hour.padStart(2, '0')}:00 (local time)`;
|
||||
}
|
||||
if (minute !== '*' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
|
||||
return `Runs daily at ${hour.padStart(2, '0')}:${minute.padStart(2, '0')} (local time)`;
|
||||
}
|
||||
if (minute === '0' && hour === '0' && day === '*' && month === '*' && weekday !== '*') {
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
return `Runs weekly on ${days[Number(weekday)]} at midnight`;
|
||||
}
|
||||
if (minute === '0' && hour === '0' && day !== '*' && month === '*' && weekday === '*') {
|
||||
return `Runs monthly on day ${day} at midnight`;
|
||||
}
|
||||
if (minute.startsWith('*/')) {
|
||||
const interval = minute.split('/')[1];
|
||||
return `Runs every ${interval} minutes`;
|
||||
}
|
||||
if (hour.startsWith('*/') && minute === '0') {
|
||||
const interval = hour.split('/')[1];
|
||||
return `Runs every ${interval} hours`;
|
||||
}
|
||||
|
||||
return `Runs at ${minute} ${hour} ${day} ${month} ${weekday} (cron format)`;
|
||||
}
|
||||
|
||||
// Calculate next 5 run times (simplified - server will do actual calculation)
|
||||
function calculateNextRuns(expression) {
|
||||
const list = document.getElementById('next-runs-list');
|
||||
list.innerHTML = '<li class="list-group-item"><em>Will be calculated by server...</em></li>';
|
||||
|
||||
// In production, this would call an API endpoint to get accurate next runs
|
||||
// For now, just show placeholder
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('create-schedule-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const originalText = submitBtn.innerHTML;
|
||||
|
||||
// Get form data
|
||||
const formData = {
|
||||
name: document.getElementById('schedule-name').value.trim(),
|
||||
config_file: document.getElementById('config-file').value,
|
||||
cron_expression: document.getElementById('cron-expression').value.trim(),
|
||||
enabled: document.getElementById('schedule-enabled').checked
|
||||
};
|
||||
|
||||
// Validate
|
||||
if (!formData.name || !formData.config_file || !formData.cron_expression) {
|
||||
showNotification('Please fill in all required fields', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable submit button
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/schedules', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
showNotification('Schedule created successfully! Redirecting...', 'success');
|
||||
|
||||
// Redirect to schedules list
|
||||
setTimeout(() => {
|
||||
window.location.href = '/schedules';
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating schedule:', error);
|
||||
showNotification(`Error: ${error.message}`, 'danger');
|
||||
|
||||
// Re-enable submit button
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
}
|
||||
});
|
||||
|
||||
// Show notification
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `alert alert-${type} alert-dismissible fade show`;
|
||||
notification.style.position = 'fixed';
|
||||
notification.style.top = '20px';
|
||||
notification.style.right = '20px';
|
||||
notification.style.zIndex = '9999';
|
||||
notification.style.minWidth = '300px';
|
||||
|
||||
notification.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.remove();
|
||||
}, 5000);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
596
app/web/templates/schedule_edit.html
Normal file
596
app/web/templates/schedule_edit.html
Normal file
@@ -0,0 +1,596 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit Schedule - 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;">Edit Schedule</h1>
|
||||
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Schedules
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<!-- Loading State -->
|
||||
<div id="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 schedule...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div id="error-state" style="display: none;" class="alert alert-danger">
|
||||
<strong>Error:</strong> <span id="error-message"></span>
|
||||
</div>
|
||||
|
||||
<!-- Edit Form -->
|
||||
<form id="edit-schedule-form" style="display: none;">
|
||||
<input type="hidden" id="schedule-id">
|
||||
|
||||
<!-- Basic Information Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">Basic Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Schedule Name -->
|
||||
<div class="mb-3">
|
||||
<label for="schedule-name" class="form-label">Schedule Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="schedule-name" name="name"
|
||||
placeholder="e.g., Daily Infrastructure Scan"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<!-- Config File (read-only) -->
|
||||
<div class="mb-3">
|
||||
<label for="config-file" class="form-label">Configuration File</label>
|
||||
<input type="text" class="form-control" id="config-file" readonly>
|
||||
<small class="form-text text-muted">Configuration file cannot be changed after creation</small>
|
||||
</div>
|
||||
|
||||
<!-- Enable/Disable -->
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="schedule-enabled"
|
||||
name="enabled">
|
||||
<label class="form-check-label" for="schedule-enabled">
|
||||
Schedule enabled
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">
|
||||
<strong>Created:</strong> <span id="created-at">-</span>
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">
|
||||
<strong>Last Modified:</strong> <span id="updated-at">-</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cron Expression Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">Schedule Configuration</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Quick Templates -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Quick Templates:</label>
|
||||
<div class="btn-group-vertical btn-group-sm w-100" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * *')">
|
||||
<strong>Daily at Midnight (local)</strong> <code class="float-end">0 0 * * *</code>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 2 * * *')">
|
||||
<strong>Daily at 2 AM (local)</strong> <code class="float-end">0 2 * * *</code>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 */6 * * *')">
|
||||
<strong>Every 6 Hours</strong> <code class="float-end">0 */6 * * *</code>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * 0')">
|
||||
<strong>Weekly (Sunday at Midnight)</strong> <code class="float-end">0 0 * * 0</code>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 1 * *')">
|
||||
<strong>Monthly (1st at Midnight)</strong> <code class="float-end">0 0 1 * *</code>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Cron Entry -->
|
||||
<div class="mb-3">
|
||||
<label for="cron-expression" class="form-label">
|
||||
Cron Expression <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control font-monospace" id="cron-expression"
|
||||
name="cron_expression" placeholder="0 2 * * *"
|
||||
oninput="validateCron()" required>
|
||||
<small class="form-text text-muted">
|
||||
Format: <code>minute hour day month weekday</code> (local timezone)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Cron Validation Feedback -->
|
||||
<div id="cron-feedback" class="alert" style="display: none;"></div>
|
||||
|
||||
<!-- Run Times Info -->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="alert alert-info">
|
||||
<strong>Last Run:</strong><br>
|
||||
<span id="last-run" style="white-space: pre-line;">Never</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="alert alert-info">
|
||||
<strong>Next Run:</strong><br>
|
||||
<span id="next-run" style="white-space: pre-line;">Not scheduled</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Execution History Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">Execution History</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="history-loading" class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
<span class="ms-2 text-muted">Loading history...</span>
|
||||
</div>
|
||||
<div id="history-content" style="display: none;">
|
||||
<p class="text-muted">Last 10 scans triggered by this schedule:</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Scan ID</th>
|
||||
<th>Started</th>
|
||||
<th>Status</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="history-tbody">
|
||||
<!-- Populated by JavaScript -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="history-empty" style="display: none;" class="text-center py-3 text-muted">
|
||||
No executions yet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<button type="button" class="btn btn-danger" onclick="deleteSchedule()">
|
||||
<i class="bi bi-trash"></i> Delete Schedule
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="testRun()">
|
||||
<i class="bi bi-play-fill"></i> Test Run Now
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary me-2">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary" id="submit-btn">
|
||||
<i class="bi bi-check-circle"></i> Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Help Sidebar -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card sticky-top" style="top: 20px;">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">Cron Expression Help</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h6>Field Format:</h6>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Values</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Minute</td>
|
||||
<td>0-59</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Hour</td>
|
||||
<td>0-23</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Day</td>
|
||||
<td>1-31</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Month</td>
|
||||
<td>1-12</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Weekday</td>
|
||||
<td>0-6 (0=Sunday)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h6 class="mt-3">Special Characters:</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li><code>*</code> - Any value</li>
|
||||
<li><code>*/n</code> - Every n units</li>
|
||||
<li><code>1,2,3</code> - Specific values</li>
|
||||
<li><code>1-5</code> - Range of values</li>
|
||||
</ul>
|
||||
|
||||
<div class="alert alert-info mt-3">
|
||||
<strong><i class="bi bi-info-circle"></i> Timezone Information:</strong><br>
|
||||
All cron expressions use your <strong>local system time</strong>.<br><br>
|
||||
<strong>Current local time:</strong> <span id="current-local"></span><br>
|
||||
<strong>Your timezone:</strong> <span id="tz-offset"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let scheduleData = null;
|
||||
|
||||
// Get schedule ID from URL
|
||||
const scheduleId = parseInt(window.location.pathname.split('/')[2]);
|
||||
|
||||
// Load schedule data
|
||||
async function loadSchedule() {
|
||||
try {
|
||||
const response = await fetch(`/api/schedules/${scheduleId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
scheduleData = await response.json();
|
||||
|
||||
// Populate form
|
||||
populateForm(scheduleData);
|
||||
|
||||
// Load execution history
|
||||
loadHistory();
|
||||
|
||||
// Hide loading, show form
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('edit-schedule-form').style.display = 'block';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading schedule:', error);
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('error-state').style.display = 'block';
|
||||
document.getElementById('error-message').textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Populate form with schedule data
|
||||
function populateForm(schedule) {
|
||||
document.getElementById('schedule-id').value = schedule.id;
|
||||
document.getElementById('schedule-name').value = schedule.name;
|
||||
document.getElementById('config-file').value = schedule.config_file;
|
||||
document.getElementById('cron-expression').value = schedule.cron_expression;
|
||||
document.getElementById('schedule-enabled').checked = schedule.enabled;
|
||||
|
||||
// Metadata
|
||||
document.getElementById('created-at').textContent = new Date(schedule.created_at).toLocaleString();
|
||||
document.getElementById('updated-at').textContent = new Date(schedule.updated_at).toLocaleString();
|
||||
|
||||
// Run times - show in local time
|
||||
document.getElementById('last-run').textContent = schedule.last_run
|
||||
? formatRelativeTime(schedule.last_run) + '\n' +
|
||||
new Date(schedule.last_run).toLocaleString()
|
||||
: 'Never';
|
||||
|
||||
document.getElementById('next-run').textContent = schedule.next_run && schedule.enabled
|
||||
? formatRelativeTime(schedule.next_run) + '\n' +
|
||||
new Date(schedule.next_run).toLocaleString()
|
||||
: (schedule.enabled ? 'Calculating...' : 'Disabled');
|
||||
|
||||
// Validate cron
|
||||
validateCron();
|
||||
}
|
||||
|
||||
// Load execution history
|
||||
async function loadHistory() {
|
||||
try {
|
||||
// Note: This would ideally be a separate API endpoint
|
||||
// For now, we'll fetch scans filtered by schedule_id
|
||||
const response = await fetch(`/api/scans?schedule_id=${scheduleId}&limit=10`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const scans = data.scans || [];
|
||||
|
||||
renderHistory(scans);
|
||||
|
||||
document.getElementById('history-loading').style.display = 'none';
|
||||
document.getElementById('history-content').style.display = 'block';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading history:', error);
|
||||
document.getElementById('history-loading').innerHTML = '<p class="text-danger">Failed to load history</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Render history table
|
||||
function renderHistory(scans) {
|
||||
const tbody = document.getElementById('history-tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (scans.length === 0) {
|
||||
document.querySelector('#history-content .table-responsive').style.display = 'none';
|
||||
document.getElementById('history-empty').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelector('#history-content .table-responsive').style.display = 'block';
|
||||
document.getElementById('history-empty').style.display = 'none';
|
||||
|
||||
scans.forEach(scan => {
|
||||
const row = document.createElement('tr');
|
||||
row.classList.add('schedule-row');
|
||||
row.style.cursor = 'pointer';
|
||||
row.onclick = () => window.location.href = `/scans/${scan.id}`;
|
||||
|
||||
const duration = scan.end_time
|
||||
? Math.round((new Date(scan.end_time) - new Date(scan.timestamp)) / 1000) + 's'
|
||||
: '-';
|
||||
|
||||
row.innerHTML = `
|
||||
<td class="mono"><a href="/scans/${scan.id}">#${scan.id}</a></td>
|
||||
<td>${new Date(scan.timestamp).toLocaleString()}</td>
|
||||
<td>${getStatusBadge(scan.status)}</td>
|
||||
<td>${duration}</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Get status badge
|
||||
function getStatusBadge(status) {
|
||||
const badges = {
|
||||
'running': '<span class="badge bg-primary">Running</span>',
|
||||
'completed': '<span class="badge bg-success">Completed</span>',
|
||||
'failed': '<span class="badge bg-danger">Failed</span>',
|
||||
'pending': '<span class="badge bg-warning">Pending</span>'
|
||||
};
|
||||
return badges[status] || '<span class="badge bg-secondary">' + status + '</span>';
|
||||
}
|
||||
|
||||
// Format relative time
|
||||
function formatRelativeTime(timestamp) {
|
||||
if (!timestamp) return 'Never';
|
||||
|
||||
const now = new Date();
|
||||
const date = new Date(timestamp);
|
||||
const diffMs = date - now;
|
||||
const diffMinutes = Math.abs(Math.floor(diffMs / 60000));
|
||||
const diffHours = Math.abs(Math.floor(diffMs / 3600000));
|
||||
const diffDays = Math.abs(Math.floor(diffMs / 86400000));
|
||||
|
||||
if (diffMs < 0) {
|
||||
// Past time
|
||||
if (diffMinutes < 1) return 'Just now';
|
||||
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
return `${diffDays} days ago`;
|
||||
} else {
|
||||
// Future time
|
||||
if (diffMinutes < 1) return 'In less than a minute';
|
||||
if (diffMinutes < 60) return `In ${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''}`;
|
||||
if (diffHours < 24) return `In ${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
|
||||
if (diffDays === 1) return 'Tomorrow';
|
||||
return `In ${diffDays} days`;
|
||||
}
|
||||
}
|
||||
|
||||
// Set cron from template
|
||||
function setCron(expression) {
|
||||
document.getElementById('cron-expression').value = expression;
|
||||
validateCron();
|
||||
}
|
||||
|
||||
// Validate cron (basic client-side)
|
||||
function validateCron() {
|
||||
const expression = document.getElementById('cron-expression').value.trim();
|
||||
const feedback = document.getElementById('cron-feedback');
|
||||
|
||||
if (!expression) {
|
||||
feedback.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = expression.split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
feedback.className = 'alert alert-danger';
|
||||
feedback.textContent = 'Invalid: Must have exactly 5 fields';
|
||||
feedback.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
feedback.className = 'alert alert-success';
|
||||
feedback.textContent = 'Valid cron expression';
|
||||
feedback.style.display = 'block';
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('edit-schedule-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const originalText = submitBtn.innerHTML;
|
||||
|
||||
const formData = {
|
||||
name: document.getElementById('schedule-name').value.trim(),
|
||||
cron_expression: document.getElementById('cron-expression').value.trim(),
|
||||
enabled: document.getElementById('schedule-enabled').checked
|
||||
};
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Saving...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/schedules/${scheduleId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
showNotification('Schedule updated successfully! Redirecting...', 'success');
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = '/schedules';
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating schedule:', error);
|
||||
showNotification(`Error: ${error.message}`, 'danger');
|
||||
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
}
|
||||
});
|
||||
|
||||
// Test run
|
||||
async function testRun() {
|
||||
if (!confirm('Trigger a test run of this schedule now?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/schedules/${scheduleId}/trigger`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
showNotification(`Scan triggered! Redirecting to scan #${data.scan_id}...`, 'success');
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = `/scans/${data.scan_id}`;
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error triggering schedule:', error);
|
||||
showNotification(`Error: ${error.message}`, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete schedule
|
||||
async function deleteSchedule() {
|
||||
const scheduleName = document.getElementById('schedule-name').value;
|
||||
|
||||
if (!confirm(`Delete schedule "${scheduleName}"?\n\nThis action cannot be undone. Associated scan history will be preserved.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/schedules/${scheduleId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
showNotification('Schedule deleted successfully! Redirecting...', 'success');
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = '/schedules';
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting schedule:', error);
|
||||
showNotification(`Error: ${error.message}`, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// Show notification
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `alert alert-${type} alert-dismissible fade show`;
|
||||
notification.style.position = 'fixed';
|
||||
notification.style.top = '20px';
|
||||
notification.style.right = '20px';
|
||||
notification.style.zIndex = '9999';
|
||||
notification.style.minWidth = '300px';
|
||||
|
||||
notification.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Update current time display
|
||||
function updateCurrentTime() {
|
||||
const now = new Date();
|
||||
if (document.getElementById('current-local')) {
|
||||
document.getElementById('current-local').textContent = now.toLocaleTimeString();
|
||||
}
|
||||
if (document.getElementById('tz-offset')) {
|
||||
const offset = -now.getTimezoneOffset() / 60;
|
||||
document.getElementById('tz-offset').textContent = `CST (UTC${offset >= 0 ? '+' : ''}${offset})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Load on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadSchedule();
|
||||
updateCurrentTime();
|
||||
setInterval(updateCurrentTime, 1000);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
393
app/web/templates/schedules.html
Normal file
393
app/web/templates/schedules.html
Normal file
@@ -0,0 +1,393 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Scheduled Scans - SneakyScanner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 style="color: #60a5fa;">Scheduled Scans</h1>
|
||||
<a href="{{ url_for('main.create_schedule') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> New Schedule
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="total-schedules">-</div>
|
||||
<div class="stat-label">Total Schedules</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="enabled-schedules">-</div>
|
||||
<div class="stat-label">Enabled</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="next-run-time">-</div>
|
||||
<div class="stat-label">Next Run</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="recent-executions">-</div>
|
||||
<div class="stat-label">Executions (24h)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedules Table -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">All Schedules</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="schedules-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 schedules...</p>
|
||||
</div>
|
||||
<div id="schedules-error" style="display: none;" class="alert alert-danger">
|
||||
<strong>Error:</strong> <span id="error-message"></span>
|
||||
</div>
|
||||
<div id="schedules-content" style="display: none;">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Schedule (Cron)</th>
|
||||
<th>Next Run</th>
|
||||
<th>Last Run</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="schedules-tbody">
|
||||
<!-- Populated by JavaScript -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="empty-state" style="display: none;" class="text-center py-5">
|
||||
<i class="bi bi-calendar-x" style="font-size: 3rem; color: #64748b;"></i>
|
||||
<h5 class="mt-3 text-muted">No schedules configured</h5>
|
||||
<p class="text-muted">Create your first schedule to automate scans</p>
|
||||
<a href="{{ url_for('main.create_schedule') }}" class="btn btn-primary mt-2">
|
||||
<i class="bi bi-plus-circle"></i> Create Schedule
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Global variables
|
||||
let schedulesData = [];
|
||||
|
||||
// Format relative time (e.g., "in 2 hours", "5 minutes ago")
|
||||
function formatRelativeTime(timestamp) {
|
||||
if (!timestamp) return 'Never';
|
||||
|
||||
const now = new Date();
|
||||
const date = new Date(timestamp);
|
||||
const diffMs = date - now;
|
||||
const diffMinutes = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
// Get local time string for tooltip/fallback
|
||||
const localStr = date.toLocaleString();
|
||||
|
||||
if (diffMs < 0) {
|
||||
// Past time
|
||||
const absDiffMinutes = Math.abs(diffMinutes);
|
||||
const absDiffHours = Math.abs(diffHours);
|
||||
const absDiffDays = Math.abs(diffDays);
|
||||
|
||||
if (absDiffMinutes < 1) return 'Just now';
|
||||
if (absDiffMinutes === 1) return '1 minute ago';
|
||||
if (absDiffMinutes < 60) return `${absDiffMinutes} minutes ago`;
|
||||
if (absDiffHours === 1) return '1 hour ago';
|
||||
if (absDiffHours < 24) return `${absDiffHours} hours ago`;
|
||||
if (absDiffDays === 1) return 'Yesterday';
|
||||
if (absDiffDays < 7) return `${absDiffDays} days ago`;
|
||||
return `<span title="${localStr}">${absDiffDays} days ago</span>`;
|
||||
} else {
|
||||
// Future time
|
||||
if (diffMinutes < 1) return 'In less than a minute';
|
||||
if (diffMinutes === 1) return 'In 1 minute';
|
||||
if (diffMinutes < 60) return `In ${diffMinutes} minutes`;
|
||||
if (diffHours === 1) return 'In 1 hour';
|
||||
if (diffHours < 24) return `In ${diffHours} hours`;
|
||||
if (diffDays === 1) return 'Tomorrow';
|
||||
if (diffDays < 7) return `In ${diffDays} days`;
|
||||
return `<span title="${localStr}">In ${diffDays} days</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Get status badge HTML
|
||||
function getStatusBadge(enabled) {
|
||||
if (enabled) {
|
||||
return '<span class="badge bg-success">Enabled</span>';
|
||||
} else {
|
||||
return '<span class="badge bg-secondary">Disabled</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// Load schedules from API
|
||||
async function loadSchedules() {
|
||||
try {
|
||||
const response = await fetch('/api/schedules');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
schedulesData = data.schedules || [];
|
||||
|
||||
renderSchedules();
|
||||
updateStats(data);
|
||||
|
||||
// Hide loading, show content
|
||||
document.getElementById('schedules-loading').style.display = 'none';
|
||||
document.getElementById('schedules-error').style.display = 'none';
|
||||
document.getElementById('schedules-content').style.display = 'block';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading schedules:', error);
|
||||
document.getElementById('schedules-loading').style.display = 'none';
|
||||
document.getElementById('schedules-content').style.display = 'none';
|
||||
document.getElementById('schedules-error').style.display = 'block';
|
||||
document.getElementById('error-message').textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Render schedules table
|
||||
function renderSchedules() {
|
||||
const tbody = document.getElementById('schedules-tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (schedulesData.length === 0) {
|
||||
document.querySelector('.table-responsive').style.display = 'none';
|
||||
document.getElementById('empty-state').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelector('.table-responsive').style.display = 'block';
|
||||
document.getElementById('empty-state').style.display = 'none';
|
||||
|
||||
schedulesData.forEach(schedule => {
|
||||
const row = document.createElement('tr');
|
||||
row.classList.add('schedule-row');
|
||||
|
||||
row.innerHTML = `
|
||||
<td class="mono">#${schedule.id}</td>
|
||||
<td>
|
||||
<strong>${escapeHtml(schedule.name)}</strong>
|
||||
<br>
|
||||
<small class="text-muted">${escapeHtml(schedule.config_file)}</small>
|
||||
</td>
|
||||
<td class="mono"><code>${escapeHtml(schedule.cron_expression)}</code></td>
|
||||
<td>${formatRelativeTime(schedule.next_run)}</td>
|
||||
<td>${formatRelativeTime(schedule.last_run)}</td>
|
||||
<td>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
id="enable-${schedule.id}"
|
||||
${schedule.enabled ? 'checked' : ''}
|
||||
onchange="toggleSchedule(${schedule.id}, this.checked)">
|
||||
<label class="form-check-label" for="enable-${schedule.id}">
|
||||
${schedule.enabled ? 'Enabled' : 'Disabled'}
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-secondary" onclick="triggerSchedule(${schedule.id})"
|
||||
title="Run Now">
|
||||
<i class="bi bi-play-fill"></i>
|
||||
</button>
|
||||
<a href="/schedules/${schedule.id}/edit" class="btn btn-secondary"
|
||||
title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<button class="btn btn-danger" onclick="deleteSchedule(${schedule.id})"
|
||||
title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Update stats
|
||||
function updateStats(data) {
|
||||
const totalSchedules = data.total || schedulesData.length;
|
||||
const enabledSchedules = schedulesData.filter(s => s.enabled).length;
|
||||
|
||||
// Find next run time
|
||||
let nextRun = null;
|
||||
schedulesData.filter(s => s.enabled && s.next_run).forEach(s => {
|
||||
const scheduleNext = new Date(s.next_run);
|
||||
if (!nextRun || scheduleNext < nextRun) {
|
||||
nextRun = scheduleNext;
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate executions in last 24h (would need API support)
|
||||
const recentExecutions = data.recent_executions || 0;
|
||||
|
||||
document.getElementById('total-schedules').textContent = totalSchedules;
|
||||
document.getElementById('enabled-schedules').textContent = enabledSchedules;
|
||||
document.getElementById('next-run-time').innerHTML = nextRun
|
||||
? `<small>${formatRelativeTime(nextRun)}</small>`
|
||||
: '<small>None</small>';
|
||||
document.getElementById('recent-executions').textContent = recentExecutions;
|
||||
}
|
||||
|
||||
// Toggle schedule enabled/disabled
|
||||
async function toggleSchedule(scheduleId, enabled) {
|
||||
try {
|
||||
const response = await fetch(`/api/schedules/${scheduleId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ enabled: enabled })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update schedule: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Reload schedules
|
||||
await loadSchedules();
|
||||
|
||||
// Show success notification
|
||||
showNotification(`Schedule ${enabled ? 'enabled' : 'disabled'} successfully`, 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error toggling schedule:', error);
|
||||
showNotification(`Error: ${error.message}`, 'danger');
|
||||
|
||||
// Revert checkbox
|
||||
document.getElementById(`enable-${scheduleId}`).checked = !enabled;
|
||||
}
|
||||
}
|
||||
|
||||
// Manually trigger schedule
|
||||
async function triggerSchedule(scheduleId) {
|
||||
if (!confirm('Run this schedule now?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/schedules/${scheduleId}/trigger`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to trigger schedule: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
showNotification(`Scan triggered! Redirecting to scan #${data.scan_id}...`, 'success');
|
||||
|
||||
// Redirect to scan detail page
|
||||
setTimeout(() => {
|
||||
window.location.href = `/scans/${data.scan_id}`;
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error triggering schedule:', error);
|
||||
showNotification(`Error: ${error.message}`, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete schedule
|
||||
async function deleteSchedule(scheduleId) {
|
||||
const schedule = schedulesData.find(s => s.id === scheduleId);
|
||||
const scheduleName = schedule ? schedule.name : `#${scheduleId}`;
|
||||
|
||||
if (!confirm(`Delete schedule "${scheduleName}"?\n\nThis action cannot be undone. Associated scan history will be preserved.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/schedules/${scheduleId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete schedule: ${response.statusText}`);
|
||||
}
|
||||
|
||||
showNotification('Schedule deleted successfully', 'success');
|
||||
|
||||
// Reload schedules
|
||||
await loadSchedules();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting schedule:', error);
|
||||
showNotification(`Error: ${error.message}`, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// Show notification
|
||||
function showNotification(message, type = 'info') {
|
||||
// Create notification element
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `alert alert-${type} alert-dismissible fade show`;
|
||||
notification.style.position = 'fixed';
|
||||
notification.style.top = '20px';
|
||||
notification.style.right = '20px';
|
||||
notification.style.zIndex = '9999';
|
||||
notification.style.minWidth = '300px';
|
||||
|
||||
notification.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
notification.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Escape HTML to prevent XSS
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Load schedules on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadSchedules();
|
||||
|
||||
// Refresh every 30 seconds
|
||||
setInterval(loadSchedules, 30000);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
95
app/web/templates/setup.html
Normal file
95
app/web/templates/setup.html
Normal file
@@ -0,0 +1,95 @@
|
||||
<!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>Setup - SneakyScanner</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
}
|
||||
.setup-container {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
padding: 2rem;
|
||||
}
|
||||
.card {
|
||||
border: none;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.brand-title {
|
||||
color: #00d9ff;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="setup-container">
|
||||
<div class="card">
|
||||
<div class="card-body p-5">
|
||||
<div class="text-center mb-4">
|
||||
<h1 class="brand-title">SneakyScanner</h1>
|
||||
<p class="text-muted">Initial Setup</p>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mb-4">
|
||||
<strong>Welcome!</strong> Please set an application password to secure your scanner.
|
||||
</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 %}
|
||||
|
||||
<form method="post" action="{{ url_for('auth.setup') }}">
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password"
|
||||
class="form-control"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
minlength="8"
|
||||
autofocus
|
||||
placeholder="Enter password (min 8 characters)">
|
||||
<div class="form-text">Password must be at least 8 characters long.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="confirm_password" class="form-label">Confirm Password</label>
|
||||
<input type="password"
|
||||
class="form-control"
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
required
|
||||
minlength="8"
|
||||
placeholder="Confirm your password">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100">
|
||||
Set Password
|
||||
</button>
|
||||
</form>
|
||||
</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>
|
||||
0
app/web/utils/__init__.py
Normal file
0
app/web/utils/__init__.py
Normal file
158
app/web/utils/pagination.py
Normal file
158
app/web/utils/pagination.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Pagination utilities for SneakyScanner web application.
|
||||
|
||||
Provides helper functions for paginating SQLAlchemy queries.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List
|
||||
from sqlalchemy.orm import Query
|
||||
|
||||
|
||||
class PaginatedResult:
|
||||
"""Container for paginated query results."""
|
||||
|
||||
def __init__(self, items: List[Any], total: int, page: int, per_page: int):
|
||||
"""
|
||||
Initialize paginated result.
|
||||
|
||||
Args:
|
||||
items: List of items for current page
|
||||
total: Total number of items across all pages
|
||||
page: Current page number (1-indexed)
|
||||
per_page: Number of items per page
|
||||
"""
|
||||
self.items = items
|
||||
self.total = total
|
||||
self.page = page
|
||||
self.per_page = per_page
|
||||
|
||||
@property
|
||||
def pages(self) -> int:
|
||||
"""Calculate total number of pages."""
|
||||
if self.per_page == 0:
|
||||
return 0
|
||||
return (self.total + self.per_page - 1) // self.per_page
|
||||
|
||||
@property
|
||||
def has_prev(self) -> bool:
|
||||
"""Check if there is a previous page."""
|
||||
return self.page > 1
|
||||
|
||||
@property
|
||||
def has_next(self) -> bool:
|
||||
"""Check if there is a next page."""
|
||||
return self.page < self.pages
|
||||
|
||||
@property
|
||||
def prev_page(self) -> int:
|
||||
"""Get previous page number."""
|
||||
return self.page - 1 if self.has_prev else None
|
||||
|
||||
@property
|
||||
def next_page(self) -> int:
|
||||
"""Get next page number."""
|
||||
return self.page + 1 if self.has_next else None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert to dictionary for API responses.
|
||||
|
||||
Returns:
|
||||
Dictionary with pagination metadata and items
|
||||
"""
|
||||
return {
|
||||
'items': self.items,
|
||||
'total': self.total,
|
||||
'page': self.page,
|
||||
'per_page': self.per_page,
|
||||
'pages': self.pages,
|
||||
'has_prev': self.has_prev,
|
||||
'has_next': self.has_next,
|
||||
'prev_page': self.prev_page,
|
||||
'next_page': self.next_page,
|
||||
}
|
||||
|
||||
|
||||
def paginate(query: Query, page: int = 1, per_page: int = 20,
|
||||
max_per_page: int = 100) -> PaginatedResult:
|
||||
"""
|
||||
Paginate a SQLAlchemy query.
|
||||
|
||||
Args:
|
||||
query: SQLAlchemy query to paginate
|
||||
page: Page number (1-indexed, default: 1)
|
||||
per_page: Items per page (default: 20)
|
||||
max_per_page: Maximum items per page (default: 100)
|
||||
|
||||
Returns:
|
||||
PaginatedResult with items and pagination metadata
|
||||
|
||||
Examples:
|
||||
>>> from web.models import Scan
|
||||
>>> query = db.query(Scan).order_by(Scan.timestamp.desc())
|
||||
>>> result = paginate(query, page=1, per_page=20)
|
||||
>>> scans = result.items
|
||||
>>> total_pages = result.pages
|
||||
"""
|
||||
# Validate and sanitize parameters
|
||||
page = max(1, page) # Page must be at least 1
|
||||
per_page = max(1, min(per_page, max_per_page)) # Clamp per_page
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Calculate offset
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
# Execute query with limit and offset
|
||||
items = query.limit(per_page).offset(offset).all()
|
||||
|
||||
return PaginatedResult(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page
|
||||
)
|
||||
|
||||
|
||||
def validate_page_params(page: Any, per_page: Any,
|
||||
max_per_page: int = 100) -> tuple[int, int]:
|
||||
"""
|
||||
Validate and sanitize pagination parameters.
|
||||
|
||||
Args:
|
||||
page: Page number (any type, will be converted to int)
|
||||
per_page: Items per page (any type, will be converted to int)
|
||||
max_per_page: Maximum items per page (default: 100)
|
||||
|
||||
Returns:
|
||||
Tuple of (validated_page, validated_per_page)
|
||||
|
||||
Examples:
|
||||
>>> validate_page_params('2', '50')
|
||||
(2, 50)
|
||||
>>> validate_page_params(-1, 200)
|
||||
(1, 100)
|
||||
>>> validate_page_params(None, None)
|
||||
(1, 20)
|
||||
"""
|
||||
# Default values
|
||||
default_page = 1
|
||||
default_per_page = 20
|
||||
|
||||
# Convert to int, use default if invalid
|
||||
try:
|
||||
page = int(page) if page is not None else default_page
|
||||
except (ValueError, TypeError):
|
||||
page = default_page
|
||||
|
||||
try:
|
||||
per_page = int(per_page) if per_page is not None else default_per_page
|
||||
except (ValueError, TypeError):
|
||||
per_page = default_per_page
|
||||
|
||||
# Validate ranges
|
||||
page = max(1, page)
|
||||
per_page = max(1, min(per_page, max_per_page))
|
||||
|
||||
return page, per_page
|
||||
323
app/web/utils/settings.py
Normal file
323
app/web/utils/settings.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
Settings management system for SneakyScanner.
|
||||
|
||||
Provides secure storage and retrieval of application settings with encryption
|
||||
for sensitive values like passwords and API tokens.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import bcrypt
|
||||
from cryptography.fernet import Fernet
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.models import Setting
|
||||
|
||||
|
||||
class SettingsManager:
|
||||
"""
|
||||
Manages application settings with encryption support.
|
||||
|
||||
Handles CRUD operations for settings stored in the database, with automatic
|
||||
encryption/decryption for sensitive values.
|
||||
"""
|
||||
|
||||
# Keys that should be encrypted when stored
|
||||
ENCRYPTED_KEYS = {
|
||||
'smtp_password',
|
||||
'api_token',
|
||||
'encryption_key',
|
||||
}
|
||||
|
||||
def __init__(self, db_session: Session, encryption_key: Optional[bytes] = None):
|
||||
"""
|
||||
Initialize the settings manager.
|
||||
|
||||
Args:
|
||||
db_session: SQLAlchemy database session
|
||||
encryption_key: Fernet encryption key (32 url-safe base64-encoded bytes)
|
||||
If not provided, will generate or load from environment
|
||||
"""
|
||||
self.db = db_session
|
||||
self._encryption_key = encryption_key or self._get_or_create_encryption_key()
|
||||
self._cipher = Fernet(self._encryption_key)
|
||||
|
||||
def _get_or_create_encryption_key(self) -> bytes:
|
||||
"""
|
||||
Get encryption key from environment or generate new one.
|
||||
|
||||
Returns:
|
||||
Fernet encryption key (32 url-safe base64-encoded bytes)
|
||||
"""
|
||||
# Try to get from environment variable
|
||||
key_str = os.environ.get('SNEAKYSCANNER_ENCRYPTION_KEY')
|
||||
if key_str:
|
||||
return key_str.encode()
|
||||
|
||||
# Try to get from settings table (for persistence)
|
||||
existing_key = self.get('encryption_key', decrypt=False)
|
||||
if existing_key:
|
||||
return existing_key.encode()
|
||||
|
||||
# Generate new key if none exists
|
||||
new_key = Fernet.generate_key()
|
||||
# Store it in settings (unencrypted, as it's the key itself)
|
||||
self._store_raw('encryption_key', new_key.decode())
|
||||
return new_key
|
||||
|
||||
def _store_raw(self, key: str, value: str) -> None:
|
||||
"""Store a setting without encryption (internal use only)."""
|
||||
setting = self.db.query(Setting).filter_by(key=key).first()
|
||||
if setting:
|
||||
setting.value = value
|
||||
setting.updated_at = datetime.utcnow()
|
||||
else:
|
||||
setting = Setting(key=key, value=value)
|
||||
self.db.add(setting)
|
||||
self.db.commit()
|
||||
|
||||
def _should_encrypt(self, key: str) -> bool:
|
||||
"""Check if a setting key should be encrypted."""
|
||||
return key in self.ENCRYPTED_KEYS
|
||||
|
||||
def _encrypt(self, value: str) -> str:
|
||||
"""Encrypt a string value."""
|
||||
return self._cipher.encrypt(value.encode()).decode()
|
||||
|
||||
def _decrypt(self, encrypted_value: str) -> str:
|
||||
"""Decrypt an encrypted value."""
|
||||
return self._cipher.decrypt(encrypted_value.encode()).decode()
|
||||
|
||||
def get(self, key: str, default: Any = None, decrypt: bool = True) -> Any:
|
||||
"""
|
||||
Get a setting value by key.
|
||||
|
||||
Args:
|
||||
key: Setting key to retrieve
|
||||
default: Default value if key not found
|
||||
decrypt: Whether to decrypt if value is encrypted
|
||||
|
||||
Returns:
|
||||
Setting value (automatically decrypts if needed and decrypt=True)
|
||||
"""
|
||||
setting = self.db.query(Setting).filter_by(key=key).first()
|
||||
if not setting:
|
||||
return default
|
||||
|
||||
value = setting.value
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
# Decrypt if needed
|
||||
if decrypt and self._should_encrypt(key):
|
||||
try:
|
||||
value = self._decrypt(value)
|
||||
except Exception:
|
||||
# If decryption fails, return as-is (might be legacy unencrypted value)
|
||||
pass
|
||||
|
||||
# Try to parse JSON for complex types
|
||||
if value.startswith('[') or value.startswith('{'):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return value
|
||||
|
||||
def set(self, key: str, value: Any, encrypt: bool = None) -> None:
|
||||
"""
|
||||
Set a setting value.
|
||||
|
||||
Args:
|
||||
key: Setting key
|
||||
value: Setting value (will be JSON-encoded if dict/list)
|
||||
encrypt: Force encryption on/off (None = auto-detect from ENCRYPTED_KEYS)
|
||||
"""
|
||||
# Convert complex types to JSON
|
||||
if isinstance(value, (dict, list)):
|
||||
value_str = json.dumps(value)
|
||||
else:
|
||||
value_str = str(value)
|
||||
|
||||
# Determine if we should encrypt
|
||||
should_encrypt = encrypt if encrypt is not None else self._should_encrypt(key)
|
||||
|
||||
if should_encrypt:
|
||||
value_str = self._encrypt(value_str)
|
||||
|
||||
# Store in database
|
||||
setting = self.db.query(Setting).filter_by(key=key).first()
|
||||
if setting:
|
||||
setting.value = value_str
|
||||
setting.updated_at = datetime.utcnow()
|
||||
else:
|
||||
setting = Setting(key=key, value=value_str)
|
||||
self.db.add(setting)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""
|
||||
Delete a setting.
|
||||
|
||||
Args:
|
||||
key: Setting key to delete
|
||||
|
||||
Returns:
|
||||
True if deleted, False if key not found
|
||||
"""
|
||||
setting = self.db.query(Setting).filter_by(key=key).first()
|
||||
if setting:
|
||||
self.db.delete(setting)
|
||||
self.db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_all(self, decrypt: bool = False, sanitize: bool = True) -> Dict[str, Any]:
|
||||
"""
|
||||
Get all settings as a dictionary.
|
||||
|
||||
Args:
|
||||
decrypt: Whether to decrypt encrypted values
|
||||
sanitize: If True, replaces encrypted values with '***' for security
|
||||
|
||||
Returns:
|
||||
Dictionary of all settings
|
||||
"""
|
||||
settings = self.db.query(Setting).all()
|
||||
result = {}
|
||||
|
||||
for setting in settings:
|
||||
key = setting.key
|
||||
value = setting.value
|
||||
|
||||
if value is None:
|
||||
result[key] = None
|
||||
continue
|
||||
|
||||
# Handle sanitization for sensitive keys
|
||||
if sanitize and self._should_encrypt(key):
|
||||
result[key] = '***ENCRYPTED***'
|
||||
continue
|
||||
|
||||
# Decrypt if requested
|
||||
if decrypt and self._should_encrypt(key):
|
||||
try:
|
||||
value = self._decrypt(value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try to parse JSON
|
||||
if value and (value.startswith('[') or value.startswith('{')):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
result[key] = value
|
||||
|
||||
return result
|
||||
|
||||
def init_defaults(self) -> None:
|
||||
"""
|
||||
Initialize default settings if they don't exist.
|
||||
|
||||
This should be called on first app startup to populate default values.
|
||||
"""
|
||||
defaults = {
|
||||
# SMTP settings
|
||||
'smtp_server': 'localhost',
|
||||
'smtp_port': 587,
|
||||
'smtp_username': '',
|
||||
'smtp_password': '',
|
||||
'smtp_from_email': 'noreply@sneakyscanner.local',
|
||||
'smtp_to_emails': [],
|
||||
|
||||
# Authentication
|
||||
'app_password': '', # Will need to be set by user
|
||||
|
||||
# Retention policy
|
||||
'retention_days': 0, # 0 = keep forever
|
||||
|
||||
# Alert settings
|
||||
'cert_expiry_threshold': 30, # Days before expiry to alert
|
||||
'email_alerts_enabled': False,
|
||||
}
|
||||
|
||||
for key, value in defaults.items():
|
||||
# Only set if doesn't exist
|
||||
if self.db.query(Setting).filter_by(key=key).first() is None:
|
||||
self.set(key, value)
|
||||
|
||||
|
||||
class PasswordManager:
|
||||
"""
|
||||
Manages password hashing and verification using bcrypt.
|
||||
|
||||
Used for the single-user authentication system.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password: str) -> str:
|
||||
"""
|
||||
Hash a password using bcrypt.
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
Bcrypt hash string
|
||||
"""
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
@staticmethod
|
||||
def verify_password(password: str, hashed: str) -> bool:
|
||||
"""
|
||||
Verify a password against a bcrypt hash.
|
||||
|
||||
Args:
|
||||
password: Plain text password to verify
|
||||
hashed: Bcrypt hash to check against
|
||||
|
||||
Returns:
|
||||
True if password matches, False otherwise
|
||||
"""
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode(), hashed.encode())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def set_app_password(settings_manager: SettingsManager, password: str) -> None:
|
||||
"""
|
||||
Set the application password (stored as bcrypt hash).
|
||||
|
||||
Args:
|
||||
settings_manager: SettingsManager instance
|
||||
password: New password to set
|
||||
"""
|
||||
hashed = PasswordManager.hash_password(password)
|
||||
# Password hash stored as regular setting (not encrypted, as it's already a hash)
|
||||
settings_manager.set('app_password', hashed, encrypt=False)
|
||||
|
||||
@staticmethod
|
||||
def verify_app_password(settings_manager: SettingsManager, password: str) -> bool:
|
||||
"""
|
||||
Verify the application password.
|
||||
|
||||
Args:
|
||||
settings_manager: SettingsManager instance
|
||||
password: Password to verify
|
||||
|
||||
Returns:
|
||||
True if password matches, False otherwise
|
||||
"""
|
||||
stored_hash = settings_manager.get('app_password', decrypt=False)
|
||||
if not stored_hash:
|
||||
# No password set - should prompt user to create one
|
||||
return False
|
||||
return PasswordManager.verify_password(password, stored_hash)
|
||||
290
app/web/utils/validators.py
Normal file
290
app/web/utils/validators.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
Input validation utilities for SneakyScanner web application.
|
||||
|
||||
Provides validation functions for API inputs, file paths, and data integrity.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def validate_config_file(file_path: str) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate that a configuration file exists and is valid YAML.
|
||||
|
||||
Args:
|
||||
file_path: Path to configuration file (absolute or relative filename)
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
If valid, returns (True, None)
|
||||
If invalid, returns (False, error_message)
|
||||
|
||||
Examples:
|
||||
>>> validate_config_file('/app/configs/example.yaml')
|
||||
(True, None)
|
||||
>>> validate_config_file('example.yaml')
|
||||
(True, None)
|
||||
>>> validate_config_file('/nonexistent.yaml')
|
||||
(False, 'File does not exist: /nonexistent.yaml')
|
||||
"""
|
||||
# Check if path is provided
|
||||
if not file_path:
|
||||
return False, 'Config file path is required'
|
||||
|
||||
# If file_path is just a filename (not absolute), prepend configs directory
|
||||
if not file_path.startswith('/'):
|
||||
file_path = f'/app/configs/{file_path}'
|
||||
|
||||
# Convert to Path object
|
||||
path = Path(file_path)
|
||||
|
||||
# Check if file exists
|
||||
if not path.exists():
|
||||
return False, f'File does not exist: {file_path}'
|
||||
|
||||
# Check if it's a file (not directory)
|
||||
if not path.is_file():
|
||||
return False, f'Path is not a file: {file_path}'
|
||||
|
||||
# Check file extension
|
||||
if path.suffix.lower() not in ['.yaml', '.yml']:
|
||||
return False, f'File must be YAML (.yaml or .yml): {file_path}'
|
||||
|
||||
# Try to parse as YAML
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
# Check if it's a dictionary (basic structure validation)
|
||||
if not isinstance(config, dict):
|
||||
return False, 'Config file must contain a YAML dictionary'
|
||||
|
||||
# Check for required top-level keys
|
||||
if 'title' not in config:
|
||||
return False, 'Config file missing required "title" field'
|
||||
|
||||
if 'sites' not in config:
|
||||
return False, 'Config file missing required "sites" field'
|
||||
|
||||
# Validate sites structure
|
||||
if not isinstance(config['sites'], list):
|
||||
return False, '"sites" must be a list'
|
||||
|
||||
if len(config['sites']) == 0:
|
||||
return False, '"sites" list cannot be empty'
|
||||
|
||||
except yaml.YAMLError as e:
|
||||
return False, f'Invalid YAML syntax: {str(e)}'
|
||||
except Exception as e:
|
||||
return False, f'Error reading config file: {str(e)}'
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_scan_status(status: str) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate scan status value.
|
||||
|
||||
Args:
|
||||
status: Status string to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
|
||||
Examples:
|
||||
>>> validate_scan_status('running')
|
||||
(True, None)
|
||||
>>> validate_scan_status('invalid')
|
||||
(False, 'Invalid status: invalid. Must be one of: running, completed, failed')
|
||||
"""
|
||||
valid_statuses = ['running', 'completed', 'failed']
|
||||
|
||||
if status not in valid_statuses:
|
||||
return False, f'Invalid status: {status}. Must be one of: {", ".join(valid_statuses)}'
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_triggered_by(triggered_by: str) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate triggered_by value.
|
||||
|
||||
Args:
|
||||
triggered_by: Source that triggered the scan
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
|
||||
Examples:
|
||||
>>> validate_triggered_by('manual')
|
||||
(True, None)
|
||||
>>> validate_triggered_by('api')
|
||||
(True, None)
|
||||
"""
|
||||
valid_sources = ['manual', 'scheduled', 'api']
|
||||
|
||||
if triggered_by not in valid_sources:
|
||||
return False, f'Invalid triggered_by: {triggered_by}. Must be one of: {", ".join(valid_sources)}'
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_scan_id(scan_id: any) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate scan ID is a positive integer.
|
||||
|
||||
Args:
|
||||
scan_id: Scan ID to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
|
||||
Examples:
|
||||
>>> validate_scan_id(42)
|
||||
(True, None)
|
||||
>>> validate_scan_id('42')
|
||||
(True, None)
|
||||
>>> validate_scan_id(-1)
|
||||
(False, 'Scan ID must be a positive integer')
|
||||
"""
|
||||
try:
|
||||
scan_id_int = int(scan_id)
|
||||
if scan_id_int <= 0:
|
||||
return False, 'Scan ID must be a positive integer'
|
||||
except (ValueError, TypeError):
|
||||
return False, f'Invalid scan ID: {scan_id}'
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_file_path(file_path: str, must_exist: bool = True) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate a file path.
|
||||
|
||||
Args:
|
||||
file_path: Path to validate
|
||||
must_exist: If True, file must exist. If False, only validate format.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
|
||||
Examples:
|
||||
>>> validate_file_path('/app/output/scan.json', must_exist=False)
|
||||
(True, None)
|
||||
>>> validate_file_path('', must_exist=False)
|
||||
(False, 'File path is required')
|
||||
"""
|
||||
if not file_path:
|
||||
return False, 'File path is required'
|
||||
|
||||
# Check for path traversal attempts
|
||||
if '..' in file_path:
|
||||
return False, 'Path traversal not allowed'
|
||||
|
||||
if must_exist:
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
return False, f'File does not exist: {file_path}'
|
||||
if not path.is_file():
|
||||
return False, f'Path is not a file: {file_path}'
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""
|
||||
Sanitize a filename by removing/replacing unsafe characters.
|
||||
|
||||
Args:
|
||||
filename: Original filename
|
||||
|
||||
Returns:
|
||||
Sanitized filename safe for filesystem
|
||||
|
||||
Examples:
|
||||
>>> sanitize_filename('my scan.json')
|
||||
'my_scan.json'
|
||||
>>> sanitize_filename('../../etc/passwd')
|
||||
'etc_passwd'
|
||||
"""
|
||||
# Remove path components
|
||||
filename = os.path.basename(filename)
|
||||
|
||||
# Replace unsafe characters with underscore
|
||||
unsafe_chars = ['/', '\\', '..', ' ', ':', '*', '?', '"', '<', '>', '|']
|
||||
for char in unsafe_chars:
|
||||
filename = filename.replace(char, '_')
|
||||
|
||||
# Remove leading/trailing underscores and dots
|
||||
filename = filename.strip('_.')
|
||||
|
||||
# Ensure filename is not empty
|
||||
if not filename:
|
||||
filename = 'unnamed'
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
def validate_port(port: any) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate port number.
|
||||
|
||||
Args:
|
||||
port: Port number to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
|
||||
Examples:
|
||||
>>> validate_port(443)
|
||||
(True, None)
|
||||
>>> validate_port(70000)
|
||||
(False, 'Port must be between 1 and 65535')
|
||||
"""
|
||||
try:
|
||||
port_int = int(port)
|
||||
if port_int < 1 or port_int > 65535:
|
||||
return False, 'Port must be between 1 and 65535'
|
||||
except (ValueError, TypeError):
|
||||
return False, f'Invalid port: {port}'
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_ip_address(ip: str) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate IPv4 address format (basic validation).
|
||||
|
||||
Args:
|
||||
ip: IP address string
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
|
||||
Examples:
|
||||
>>> validate_ip_address('192.168.1.1')
|
||||
(True, None)
|
||||
>>> validate_ip_address('256.1.1.1')
|
||||
(False, 'Invalid IP address format')
|
||||
"""
|
||||
if not ip:
|
||||
return False, 'IP address is required'
|
||||
|
||||
# Basic IPv4 validation
|
||||
parts = ip.split('.')
|
||||
if len(parts) != 4:
|
||||
return False, 'Invalid IP address format'
|
||||
|
||||
try:
|
||||
for part in parts:
|
||||
num = int(part)
|
||||
if num < 0 or num > 255:
|
||||
return False, 'Invalid IP address format'
|
||||
except (ValueError, TypeError):
|
||||
return False, 'Invalid IP address format'
|
||||
|
||||
return True, None
|
||||
Reference in New Issue
Block a user