Migrate from file-based configs to database with per-IP site configuration

Major architectural changes:
   - Replace YAML config files with database-stored ScanConfig model
   - Remove CIDR block support in favor of individual IP addresses per site
   - Each IP now has its own expected_ping, expected_tcp_ports, expected_udp_ports
   - AlertRule now uses config_id FK instead of config_file string

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

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

   Migrations:
   - 008: Expand CIDRs to individual IPs with per-IP port configs
   - 009: Remove CIDR-related columns
   - 010: Add config_id to alert_rules, remove config_file
This commit is contained in:
2025-11-19 19:40:34 -06:00
parent 034f146fa1
commit 0ec338e252
21 changed files with 2004 additions and 686 deletions

View File

@@ -169,7 +169,8 @@ def list_alert_rules():
'webhook_enabled': rule.webhook_enabled,
'severity': rule.severity,
'filter_conditions': json.loads(rule.filter_conditions) if rule.filter_conditions else None,
'config_file': rule.config_file,
'config_id': rule.config_id,
'config_title': rule.config.title if rule.config else None,
'created_at': rule.created_at.isoformat(),
'updated_at': rule.updated_at.isoformat() if rule.updated_at else None
})
@@ -195,7 +196,7 @@ def create_alert_rule():
webhook_enabled: Send webhook for this rule (default: false)
severity: Alert severity (critical, warning, info)
filter_conditions: JSON object with filter conditions
config_file: Optional config file to apply rule to
config_id: Optional config ID to apply rule to
Returns:
JSON response with created rule
@@ -226,6 +227,17 @@ def create_alert_rule():
}), 400
try:
# Validate config_id if provided
config_id = data.get('config_id')
if config_id:
from web.models import ScanConfig
config = current_app.db_session.query(ScanConfig).filter_by(id=config_id).first()
if not config:
return jsonify({
'status': 'error',
'message': f'Config with ID {config_id} not found'
}), 400
# Create new rule
rule = AlertRule(
name=data.get('name', f"{data['rule_type']} rule"),
@@ -236,7 +248,7 @@ def create_alert_rule():
webhook_enabled=data.get('webhook_enabled', False),
severity=data.get('severity', 'warning'),
filter_conditions=json.dumps(data['filter_conditions']) if data.get('filter_conditions') else None,
config_file=data.get('config_file'),
config_id=config_id,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
@@ -257,7 +269,8 @@ def create_alert_rule():
'webhook_enabled': rule.webhook_enabled,
'severity': rule.severity,
'filter_conditions': json.loads(rule.filter_conditions) if rule.filter_conditions else None,
'config_file': rule.config_file,
'config_id': rule.config_id,
'config_title': rule.config.title if rule.config else None,
'created_at': rule.created_at.isoformat(),
'updated_at': rule.updated_at.isoformat()
}
@@ -288,7 +301,7 @@ def update_alert_rule(rule_id):
webhook_enabled: Send webhook for this rule (optional)
severity: Alert severity (optional)
filter_conditions: JSON object with filter conditions (optional)
config_file: Config file to apply rule to (optional)
config_id: Config ID to apply rule to (optional)
Returns:
JSON response with update status
@@ -312,6 +325,18 @@ def update_alert_rule(rule_id):
}), 400
try:
# Validate config_id if provided
if 'config_id' in data:
config_id = data['config_id']
if config_id:
from web.models import ScanConfig
config = current_app.db_session.query(ScanConfig).filter_by(id=config_id).first()
if not config:
return jsonify({
'status': 'error',
'message': f'Config with ID {config_id} not found'
}), 400
# Update fields if provided
if 'name' in data:
rule.name = data['name']
@@ -327,8 +352,8 @@ def update_alert_rule(rule_id):
rule.severity = data['severity']
if 'filter_conditions' in data:
rule.filter_conditions = json.dumps(data['filter_conditions']) if data['filter_conditions'] else None
if 'config_file' in data:
rule.config_file = data['config_file']
if 'config_id' in data:
rule.config_id = data['config_id']
rule.updated_at = datetime.now(timezone.utc)
@@ -347,7 +372,8 @@ def update_alert_rule(rule_id):
'webhook_enabled': rule.webhook_enabled,
'severity': rule.severity,
'filter_conditions': json.loads(rule.filter_conditions) if rule.filter_conditions else None,
'config_file': rule.config_file,
'config_id': rule.config_id,
'config_title': rule.config.title if rule.config else None,
'created_at': rule.created_at.isoformat(),
'updated_at': rule.updated_at.isoformat()
}

View File

@@ -174,7 +174,7 @@ def get_config(config_id: int):
"id": 1,
"name": "Production DC",
"description": "...",
"cidr_count": 5
"ip_count": 5
}
],
"created_at": "2025-11-19T10:30:00Z",

View File

@@ -129,7 +129,7 @@ def trigger_scan():
Trigger a new scan.
Request body:
config_file: Path to YAML config file
config_id: Database config ID (required)
Returns:
JSON response with scan_id and status
@@ -137,25 +137,35 @@ def trigger_scan():
try:
# Get request data
data = request.get_json() or {}
config_file = data.get('config_file')
config_id = data.get('config_id')
# Validate required fields
if not config_file:
logger.warning("Scan trigger request missing config_file")
if not config_id:
logger.warning("Scan trigger request missing config_id")
return jsonify({
'error': 'Invalid request',
'message': 'config_file is required'
'message': 'config_id is required'
}), 400
# Validate config_id is an integer
try:
config_id = int(config_id)
except (TypeError, ValueError):
logger.warning(f"Invalid config_id type: {config_id}")
return jsonify({
'error': 'Invalid request',
'message': 'config_id must be an integer'
}), 400
# Trigger scan via service
scan_service = ScanService(current_app.db_session)
scan_id = scan_service.trigger_scan(
config_file=config_file,
config_id=config_id,
triggered_by='api',
scheduler=current_app.scheduler
)
logger.info(f"Scan {scan_id} triggered via API: config={config_file}")
logger.info(f"Scan {scan_id} triggered via API: config_id={config_id}")
return jsonify({
'scan_id': scan_id,
@@ -164,10 +174,10 @@ def trigger_scan():
}), 201
except ValueError as e:
# Config file validation error
# Config validation error
error_message = str(e)
logger.warning(f"Invalid config file: {error_message}")
logger.warning(f"Request data: config_file='{config_file}'")
logger.warning(f"Invalid config: {error_message}")
logger.warning(f"Request data: config_id='{config_id}'")
return jsonify({
'error': 'Invalid request',
'message': error_message

View File

@@ -158,21 +158,12 @@ def create_site():
}), 400
description = data.get('description')
cidrs = data.get('cidrs', [])
# Validate cidrs is a list
if not isinstance(cidrs, list):
return jsonify({
'error': 'Invalid request',
'message': 'cidrs must be a list'
}), 400
# Create site
# Create site (empty initially)
site_service = SiteService(current_app.db_session)
site = site_service.create_site(
name=name,
description=description,
cidrs=cidrs if cidrs else None
description=description
)
logger.info(f"Created site '{name}' (id={site['id']})")
@@ -294,135 +285,178 @@ def delete_site(site_id):
}), 500
@bp.route('/<int:site_id>/cidrs', methods=['POST'])
@bp.route('/<int:site_id>/ips/bulk', methods=['POST'])
@api_auth_required
def add_cidr(site_id):
def bulk_add_ips(site_id):
"""
Add a CIDR range to a site.
Bulk add IPs to a site from CIDR or list.
Args:
site_id: Site ID
Request body:
cidr: CIDR notation (required, e.g., "10.0.0.0/24")
expected_ping: Expected ping response (optional)
expected_tcp_ports: List of expected TCP ports (optional)
expected_udp_ports: List of expected UDP ports (optional)
source_type: "cidr" or "list" (required)
cidr: CIDR notation if source_type="cidr" (e.g., "10.0.0.0/24")
ips: List of IP addresses if source_type="list" (e.g., ["10.0.0.1", "10.0.0.2"])
expected_ping: Expected ping response for all IPs (optional)
expected_tcp_ports: List of expected TCP ports for all IPs (optional)
expected_udp_ports: List of expected UDP ports for all IPs (optional)
Returns:
JSON response with created CIDR data
JSON response with count of IPs added and any errors
"""
try:
data = request.get_json() or {}
# Validate required fields
cidr = data.get('cidr')
if not cidr:
logger.warning("CIDR creation request missing cidr")
source_type = data.get('source_type')
if source_type not in ['cidr', 'list']:
return jsonify({
'error': 'Invalid request',
'message': 'cidr is required'
'message': 'source_type must be "cidr" or "list"'
}), 400
expected_ping = data.get('expected_ping')
expected_tcp_ports = data.get('expected_tcp_ports', [])
expected_udp_ports = data.get('expected_udp_ports', [])
# Add CIDR
site_service = SiteService(current_app.db_session)
cidr_data = site_service.add_cidr(
site_id=site_id,
cidr=cidr,
expected_ping=expected_ping,
expected_tcp_ports=expected_tcp_ports,
expected_udp_ports=expected_udp_ports
)
logger.info(f"Added CIDR '{cidr}' to site {site_id}")
return jsonify(cidr_data), 201
if source_type == 'cidr':
cidr = data.get('cidr')
if not cidr:
return jsonify({
'error': 'Invalid request',
'message': 'cidr is required when source_type="cidr"'
}), 400
result = site_service.bulk_add_ips_from_cidr(
site_id=site_id,
cidr=cidr,
expected_ping=expected_ping,
expected_tcp_ports=expected_tcp_ports,
expected_udp_ports=expected_udp_ports
)
logger.info(f"Bulk added {result['ip_count']} IPs from CIDR '{cidr}' to site {site_id}")
return jsonify(result), 201
else: # source_type == 'list'
ip_list = data.get('ips', [])
if not isinstance(ip_list, list):
return jsonify({
'error': 'Invalid request',
'message': 'ips must be a list when source_type="list"'
}), 400
result = site_service.bulk_add_ips_from_list(
site_id=site_id,
ip_list=ip_list,
expected_ping=expected_ping,
expected_tcp_ports=expected_tcp_ports,
expected_udp_ports=expected_udp_ports
)
logger.info(f"Bulk added {result['ip_count']} IPs from list to site {site_id}")
return jsonify(result), 201
except ValueError as e:
logger.warning(f"Invalid CIDR creation request: {str(e)}")
logger.warning(f"Invalid bulk IP request: {str(e)}")
return jsonify({
'error': 'Invalid request',
'message': str(e)
}), 400
except SQLAlchemyError as e:
logger.error(f"Database error adding CIDR to site {site_id}: {str(e)}")
logger.error(f"Database error bulk adding IPs to site {site_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to add CIDR'
'message': 'Failed to add IPs'
}), 500
except Exception as e:
logger.error(f"Unexpected error adding CIDR to site {site_id}: {str(e)}", exc_info=True)
logger.error(f"Unexpected error bulk adding IPs to site {site_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:site_id>/cidrs/<int:cidr_id>', methods=['DELETE'])
@bp.route('/<int:site_id>/ips', methods=['GET'])
@api_auth_required
def remove_cidr(site_id, cidr_id):
def list_ips(site_id):
"""
Remove a CIDR range from a site.
List IPs in a site with pagination.
Prevents removal if it's the last CIDR.
Args:
site_id: Site ID
cidr_id: CIDR ID
Query params:
page: Page number (default: 1)
per_page: Items per page (default: 50, max: 200)
Returns:
JSON response with success message
JSON response with IPs list and pagination info
"""
try:
site_service = SiteService(current_app.db_session)
site_service.remove_cidr(site_id, cidr_id)
# Get and validate query parameters
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
# Validate pagination params
page, per_page = validate_page_params(page, per_page, max_per_page=200)
# Get IPs from service
site_service = SiteService(current_app.db_session)
paginated_result = site_service.list_ips(
site_id=site_id,
page=page,
per_page=per_page
)
logger.info(f"Listed IPs for site {site_id}: page={page}, per_page={per_page}, total={paginated_result.total}")
logger.info(f"Removed CIDR {cidr_id} from site {site_id}")
return jsonify({
'message': f'CIDR {cidr_id} removed successfully'
'ips': 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"Cannot remove CIDR {cidr_id} from site {site_id}: {str(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 removing CIDR {cidr_id} from site {site_id}: {str(e)}")
logger.error(f"Database error listing IPs for site {site_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to remove CIDR'
'message': 'Failed to retrieve IPs'
}), 500
except Exception as e:
logger.error(f"Unexpected error removing CIDR {cidr_id} from site {site_id}: {str(e)}", exc_info=True)
logger.error(f"Unexpected error listing IPs for site {site_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:site_id>/cidrs/<int:cidr_id>/ips', methods=['POST'])
@bp.route('/<int:site_id>/ips', methods=['POST'])
@api_auth_required
def add_ip_override(site_id, cidr_id):
def add_standalone_ip(site_id):
"""
Add an IP-level expectation override within a CIDR.
Add a standalone IP (without CIDR parent) to a site.
Args:
site_id: Site ID (for validation)
cidr_id: CIDR ID
site_id: Site ID
Request body:
ip_address: IP address (required)
expected_ping: Override ping expectation (optional)
expected_tcp_ports: Override TCP ports expectation (optional)
expected_udp_ports: Override UDP ports expectation (optional)
expected_ping: Expected ping response (optional)
expected_tcp_ports: List of expected TCP ports (optional)
expected_udp_ports: List of expected UDP ports (optional)
Returns:
JSON response with created IP override data
JSON response with created IP data
"""
try:
data = request.get_json() or {}
@@ -430,7 +464,7 @@ def add_ip_override(site_id, cidr_id):
# Validate required fields
ip_address = data.get('ip_address')
if not ip_address:
logger.warning("IP override creation request missing ip_address")
logger.warning("Standalone IP creation request missing ip_address")
return jsonify({
'error': 'Invalid request',
'message': 'ip_address is required'
@@ -440,76 +474,133 @@ def add_ip_override(site_id, cidr_id):
expected_tcp_ports = data.get('expected_tcp_ports', [])
expected_udp_ports = data.get('expected_udp_ports', [])
# Add IP override
# Add standalone IP
site_service = SiteService(current_app.db_session)
ip_data = site_service.add_ip_override(
cidr_id=cidr_id,
ip_data = site_service.add_standalone_ip(
site_id=site_id,
ip_address=ip_address,
expected_ping=expected_ping,
expected_tcp_ports=expected_tcp_ports,
expected_udp_ports=expected_udp_ports
)
logger.info(f"Added IP override '{ip_address}' to CIDR {cidr_id} in site {site_id}")
logger.info(f"Added standalone IP '{ip_address}' to site {site_id}")
return jsonify(ip_data), 201
except ValueError as e:
logger.warning(f"Invalid IP override creation request: {str(e)}")
logger.warning(f"Invalid standalone IP creation request: {str(e)}")
return jsonify({
'error': 'Invalid request',
'message': str(e)
}), 400
except SQLAlchemyError as e:
logger.error(f"Database error adding IP override to CIDR {cidr_id}: {str(e)}")
logger.error(f"Database error adding standalone IP to site {site_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to add IP override'
'message': 'Failed to add IP'
}), 500
except Exception as e:
logger.error(f"Unexpected error adding IP override to CIDR {cidr_id}: {str(e)}", exc_info=True)
logger.error(f"Unexpected error adding standalone IP to site {site_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:site_id>/cidrs/<int:cidr_id>/ips/<int:ip_id>', methods=['DELETE'])
@bp.route('/<int:site_id>/ips/<int:ip_id>', methods=['PUT'])
@api_auth_required
def remove_ip_override(site_id, cidr_id, ip_id):
def update_ip_settings(site_id, ip_id):
"""
Remove an IP-level override.
Update settings for an individual IP.
Args:
site_id: Site ID (for validation)
cidr_id: CIDR ID
ip_id: IP override ID
site_id: Site ID
ip_id: IP ID
Request body:
expected_ping: New ping expectation (optional)
expected_tcp_ports: New TCP ports expectation (optional)
expected_udp_ports: New UDP ports expectation (optional)
Returns:
JSON response with updated IP data
"""
try:
data = request.get_json() or {}
expected_ping = data.get('expected_ping')
expected_tcp_ports = data.get('expected_tcp_ports')
expected_udp_ports = data.get('expected_udp_ports')
# Update IP settings
site_service = SiteService(current_app.db_session)
ip_data = site_service.update_ip_settings(
site_id=site_id,
ip_id=ip_id,
expected_ping=expected_ping,
expected_tcp_ports=expected_tcp_ports,
expected_udp_ports=expected_udp_ports
)
logger.info(f"Updated IP {ip_id} in site {site_id}")
return jsonify(ip_data)
except ValueError as e:
logger.warning(f"Invalid IP update request: {str(e)}")
return jsonify({
'error': 'Invalid request',
'message': str(e)
}), 400
except SQLAlchemyError as e:
logger.error(f"Database error updating IP {ip_id} in site {site_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to update IP'
}), 500
except Exception as e:
logger.error(f"Unexpected error updating IP {ip_id} in site {site_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:site_id>/ips/<int:ip_id>', methods=['DELETE'])
@api_auth_required
def remove_ip(site_id, ip_id):
"""
Remove an IP from a site.
Args:
site_id: Site ID
ip_id: IP ID
Returns:
JSON response with success message
"""
try:
site_service = SiteService(current_app.db_session)
site_service.remove_ip_override(cidr_id, ip_id)
site_service.remove_ip(site_id, ip_id)
logger.info(f"Removed IP override {ip_id} from CIDR {cidr_id} in site {site_id}")
logger.info(f"Removed IP {ip_id} from site {site_id}")
return jsonify({
'message': f'IP override {ip_id} removed successfully'
'message': f'IP {ip_id} removed successfully'
})
except ValueError as e:
logger.warning(f"Cannot remove IP override {ip_id}: {str(e)}")
logger.warning(f"Cannot remove IP {ip_id}: {str(e)}")
return jsonify({
'error': 'Invalid request',
'message': str(e)
}), 400
except SQLAlchemyError as e:
logger.error(f"Database error removing IP override {ip_id}: {str(e)}")
logger.error(f"Database error removing IP {ip_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to remove IP override'
'message': 'Failed to remove IP'
}), 500
except Exception as e:
logger.error(f"Unexpected error removing IP override {ip_id}: {str(e)}", exc_info=True)
logger.error(f"Unexpected error removing IP {ip_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'

View File

@@ -7,7 +7,7 @@ that are managed by developers, not stored in the database.
# Application metadata
APP_NAME = 'SneakyScanner'
APP_VERSION = '1.0.0-phase5'
APP_VERSION = '1.0.0-alpha'
# Repository URL
REPO_URL = 'https://git.sneakygeek.net/sneakygeek/SneakyScan'

View File

@@ -267,7 +267,7 @@ class Site(Base):
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow, comment="Last modification time")
# Relationships
cidrs = relationship('SiteCIDR', back_populates='site', cascade='all, delete-orphan')
ips = relationship('SiteIP', back_populates='site', cascade='all, delete-orphan')
scan_associations = relationship('ScanSiteAssociation', back_populates='site')
config_associations = relationship('ScanConfigSite', back_populates='site')
@@ -275,59 +275,29 @@ class Site(Base):
return f"<Site(id={self.id}, name='{self.name}')>"
class SiteCIDR(Base):
"""
CIDR ranges associated with a site.
Each site must have at least one CIDR range. CIDR-level expectations
(ping, ports) apply to all IPs in the range unless overridden at the IP level.
"""
__tablename__ = 'site_cidrs'
id = Column(Integer, primary_key=True, autoincrement=True)
site_id = Column(Integer, ForeignKey('sites.id'), nullable=False, index=True)
cidr = Column(String(45), nullable=False, comment="CIDR notation (e.g., 10.0.0.0/24)")
expected_ping = Column(Boolean, nullable=True, default=False, comment="Expected ping response for this CIDR")
expected_tcp_ports = Column(Text, nullable=True, comment="JSON array of expected TCP ports")
expected_udp_ports = Column(Text, nullable=True, comment="JSON array of expected UDP ports")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="CIDR creation time")
# Relationships
site = relationship('Site', back_populates='cidrs')
ips = relationship('SiteIP', back_populates='cidr', cascade='all, delete-orphan')
# Index for efficient CIDR lookups within a site
__table_args__ = (
UniqueConstraint('site_id', 'cidr', name='uix_site_cidr'),
)
def __repr__(self):
return f"<SiteCIDR(id={self.id}, cidr='{self.cidr}')>"
class SiteIP(Base):
"""
IP-level expectation overrides within a CIDR range.
Individual IP addresses with their own settings.
Allows fine-grained control where specific IPs within a CIDR have
different expectations than the CIDR-level defaults.
Each IP is directly associated with a site and has its own port and ping settings.
IPs are standalone entities - CIDRs are only used as a convenience for bulk creation.
"""
__tablename__ = 'site_ips'
id = Column(Integer, primary_key=True, autoincrement=True)
site_cidr_id = Column(Integer, ForeignKey('site_cidrs.id'), nullable=False, index=True)
site_id = Column(Integer, ForeignKey('sites.id'), nullable=False, index=True, comment="FK to sites")
ip_address = Column(String(45), nullable=False, comment="IPv4 or IPv6 address")
expected_ping = Column(Boolean, nullable=True, comment="Override ping expectation for this IP")
expected_tcp_ports = Column(Text, nullable=True, comment="JSON array of expected TCP ports (overrides CIDR)")
expected_udp_ports = Column(Text, nullable=True, comment="JSON array of expected UDP ports (overrides CIDR)")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="IP override creation time")
expected_ping = Column(Boolean, nullable=True, comment="Expected ping response for this IP")
expected_tcp_ports = Column(Text, nullable=True, comment="JSON array of expected TCP ports")
expected_udp_ports = Column(Text, nullable=True, comment="JSON array of expected UDP ports")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="IP creation time")
# Relationships
cidr = relationship('SiteCIDR', back_populates='ips')
site = relationship('Site', back_populates='ips')
# Index for efficient IP lookups
# Index for efficient IP lookups - prevent duplicate IPs within a site
__table_args__ = (
UniqueConstraint('site_cidr_id', 'ip_address', name='uix_site_cidr_ip'),
UniqueConstraint('site_id', 'ip_address', name='uix_site_ip_address'),
)
def __repr__(self):
@@ -507,12 +477,13 @@ class AlertRule(Base):
webhook_enabled = Column(Boolean, nullable=False, default=False, comment="Send webhook for this rule?")
severity = Column(String(20), nullable=True, comment="Alert severity: critical, warning, info")
filter_conditions = Column(Text, nullable=True, comment="JSON filter conditions for the rule")
config_file = Column(String(255), nullable=True, comment="Optional: specific config file this rule applies to")
config_id = Column(Integer, ForeignKey('scan_configs.id'), nullable=True, index=True, comment="Optional: specific config this rule applies to")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Rule creation time")
updated_at = Column(DateTime, nullable=True, comment="Last update time")
# Relationships
alerts = relationship("Alert", back_populates="rule", cascade="all, delete-orphan")
config = relationship("ScanConfig", backref="alert_rules")
def __repr__(self):
return f"<AlertRule(id={self.id}, name='{self.name}', rule_type='{self.rule_type}', enabled={self.enabled})>"

View File

@@ -35,20 +35,7 @@ def dashboard():
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)
return render_template('dashboard.html')
@bp.route('/scans')
@@ -60,20 +47,7 @@ def scans():
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)
return render_template('scans.html')
@bp.route('/scans/<int:scan_id>')
@@ -299,7 +273,6 @@ def alert_rules():
Returns:
Rendered alert rules template
"""
import os
from flask import current_app
from web.models import AlertRule
@@ -317,19 +290,7 @@ def alert_rules():
if rules is None:
rules = []
# 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(
'alert_rules.html',
rules=rules,
config_files=config_files
rules=rules
)

View File

@@ -129,7 +129,7 @@ class ConfigService:
'id': site.id,
'name': site.name,
'description': site.description,
'cidr_count': len(site.cidrs)
'ip_count': len(site.ips)
})
return {

View File

@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session, joinedload
from sqlalchemy.exc import IntegrityError
from web.models import (
Site, SiteCIDR, SiteIP, ScanSiteAssociation
Site, SiteIP, ScanSiteAssociation
)
from web.utils.pagination import paginate, PaginatedResult
@@ -40,34 +40,26 @@ class SiteService:
"""
self.db = db_session
def create_site(self, name: str, description: Optional[str] = None,
cidrs: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
def create_site(self, name: str, description: Optional[str] = None) -> Dict[str, Any]:
"""
Create a new site with optional CIDR ranges.
Create a new site.
Args:
name: Unique site name
description: Optional site description
cidrs: List of CIDR definitions with format:
[{"cidr": "10.0.0.0/24", "expected_ping": true,
"expected_tcp_ports": [22, 80], "expected_udp_ports": [53]}]
Returns:
Dictionary with created site data
Raises:
ValueError: If site name already exists or validation fails
ValueError: If site name already exists
"""
# Validate site name is unique
existing = self.db.query(Site).filter(Site.name == name).first()
if existing:
raise ValueError(f"Site with name '{name}' already exists")
# Validate we have at least one CIDR if provided
if cidrs is not None and len(cidrs) == 0:
raise ValueError("Site must have at least one CIDR range")
# Create site
# Create site (can be empty, IPs added separately)
site = Site(
name=name,
description=description,
@@ -76,17 +68,10 @@ class SiteService:
)
self.db.add(site)
self.db.flush() # Get site.id without committing
# Add CIDRs if provided
if cidrs:
for cidr_data in cidrs:
self._add_cidr_to_site(site, cidr_data)
self.db.commit()
self.db.refresh(site)
logger.info(f"Created site '{name}' (id={site.id}) with {len(cidrs or [])} CIDR(s)")
logger.info(f"Created site '{name}' (id={site.id})")
return self._site_to_dict(site)
@@ -171,7 +156,7 @@ class SiteService:
def get_site(self, site_id: int) -> Optional[Dict[str, Any]]:
"""
Get site details with all CIDRs and IP overrides.
Get site details.
Args:
site_id: Site ID to retrieve
@@ -181,9 +166,6 @@ class SiteService:
"""
site = (
self.db.query(Site)
.options(
joinedload(Site.cidrs).joinedload(SiteCIDR.ips)
)
.filter(Site.id == site_id)
.first()
)
@@ -205,9 +187,6 @@ class SiteService:
"""
site = (
self.db.query(Site)
.options(
joinedload(Site.cidrs).joinedload(SiteCIDR.ips)
)
.filter(Site.name == name)
.first()
)
@@ -230,7 +209,6 @@ class SiteService:
"""
query = (
self.db.query(Site)
.options(joinedload(Site.cidrs))
.order_by(Site.name)
)
@@ -245,160 +223,211 @@ class SiteService:
"""
sites = (
self.db.query(Site)
.options(joinedload(Site.cidrs))
.order_by(Site.name)
.all()
)
return [self._site_to_dict(site) for site in sites]
def add_cidr(self, site_id: int, cidr: str, expected_ping: Optional[bool] = None,
expected_tcp_ports: Optional[List[int]] = None,
expected_udp_ports: Optional[List[int]] = None) -> Dict[str, Any]:
def bulk_add_ips_from_cidr(self, site_id: int, cidr: str,
expected_ping: Optional[bool] = None,
expected_tcp_ports: Optional[List[int]] = None,
expected_udp_ports: Optional[List[int]] = None) -> Dict[str, Any]:
"""
Add a CIDR range to a site.
Expand a CIDR range and add all IPs to a site.
CIDRs are NOT stored - they are just used to generate IP records.
Args:
site_id: Site ID
cidr: CIDR notation (e.g., "10.0.0.0/24")
expected_ping: Expected ping response for IPs in this CIDR
expected_ping: Expected ping response for all IPs
expected_tcp_ports: List of expected TCP ports for all IPs
expected_udp_ports: List of expected UDP ports for all IPs
Returns:
Dictionary with:
- cidr: The CIDR that was expanded
- ip_count: Number of IPs created
- ips_added: List of IP addresses created
- ips_skipped: List of IPs that already existed
Raises:
ValueError: If site not found or CIDR is invalid/too large
"""
site = self.db.query(Site).filter(Site.id == site_id).first()
if not site:
raise ValueError(f"Site with id {site_id} not found")
# Validate CIDR format and size
try:
network = ipaddress.ip_network(cidr, strict=False)
except ValueError as e:
raise ValueError(f"Invalid CIDR notation '{cidr}': {str(e)}")
# Enforce CIDR size limits (max /24 for IPv4, /64 for IPv6)
if isinstance(network, ipaddress.IPv4Network) and network.prefixlen < 24:
raise ValueError(
f"CIDR '{cidr}' is too large ({network.num_addresses} IPs). "
f"Maximum allowed is /24 (256 IPs) for IPv4."
)
elif isinstance(network, ipaddress.IPv6Network) and network.prefixlen < 64:
raise ValueError(
f"CIDR '{cidr}' is too large. "
f"Maximum allowed is /64 for IPv6."
)
# Expand CIDR to individual IPs (no cidr_id since we're not storing CIDR)
ip_count, ips_added, ips_skipped = self._expand_cidr_to_ips(
site_id=site_id,
network=network,
expected_ping=expected_ping,
expected_tcp_ports=expected_tcp_ports or [],
expected_udp_ports=expected_udp_ports or []
)
site.updated_at = datetime.utcnow()
self.db.commit()
logger.info(
f"Expanded CIDR '{cidr}' for site {site_id} ('{site.name}'): "
f"added {ip_count} IPs, skipped {len(ips_skipped)} duplicates"
)
return {
'cidr': cidr,
'ip_count': ip_count,
'ips_added': ips_added,
'ips_skipped': ips_skipped
}
def bulk_add_ips_from_list(self, site_id: int, ip_list: List[str],
expected_ping: Optional[bool] = None,
expected_tcp_ports: Optional[List[int]] = None,
expected_udp_ports: Optional[List[int]] = None) -> Dict[str, Any]:
"""
Add multiple IPs from a list (e.g., from CSV/text import).
Args:
site_id: Site ID
ip_list: List of IP addresses as strings
expected_ping: Expected ping response for all IPs
expected_tcp_ports: List of expected TCP ports for all IPs
expected_udp_ports: List of expected UDP ports for all IPs
Returns:
Dictionary with:
- ip_count: Number of IPs successfully created
- ips_added: List of IP addresses created
- ips_skipped: List of IPs that already existed
- errors: List of validation errors {ip: error_message}
Raises:
ValueError: If site not found
"""
site = self.db.query(Site).filter(Site.id == site_id).first()
if not site:
raise ValueError(f"Site with id {site_id} not found")
ips_added = []
ips_skipped = []
errors = []
for ip_str in ip_list:
ip_str = ip_str.strip()
if not ip_str:
continue # Skip empty lines
# Validate IP format
try:
ipaddress.ip_address(ip_str)
except ValueError as e:
errors.append({'ip': ip_str, 'error': f"Invalid IP address: {str(e)}"})
continue
# Check for duplicate (across all IPs in the site)
existing = (
self.db.query(SiteIP)
.filter(SiteIP.site_id == site_id, SiteIP.ip_address == ip_str)
.first()
)
if existing:
ips_skipped.append(ip_str)
continue
# Create IP record
try:
ip_obj = SiteIP(
site_id=site_id,
ip_address=ip_str,
expected_ping=expected_ping,
expected_tcp_ports=json.dumps(expected_tcp_ports or []),
expected_udp_ports=json.dumps(expected_udp_ports or []),
created_at=datetime.utcnow()
)
self.db.add(ip_obj)
ips_added.append(ip_str)
except Exception as e:
errors.append({'ip': ip_str, 'error': f"Database error: {str(e)}"})
site.updated_at = datetime.utcnow()
self.db.commit()
logger.info(
f"Bulk added {len(ips_added)} IPs to site {site_id} ('{site.name}'), "
f"skipped {len(ips_skipped)} duplicates, {len(errors)} errors"
)
return {
'ip_count': len(ips_added),
'ips_added': ips_added,
'ips_skipped': ips_skipped,
'errors': errors
}
def add_standalone_ip(self, site_id: int, ip_address: str,
expected_ping: Optional[bool] = None,
expected_tcp_ports: Optional[List[int]] = None,
expected_udp_ports: Optional[List[int]] = None) -> Dict[str, Any]:
"""
Add a standalone IP (without a CIDR parent) to a site.
Args:
site_id: Site ID
ip_address: IP address to add
expected_ping: Expected ping response
expected_tcp_ports: List of expected TCP ports
expected_udp_ports: List of expected UDP ports
Returns:
Dictionary with CIDR data
Dictionary with IP data
Raises:
ValueError: If site not found, CIDR is invalid, or already exists
ValueError: If site not found, IP is invalid, or already exists
"""
site = self.db.query(Site).filter(Site.id == site_id).first()
if not site:
raise ValueError(f"Site with id {site_id} not found")
# Validate CIDR format
try:
ipaddress.ip_network(cidr, strict=False)
except ValueError as e:
raise ValueError(f"Invalid CIDR notation '{cidr}': {str(e)}")
# Check for duplicate CIDR
existing = (
self.db.query(SiteCIDR)
.filter(SiteCIDR.site_id == site_id, SiteCIDR.cidr == cidr)
.first()
)
if existing:
raise ValueError(f"CIDR '{cidr}' already exists for this site")
# Create CIDR
cidr_obj = SiteCIDR(
site_id=site_id,
cidr=cidr,
expected_ping=expected_ping,
expected_tcp_ports=json.dumps(expected_tcp_ports or []),
expected_udp_ports=json.dumps(expected_udp_ports or []),
created_at=datetime.utcnow()
)
self.db.add(cidr_obj)
site.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(cidr_obj)
logger.info(f"Added CIDR '{cidr}' to site {site_id} ('{site.name}')")
return self._cidr_to_dict(cidr_obj)
def remove_cidr(self, site_id: int, cidr_id: int) -> None:
"""
Remove a CIDR range from a site.
Prevents removal if it's the last CIDR (sites must have at least one CIDR).
Args:
site_id: Site ID
cidr_id: CIDR ID to remove
Raises:
ValueError: If CIDR not found or it's the last CIDR
"""
site = self.db.query(Site).filter(Site.id == site_id).first()
if not site:
raise ValueError(f"Site with id {site_id} not found")
cidr = (
self.db.query(SiteCIDR)
.filter(SiteCIDR.id == cidr_id, SiteCIDR.site_id == site_id)
.first()
)
if not cidr:
raise ValueError(f"CIDR with id {cidr_id} not found for site {site_id}")
# Check if this is the last CIDR
cidr_count = (
self.db.query(func.count(SiteCIDR.id))
.filter(SiteCIDR.site_id == site_id)
.scalar()
)
if cidr_count <= 1:
raise ValueError(
f"Cannot remove CIDR '{cidr.cidr}': site must have at least one CIDR range"
)
self.db.delete(cidr)
site.updated_at = datetime.utcnow()
self.db.commit()
logger.info(f"Removed CIDR '{cidr.cidr}' from site {site_id} ('{site.name}')")
def add_ip_override(self, cidr_id: int, ip_address: str,
expected_ping: Optional[bool] = None,
expected_tcp_ports: Optional[List[int]] = None,
expected_udp_ports: Optional[List[int]] = None) -> Dict[str, Any]:
"""
Add an IP-level expectation override within a CIDR.
Args:
cidr_id: CIDR ID
ip_address: IP address to override
expected_ping: Override ping expectation
expected_tcp_ports: Override TCP ports expectation
expected_udp_ports: Override UDP ports expectation
Returns:
Dictionary with IP override data
Raises:
ValueError: If CIDR not found, IP is invalid, or not in CIDR range
"""
cidr = self.db.query(SiteCIDR).filter(SiteCIDR.id == cidr_id).first()
if not cidr:
raise ValueError(f"CIDR with id {cidr_id} not found")
# Validate IP format
try:
ip_obj = ipaddress.ip_address(ip_address)
ipaddress.ip_address(ip_address)
except ValueError as e:
raise ValueError(f"Invalid IP address '{ip_address}': {str(e)}")
# Validate IP is within CIDR range
network = ipaddress.ip_network(cidr.cidr, strict=False)
if ip_obj not in network:
raise ValueError(f"IP address '{ip_address}' is not within CIDR '{cidr.cidr}'")
# Check for duplicate
# Check for duplicate (across all IPs in the site)
existing = (
self.db.query(SiteIP)
.filter(SiteIP.site_cidr_id == cidr_id, SiteIP.ip_address == ip_address)
.filter(SiteIP.site_id == site_id, SiteIP.ip_address == ip_address)
.first()
)
if existing:
raise ValueError(f"IP override for '{ip_address}' already exists in this CIDR")
raise ValueError(f"IP '{ip_address}' already exists in this site")
# Create IP override
ip_override = SiteIP(
site_cidr_id=cidr_id,
# Create IP
ip_obj = SiteIP(
site_id=site_id,
ip_address=ip_address,
expected_ping=expected_ping,
expected_tcp_ports=json.dumps(expected_tcp_ports or []),
@@ -406,38 +435,102 @@ class SiteService:
created_at=datetime.utcnow()
)
self.db.add(ip_override)
self.db.add(ip_obj)
site.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(ip_override)
self.db.refresh(ip_obj)
logger.info(f"Added IP override '{ip_address}' to CIDR {cidr_id} ('{cidr.cidr}')")
logger.info(f"Added IP '{ip_address}' to site {site_id} ('{site.name}')")
return self._ip_override_to_dict(ip_override)
return self._ip_to_dict(ip_obj)
def remove_ip_override(self, cidr_id: int, ip_id: int) -> None:
def update_ip_settings(self, site_id: int, ip_id: int,
expected_ping: Optional[bool] = None,
expected_tcp_ports: Optional[List[int]] = None,
expected_udp_ports: Optional[List[int]] = None) -> Dict[str, Any]:
"""
Remove an IP-level override.
Update settings for an individual IP.
Args:
cidr_id: CIDR ID
ip_id: IP override ID to remove
site_id: Site ID
ip_id: IP ID to update
expected_ping: New ping expectation (if provided)
expected_tcp_ports: New TCP ports expectation (if provided)
expected_udp_ports: New UDP ports expectation (if provided)
Returns:
Dictionary with updated IP data
Raises:
ValueError: If IP override not found
ValueError: If IP not found
"""
ip_override = (
ip_obj = (
self.db.query(SiteIP)
.filter(SiteIP.id == ip_id, SiteIP.site_cidr_id == cidr_id)
.filter(SiteIP.id == ip_id, SiteIP.site_id == site_id)
.first()
)
if not ip_override:
raise ValueError(f"IP override with id {ip_id} not found for CIDR {cidr_id}")
if not ip_obj:
raise ValueError(f"IP with id {ip_id} not found for site {site_id}")
ip_address = ip_override.ip_address
self.db.delete(ip_override)
# Update settings if provided
if expected_ping is not None:
ip_obj.expected_ping = expected_ping
if expected_tcp_ports is not None:
ip_obj.expected_tcp_ports = json.dumps(expected_tcp_ports)
if expected_udp_ports is not None:
ip_obj.expected_udp_ports = json.dumps(expected_udp_ports)
self.db.commit()
self.db.refresh(ip_obj)
logger.info(f"Updated settings for IP '{ip_obj.ip_address}' in site {site_id}")
return self._ip_to_dict(ip_obj)
def remove_ip(self, site_id: int, ip_id: int) -> None:
"""
Remove an IP from a site.
Args:
site_id: Site ID
ip_id: IP ID to remove
Raises:
ValueError: If IP not found
"""
ip_obj = (
self.db.query(SiteIP)
.filter(SiteIP.id == ip_id, SiteIP.site_id == site_id)
.first()
)
if not ip_obj:
raise ValueError(f"IP with id {ip_id} not found for site {site_id}")
ip_address = ip_obj.ip_address
self.db.delete(ip_obj)
self.db.commit()
logger.info(f"Removed IP override '{ip_address}' from CIDR {cidr_id}")
logger.info(f"Removed IP '{ip_address}' from site {site_id}")
def list_ips(self, site_id: int, page: int = 1, per_page: int = 50) -> PaginatedResult:
"""
List IPs in a site with pagination.
Args:
site_id: Site ID
page: Page number (1-indexed)
per_page: Number of items per page
Returns:
PaginatedResult with IP data
"""
query = (
self.db.query(SiteIP)
.filter(SiteIP.site_id == site_id)
.order_by(SiteIP.ip_address)
)
return paginate(query, page, per_page, self._ip_to_dict)
def get_scan_usage(self, site_id: int) -> List[Dict[str, Any]]:
"""
@@ -470,59 +563,91 @@ class SiteService:
# Private helper methods
def _add_cidr_to_site(self, site: Site, cidr_data: Dict[str, Any]) -> SiteCIDR:
"""Helper to add CIDR during site creation."""
cidr = cidr_data.get('cidr')
if not cidr:
raise ValueError("CIDR 'cidr' field is required")
def _expand_cidr_to_ips(self, site_id: int,
network: ipaddress.IPv4Network | ipaddress.IPv6Network,
expected_ping: Optional[bool],
expected_tcp_ports: List[int],
expected_udp_ports: List[int]) -> tuple[int, List[str], List[str]]:
"""
Expand a CIDR to individual IP addresses.
# Validate CIDR format
try:
ipaddress.ip_network(cidr, strict=False)
except ValueError as e:
raise ValueError(f"Invalid CIDR notation '{cidr}': {str(e)}")
Args:
site_id: Site ID
network: ipaddress network object
expected_ping: Default ping setting for all IPs
expected_tcp_ports: Default TCP ports for all IPs
expected_udp_ports: Default UDP ports for all IPs
cidr_obj = SiteCIDR(
site_id=site.id,
cidr=cidr,
expected_ping=cidr_data.get('expected_ping'),
expected_tcp_ports=json.dumps(cidr_data.get('expected_tcp_ports', [])),
expected_udp_ports=json.dumps(cidr_data.get('expected_udp_ports', [])),
created_at=datetime.utcnow()
)
Returns:
Tuple of (count of IPs created, list of IPs added, list of IPs skipped)
"""
ip_count = 0
ips_added = []
ips_skipped = []
self.db.add(cidr_obj)
return cidr_obj
# For /32 or /128 (single host), use the network address
# For larger ranges, use hosts() to exclude network/broadcast addresses
if network.num_addresses == 1:
ip_list = [network.network_address]
elif network.num_addresses == 2:
# For /31 networks (point-to-point), both addresses are usable
ip_list = [network.network_address, network.broadcast_address]
else:
# Use hosts() to get usable IPs (excludes network and broadcast)
ip_list = list(network.hosts())
for ip in ip_list:
ip_str = str(ip)
# Check for duplicate
existing = (
self.db.query(SiteIP)
.filter(SiteIP.site_id == site_id, SiteIP.ip_address == ip_str)
.first()
)
if existing:
ips_skipped.append(ip_str)
continue
# Create SiteIP entry
ip_obj = SiteIP(
site_id=site_id,
ip_address=ip_str,
expected_ping=expected_ping,
expected_tcp_ports=json.dumps(expected_tcp_ports),
expected_udp_ports=json.dumps(expected_udp_ports),
created_at=datetime.utcnow()
)
self.db.add(ip_obj)
ips_added.append(ip_str)
ip_count += 1
return ip_count, ips_added, ips_skipped
def _site_to_dict(self, site: Site) -> Dict[str, Any]:
"""Convert Site model to dictionary."""
# Count IPs for this site
ip_count = (
self.db.query(func.count(SiteIP.id))
.filter(SiteIP.site_id == site.id)
.scalar() or 0
)
return {
'id': site.id,
'name': site.name,
'description': site.description,
'created_at': site.created_at.isoformat() if site.created_at else None,
'updated_at': site.updated_at.isoformat() if site.updated_at else None,
'cidrs': [self._cidr_to_dict(cidr) for cidr in site.cidrs] if hasattr(site, 'cidrs') else []
'ip_count': ip_count
}
def _cidr_to_dict(self, cidr: SiteCIDR) -> Dict[str, Any]:
"""Convert SiteCIDR model to dictionary."""
return {
'id': cidr.id,
'site_id': cidr.site_id,
'cidr': cidr.cidr,
'expected_ping': cidr.expected_ping,
'expected_tcp_ports': json.loads(cidr.expected_tcp_ports) if cidr.expected_tcp_ports else [],
'expected_udp_ports': json.loads(cidr.expected_udp_ports) if cidr.expected_udp_ports else [],
'created_at': cidr.created_at.isoformat() if cidr.created_at else None,
'ip_overrides': [self._ip_override_to_dict(ip) for ip in cidr.ips] if hasattr(cidr, 'ips') else []
}
def _ip_override_to_dict(self, ip: SiteIP) -> Dict[str, Any]:
def _ip_to_dict(self, ip: SiteIP) -> Dict[str, Any]:
"""Convert SiteIP model to dictionary."""
return {
'id': ip.id,
'site_cidr_id': ip.site_cidr_id,
'site_id': ip.site_id,
'ip_address': ip.ip_address,
'expected_ping': ip.expected_ping,
'expected_tcp_ports': json.loads(ip.expected_tcp_ports) if ip.expected_tcp_ports else [],

View File

@@ -96,8 +96,8 @@
{% endif %}
</td>
<td>
{% if rule.config_file %}
<small class="text-muted">{{ rule.config_file }}</small>
{% if rule.config %}
<small class="text-muted">{{ rule.config.title }}</small>
{% else %}
<span class="badge bg-primary">All Configs</span>
{% endif %}
@@ -209,20 +209,9 @@
<label for="rule-config" class="form-label">Apply to Config (optional)</label>
<select class="form-select" id="rule-config">
<option value="">All Configs (Apply to all scans)</option>
{% if config_files %}
{% for config_file in config_files %}
<option value="{{ config_file }}">{{ config_file }}</option>
{% endfor %}
{% else %}
<option value="" disabled>No config files found</option>
{% endif %}
</select>
<small class="form-text text-muted">
{% if config_files %}
Select a specific config file to limit this rule, or leave as "All Configs" to apply to all scans
{% else %}
No config files found. Upload a config in the Configs section to see available options.
{% endif %}
<small class="form-text text-muted" id="config-help-text">
Select a specific config to limit this rule, or leave as "All Configs" to apply to all scans
</small>
</div>
</div>
@@ -272,12 +261,51 @@
<script>
let editingRuleId = null;
// Load available configs for the dropdown
async function loadConfigsForRule() {
const selectEl = document.getElementById('rule-config');
try {
const response = await fetch('/api/configs');
if (!response.ok) {
throw new Error('Failed to load configurations');
}
const data = await response.json();
const configs = data.configs || [];
// Preserve the "All Configs" option and current selection
const currentValue = selectEl.value;
selectEl.innerHTML = '<option value="">All Configs (Apply to all scans)</option>';
configs.forEach(config => {
const option = document.createElement('option');
// Store the config ID as the value
option.value = config.id;
const siteText = config.site_count === 1 ? 'site' : 'sites';
option.textContent = `${config.title} (${config.site_count} ${siteText})`;
selectEl.appendChild(option);
});
// Restore selection if it was set
if (currentValue) {
selectEl.value = currentValue;
}
} catch (error) {
console.error('Error loading configs:', error);
}
}
function showCreateRuleModal() {
editingRuleId = null;
document.getElementById('ruleModalTitle').textContent = 'Create Alert Rule';
document.getElementById('save-rule-text').textContent = 'Create Rule';
document.getElementById('ruleForm').reset();
document.getElementById('rule-enabled').checked = true;
// Load configs when modal is shown
loadConfigsForRule();
new bootstrap.Modal(document.getElementById('ruleModal')).show();
}
@@ -286,33 +314,36 @@ function editRule(ruleId) {
document.getElementById('ruleModalTitle').textContent = 'Edit Alert Rule';
document.getElementById('save-rule-text').textContent = 'Update Rule';
// Fetch rule details
fetch(`/api/alerts/rules`, {
headers: {
'X-API-Key': localStorage.getItem('api_key') || ''
}
})
.then(response => response.json())
.then(data => {
const rule = data.rules.find(r => r.id === ruleId);
if (rule) {
document.getElementById('rule-id').value = rule.id;
document.getElementById('rule-name').value = rule.name || '';
document.getElementById('rule-type').value = rule.rule_type;
document.getElementById('rule-severity').value = rule.severity || 'warning';
document.getElementById('rule-threshold').value = rule.threshold || '';
document.getElementById('rule-config').value = rule.config_file || '';
document.getElementById('rule-email').checked = rule.email_enabled;
document.getElementById('rule-webhook').checked = rule.webhook_enabled;
document.getElementById('rule-enabled').checked = rule.enabled;
// Load configs first, then fetch rule details
loadConfigsForRule().then(() => {
// Fetch rule details
fetch(`/api/alerts/rules`, {
headers: {
'X-API-Key': localStorage.getItem('api_key') || ''
}
})
.then(response => response.json())
.then(data => {
const rule = data.rules.find(r => r.id === ruleId);
if (rule) {
document.getElementById('rule-id').value = rule.id;
document.getElementById('rule-name').value = rule.name || '';
document.getElementById('rule-type').value = rule.rule_type;
document.getElementById('rule-severity').value = rule.severity || 'warning';
document.getElementById('rule-threshold').value = rule.threshold || '';
document.getElementById('rule-config').value = rule.config_id || '';
document.getElementById('rule-email').checked = rule.email_enabled;
document.getElementById('rule-webhook').checked = rule.webhook_enabled;
document.getElementById('rule-enabled').checked = rule.enabled;
updateThresholdLabel();
new bootstrap.Modal(document.getElementById('ruleModal')).show();
}
})
.catch(error => {
console.error('Error fetching rule:', error);
alert('Failed to load rule details');
updateThresholdLabel();
new bootstrap.Modal(document.getElementById('ruleModal')).show();
}
})
.catch(error => {
console.error('Error fetching rule:', error);
alert('Failed to load rule details');
});
});
}
@@ -353,7 +384,7 @@ function saveRule() {
const ruleType = document.getElementById('rule-type').value;
const severity = document.getElementById('rule-severity').value;
const threshold = document.getElementById('rule-threshold').value;
const configFile = document.getElementById('rule-config').value;
const configId = document.getElementById('rule-config').value;
const emailEnabled = document.getElementById('rule-email').checked;
const webhookEnabled = document.getElementById('rule-webhook').checked;
const enabled = document.getElementById('rule-enabled').checked;
@@ -368,7 +399,7 @@ function saveRule() {
rule_type: ruleType,
severity: severity,
threshold: threshold ? parseInt(threshold) : null,
config_file: configFile || null,
config_id: configId ? parseInt(configId) : null,
email_enabled: emailEnabled,
webhook_enabled: webhookEnabled,
enabled: enabled

View File

@@ -295,7 +295,7 @@ function renderSitesCheckboxes(selectedIds = [], isEditMode = false) {
id="${prefix}-${site.id}" ${isChecked ? 'checked' : ''}>
<label class="form-check-label" for="${prefix}-${site.id}">
${escapeHtml(site.name)}
<small class="text-muted">(${site.cidr_count || 0} CIDR${site.cidr_count !== 1 ? 's' : ''})</small>
<small class="text-muted">(${site.ip_count || 0} IP${site.ip_count !== 1 ? 's' : ''})</small>
</label>
</div>
`;
@@ -451,7 +451,7 @@ async function viewConfig(id) {
<strong>Sites (${config.site_count}):</strong>
<ul class="mt-2">
${config.sites.map(site => `
<li>${escapeHtml(site.name)} <small class="text-muted">(${site.cidr_count} CIDR${site.cidr_count !== 1 ? 's' : ''})</small></li>
<li>${escapeHtml(site.name)} <small class="text-muted">(${site.ip_count} IP${site.ip_count !== 1 ? 's' : ''})</small></li>
`).join('')}
</ul>
</div>

View File

@@ -153,34 +153,28 @@
<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 %}
<label for="config-select" class="form-label">Scan Configuration</label>
<select class="form-select" id="config-select" name="config_id" required>
<option value="">Loading configurations...</option>
</select>
{% if config_files %}
<div class="form-text text-muted">
Select a scan configuration file
<div class="form-text text-muted" id="config-help-text">
Select a scan configuration
</div>
{% else %}
<div class="alert alert-warning mt-2 mb-0" role="alert">
<div id="no-configs-warning" class="alert alert-warning mt-2 mb-0" role="alert" style="display: none;">
<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">
<p class="mb-2 mt-2">You need to create a configuration before you can trigger a scan.</p>
<a href="{{ url_for('main.configs') }}" 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 %}>
<button type="button" class="btn btn-primary" id="trigger-scan-btn" onclick="triggerScan()">
<span id="modal-trigger-text">Trigger Scan</span>
<span id="modal-trigger-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>
@@ -323,23 +317,75 @@
});
}
// Load available configs
async function loadConfigs() {
const selectEl = document.getElementById('config-select');
const helpTextEl = document.getElementById('config-help-text');
const noConfigsWarning = document.getElementById('no-configs-warning');
const triggerBtn = document.getElementById('trigger-scan-btn');
try {
const response = await fetch('/api/configs');
if (!response.ok) {
throw new Error('Failed to load configurations');
}
const data = await response.json();
const configs = data.configs || [];
// Clear existing options
selectEl.innerHTML = '';
if (configs.length === 0) {
selectEl.innerHTML = '<option value="">No configurations available</option>';
selectEl.disabled = true;
triggerBtn.disabled = true;
helpTextEl.style.display = 'none';
noConfigsWarning.style.display = 'block';
} else {
selectEl.innerHTML = '<option value="">Select a configuration...</option>';
configs.forEach(config => {
const option = document.createElement('option');
option.value = config.id;
const siteText = config.site_count === 1 ? 'site' : 'sites';
option.textContent = `${config.title} (${config.site_count} ${siteText})`;
selectEl.appendChild(option);
});
selectEl.disabled = false;
triggerBtn.disabled = false;
helpTextEl.style.display = 'block';
noConfigsWarning.style.display = 'none';
}
} catch (error) {
console.error('Error loading configs:', error);
selectEl.innerHTML = '<option value="">Error loading configurations</option>';
selectEl.disabled = true;
triggerBtn.disabled = true;
helpTextEl.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();
// Load configs when modal is shown
loadConfigs();
modal.show();
}
// Trigger scan
async function triggerScan() {
const configFile = document.getElementById('config-file').value;
const configId = document.getElementById('config-select').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.';
if (!configId) {
errorEl.textContent = 'Please select a configuration.';
errorEl.style.display = 'block';
return;
}
@@ -356,7 +402,7 @@
'Content-Type': 'application/json',
},
body: JSON.stringify({
config_file: configFile
config_id: parseInt(configId)
})
});

View File

@@ -79,14 +79,6 @@
</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>

View File

@@ -113,34 +113,28 @@
<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 %}
<label for="config-select" class="form-label">Scan Configuration</label>
<select class="form-select" id="config-select" name="config_id" required>
<option value="">Loading configurations...</option>
</select>
{% if config_files %}
<div class="form-text text-muted">
Select a scan configuration file
<div class="form-text text-muted" id="config-help-text">
Select a scan configuration
</div>
{% else %}
<div class="alert alert-warning mt-2 mb-0" role="alert">
<div id="no-configs-warning" class="alert alert-warning mt-2 mb-0" role="alert" style="display: none;">
<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">
<p class="mb-2 mt-2">You need to create a configuration before you can trigger a scan.</p>
<a href="{{ url_for('main.configs') }}" 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 %}>
<button type="button" class="btn btn-primary" id="trigger-scan-btn" onclick="triggerScan()">
<span id="modal-trigger-text">Trigger Scan</span>
<span id="modal-trigger-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>
@@ -359,23 +353,75 @@
});
}
// Load available configs
async function loadConfigs() {
const selectEl = document.getElementById('config-select');
const helpTextEl = document.getElementById('config-help-text');
const noConfigsWarning = document.getElementById('no-configs-warning');
const triggerBtn = document.getElementById('trigger-scan-btn');
try {
const response = await fetch('/api/configs');
if (!response.ok) {
throw new Error('Failed to load configurations');
}
const data = await response.json();
const configs = data.configs || [];
// Clear existing options
selectEl.innerHTML = '';
if (configs.length === 0) {
selectEl.innerHTML = '<option value="">No configurations available</option>';
selectEl.disabled = true;
triggerBtn.disabled = true;
helpTextEl.style.display = 'none';
noConfigsWarning.style.display = 'block';
} else {
selectEl.innerHTML = '<option value="">Select a configuration...</option>';
configs.forEach(config => {
const option = document.createElement('option');
option.value = config.id;
const siteText = config.site_count === 1 ? 'site' : 'sites';
option.textContent = `${config.title} (${config.site_count} ${siteText})`;
selectEl.appendChild(option);
});
selectEl.disabled = false;
triggerBtn.disabled = false;
helpTextEl.style.display = 'block';
noConfigsWarning.style.display = 'none';
}
} catch (error) {
console.error('Error loading configs:', error);
selectEl.innerHTML = '<option value="">Error loading configurations</option>';
selectEl.disabled = true;
triggerBtn.disabled = true;
helpTextEl.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();
// Load configs when modal is shown
loadConfigs();
modal.show();
}
// Trigger scan
async function triggerScan() {
const configFile = document.getElementById('config-file').value;
const configId = document.getElementById('config-select').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.';
if (!configId) {
errorEl.textContent = 'Please select a configuration.';
errorEl.style.display = 'block';
return;
}
@@ -392,13 +438,13 @@
'Content-Type': 'application/json',
},
body: JSON.stringify({
config_file: configFile
config_id: parseInt(configId)
})
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to trigger scan');
throw new Error(data.message || data.error || 'Failed to trigger scan');
}
const data = await response.json();

View File

@@ -26,8 +26,8 @@
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="total-cidrs">-</div>
<div class="stat-label">Total CIDRs</div>
<div class="stat-value" id="total-ips">-</div>
<div class="stat-label">Total IPs</div>
</div>
</div>
<div class="col-md-4">
@@ -66,7 +66,7 @@
<tr>
<th>Site Name</th>
<th>Description</th>
<th>CIDRs</th>
<th>IPs</th>
<th>Created</th>
<th>Actions</th>
</tr>
@@ -79,7 +79,7 @@
<div id="empty-state" style="display: none;" class="text-center py-5">
<i class="bi bi-globe" style="font-size: 3rem; color: #64748b;"></i>
<h5 class="mt-3 text-muted">No sites defined</h5>
<p class="text-muted">Create your first site to group CIDR ranges</p>
<p class="text-muted">Create your first site to organize your IP addresses</p>
<button class="btn btn-primary mt-2" data-bs-toggle="modal" data-bs-target="#createSiteModal">
<i class="bi bi-plus-circle"></i> Create Site
</button>
@@ -109,17 +109,11 @@
</div>
<div class="mb-3">
<label for="site-description" class="form-label" style="color: #e2e8f0;">Description</label>
<textarea class="form-control" id="site-description" rows="2"
placeholder="Optional description"></textarea>
<textarea class="form-control" id="site-description" rows="3"
placeholder="Optional description of this site"></textarea>
</div>
<div class="mb-3">
<label class="form-label" style="color: #e2e8f0;">CIDR Ranges *</label>
<div id="cidrs-container">
<!-- CIDR inputs will be added here -->
</div>
<button type="button" class="btn btn-sm btn-outline-primary mt-2" onclick="addCidrInput()">
<i class="bi bi-plus"></i> Add CIDR
</button>
<div class="alert alert-info" style="background-color: #1e3a5f; border-color: #2d5a8c; color: #a5d6ff;">
<i class="bi bi-info-circle"></i> After creating the site, you'll be able to add IP addresses using CIDRs, individual IPs, or bulk import.
</div>
</form>
</div>
@@ -147,9 +141,146 @@
<h6 style="color: #94a3b8;">Description:</h6>
<p id="view-site-description" style="color: #e2e8f0;"></p>
<h6 class="mt-3" style="color: #94a3b8;">CIDR Ranges:</h6>
<div id="view-site-cidrs" class="table-responsive">
<!-- Will be populated -->
<div class="d-flex justify-content-between align-items-center mt-3 mb-2">
<h6 style="color: #94a3b8; margin: 0;">IP Addresses (<span id="ip-count">0</span>):</h6>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-plus-circle"></i> Add IPs
</button>
<ul class="dropdown-menu dropdown-menu-dark">
<li><a class="dropdown-item" href="#" onclick="showAddIpMethod('cidr'); return false;"><i class="bi bi-diagram-3"></i> From CIDR</a></li>
<li><a class="dropdown-item" href="#" onclick="showAddIpMethod('individual'); return false;"><i class="bi bi- hdd-network"></i> Individual IP</a></li>
<li><a class="dropdown-item" href="#" onclick="showAddIpMethod('bulk'); return false;"><i class="bi bi-file-earmark-text"></i> Bulk Import</a></li>
</ul>
</div>
</div>
<!-- Add from CIDR Form -->
<div id="add-cidr-form" style="display: none; margin-bottom: 15px; padding: 15px; background-color: #0f172a; border: 1px solid #334155; border-radius: 5px;">
<h6 style="color: #60a5fa;"><i class="bi bi-diagram-3"></i> Add IPs from CIDR</h6>
<div class="row">
<div class="col-md-6">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">CIDR Range *</label>
<input type="text" class="form-control form-control-sm" id="bulk-cidr" placeholder="e.g., 10.0.0.0/24">
<small style="color: #64748b;">Max /24 (256 IPs)</small>
</div>
<div class="col-md-6">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected Ping</label>
<select class="form-select form-select-sm" id="bulk-cidr-ping">
<option value="null">Not Set</option>
<option value="true">Yes</option>
<option value="false" selected>No</option>
</select>
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected TCP Ports</label>
<input type="text" class="form-control form-control-sm" id="bulk-cidr-tcp-ports" placeholder="e.g., 22,80,443">
<small style="color: #64748b;">Comma-separated</small>
</div>
<div class="col-md-6">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected UDP Ports</label>
<input type="text" class="form-control form-control-sm" id="bulk-cidr-udp-ports" placeholder="e.g., 53,123">
<small style="color: #64748b;">Comma-separated</small>
</div>
</div>
<div class="mt-3">
<button class="btn btn-sm btn-primary" onclick="addIpsFromCidr()">
<i class="bi bi-check-circle"></i> Add IPs
</button>
<button class="btn btn-sm btn-secondary" onclick="hideAllAddForms()">
Cancel
</button>
</div>
</div>
<!-- Add Individual IP Form -->
<div id="add-individual-form" style="display: none; margin-bottom: 15px; padding: 15px; background-color: #0f172a; border: 1px solid #334155; border-radius: 5px;">
<h6 style="color: #60a5fa;"><i class="bi bi-hdd-network"></i> Add Individual IP</h6>
<div class="row">
<div class="col-md-6">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">IP Address *</label>
<input type="text" class="form-control form-control-sm" id="individual-ip" placeholder="e.g., 192.168.1.100">
</div>
<div class="col-md-6">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected Ping</label>
<select class="form-select form-select-sm" id="individual-ping">
<option value="null">Not Set</option>
<option value="true">Yes</option>
<option value="false" selected>No</option>
</select>
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected TCP Ports</label>
<input type="text" class="form-control form-control-sm" id="individual-tcp-ports" placeholder="e.g., 22,80,443">
<small style="color: #64748b;">Comma-separated</small>
</div>
<div class="col-md-6">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected UDP Ports</label>
<input type="text" class="form-control form-control-sm" id="individual-udp-ports" placeholder="e.g., 53,123">
<small style="color: #64748b;">Comma-separated</small>
</div>
</div>
<div class="mt-3">
<button class="btn btn-sm btn-primary" onclick="addIndividualIp()">
<i class="bi bi-check-circle"></i> Add IP
</button>
<button class="btn btn-sm btn-secondary" onclick="hideAllAddForms()">
Cancel
</button>
</div>
</div>
<!-- Bulk Import Form -->
<div id="add-bulk-form" style="display: none; margin-bottom: 15px; padding: 15px; background-color: #0f172a; border: 1px solid #334155; border-radius: 5px;">
<h6 style="color: #60a5fa;"><i class="bi bi-file-earmark-text"></i> Bulk Import IPs</h6>
<div class="mb-2">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">IP Addresses *</label>
<textarea class="form-control form-control-sm" id="bulk-ips" rows="5" placeholder="Paste IPs here (one per line, or comma/space separated)"></textarea>
<small style="color: #64748b;">Supports: one per line, comma-separated, or space-separated</small>
</div>
<div class="row mt-2">
<div class="col-md-4">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected Ping</label>
<select class="form-select form-select-sm" id="bulk-ping">
<option value="null">Not Set</option>
<option value="true">Yes</option>
<option value="false" selected>No</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected TCP Ports</label>
<input type="text" class="form-control form-control-sm" id="bulk-tcp-ports" placeholder="e.g., 22,80,443">
<small style="color: #64748b;">Comma-separated</small>
</div>
<div class="col-md-4">
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected UDP Ports</label>
<input type="text" class="form-control form-control-sm" id="bulk-udp-ports" placeholder="e.g., 53,123">
<small style="color: #64748b;">Comma-separated</small>
</div>
</div>
<div class="mt-3">
<button class="btn btn-sm btn-primary" onclick="addIpsFromBulk()">
<i class="bi bi-check-circle"></i> Import IPs
</button>
<button class="btn btn-sm btn-secondary" onclick="hideAllAddForms()">
Cancel
</button>
</div>
</div>
<!-- IP Table -->
<div id="view-site-ips-container">
<div id="ips-loading" style="display: none;" class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<span class="ms-2" style="color: #94a3b8;">Loading IPs...</span>
</div>
<div id="view-site-ips" class="table-responsive">
<!-- Will be populated by JavaScript -->
</div>
</div>
<h6 class="mt-3" style="color: #94a3b8;">Usage:</h6>
@@ -188,7 +319,7 @@
</div>
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> To edit CIDRs and IP ranges, please delete and recreate the site.
<i class="bi bi-info-circle"></i> To manage IP addresses, use the "View" button on the site.
</div>
<div class="alert alert-danger" id="edit-site-error" style="display: none;">
@@ -205,6 +336,56 @@
</div>
</div>
<!-- Edit IP Modal -->
<div class="modal fade" id="editIpModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
<div class="modal-header" style="border-bottom: 1px solid #334155;">
<h5 class="modal-title" style="color: #60a5fa;">
<i class="bi bi-pencil"></i> Edit IP Settings
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-ip-site-id">
<input type="hidden" id="edit-ip-id">
<div class="mb-3">
<label class="form-label" style="color: #e2e8f0;">IP Address</label>
<input type="text" class="form-control" id="edit-ip-address" readonly>
</div>
<div class="mb-3">
<label for="edit-ip-ping" class="form-label" style="color: #e2e8f0;">Expected Ping</label>
<select class="form-select" id="edit-ip-ping">
<option value="null">Not Set</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
<div class="mb-3">
<label for="edit-ip-tcp-ports" class="form-label" style="color: #e2e8f0;">Expected TCP Ports</label>
<input type="text" class="form-control" id="edit-ip-tcp-ports" placeholder="e.g., 22,80,443">
<small style="color: #64748b;">Comma-separated port numbers</small>
</div>
<div class="mb-3">
<label for="edit-ip-udp-ports" class="form-label" style="color: #e2e8f0;">Expected UDP Ports</label>
<input type="text" class="form-control" id="edit-ip-udp-ports" placeholder="e.g., 53,123">
<small style="color: #64748b;">Comma-separated port numbers</small>
</div>
</div>
<div class="modal-footer" style="border-top: 1px solid #334155;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="save-ip-btn">
<i class="bi bi-check-circle"></i> Save Changes
</button>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
@@ -240,7 +421,31 @@
// Global variables
let sitesData = [];
let selectedSiteForDeletion = null;
let cidrInputCounter = 0;
let currentViewingSiteId = null; // Track the site ID currently being viewed
let currentSitePage = 1;
// Helper function to clean up any stray modal backdrops
function cleanupModalBackdrops() {
// Remove any leftover backdrops
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
backdrop.remove();
});
// Remove modal-open class from body
document.body.classList.remove('modal-open');
document.body.style.removeProperty('overflow');
document.body.style.removeProperty('padding-right');
}
// Helper function to show a modal (reuses existing instance if available)
function showModal(modalId) {
const modalElement = document.getElementById(modalId);
let modal = bootstrap.Modal.getInstance(modalElement);
if (!modal) {
modal = new bootstrap.Modal(modalElement);
}
modal.show();
return modal;
}
// Format date
function formatDate(timestamp) {
@@ -249,57 +454,26 @@ function formatDate(timestamp) {
return date.toLocaleString();
}
// Add CIDR input field
function addCidrInput(cidr = '', expectedPing = false, expectedTcpPorts = [], expectedUdpPorts = []) {
const container = document.getElementById('cidrs-container');
const id = cidrInputCounter++;
const cidrHtml = `
<div class="cidr-input-group mb-2 p-3" style="background-color: #0f172a; border: 1px solid #334155; border-radius: 5px;" id="cidr-${id}">
<div class="row">
<div class="col-md-6">
<label class="form-label" style="color: #94a3b8; font-size: 0.875rem;">CIDR *</label>
<input type="text" class="form-control form-control-sm cidr-value" placeholder="e.g., 10.0.0.0/24" value="${cidr}" required>
</div>
<div class="col-md-3">
<label class="form-label" style="color: #94a3b8; font-size: 0.875rem;">Expect Ping</label>
<select class="form-select form-select-sm cidr-ping">
<option value="">Default (No)</option>
<option value="true" ${expectedPing ? 'selected' : ''}>Yes</option>
<option value="false" ${expectedPing === false ? 'selected' : ''}>No</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label" style="color: #94a3b8; font-size: 0.875rem;">Actions</label>
<button type="button" class="btn btn-sm btn-outline-danger w-100" onclick="removeCidrInput(${id})">
<i class="bi bi-trash"></i> Remove
</button>
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label class="form-label" style="color: #94a3b8; font-size: 0.875rem;">Expected TCP Ports</label>
<input type="text" class="form-control form-control-sm cidr-tcp-ports" placeholder="e.g., 22,80,443" value="${expectedTcpPorts.join(',')}">
<small style="color: #64748b;">Comma-separated port numbers</small>
</div>
<div class="col-md-6">
<label class="form-label" style="color: #94a3b8; font-size: 0.875rem;">Expected UDP Ports</label>
<input type="text" class="form-control form-control-sm cidr-udp-ports" placeholder="e.g., 53,123" value="${expectedUdpPorts.join(',')}">
<small style="color: #64748b;">Comma-separated port numbers</small>
</div>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', cidrHtml);
// Show/hide add IP form methods
function showAddIpMethod(method) {
hideAllAddForms();
if (method === 'cidr') {
document.getElementById('add-cidr-form').style.display = 'block';
} else if (method === 'individual') {
document.getElementById('add-individual-form').style.display = 'block';
} else if (method === 'bulk') {
document.getElementById('add-bulk-form').style.display = 'block';
}
}
// Remove CIDR input field
function removeCidrInput(id) {
const element = document.getElementById(`cidr-${id}`);
if (element) {
element.remove();
}
function hideAllAddForms() {
document.getElementById('add-cidr-form').style.display = 'none';
document.getElementById('add-individual-form').style.display = 'none';
document.getElementById('add-bulk-form').style.display = 'none';
// Reset forms
document.getElementById('bulk-cidr').value = '';
document.getElementById('individual-ip').value = '';
document.getElementById('bulk-ips').value = '';
}
// Parse port list from comma-separated string
@@ -342,10 +516,10 @@ async function loadSites() {
// Update summary stats
function updateStats() {
const totalSites = sitesData.length;
const totalCidrs = sitesData.reduce((sum, site) => sum + (site.cidrs?.length || 0), 0);
const totalIps = sitesData.reduce((sum, site) => sum + (site.ip_count || 0), 0);
document.getElementById('total-sites').textContent = totalSites;
document.getElementById('total-cidrs').textContent = totalCidrs;
document.getElementById('total-ips').textContent = totalIps;
document.getElementById('sites-in-use').textContent = '-'; // Will be updated async
// Count sites in use (async)
@@ -380,16 +554,16 @@ function renderSites(sites) {
emptyState.style.display = 'none';
tbody.innerHTML = sites.map(site => {
const cidrCount = site.cidrs?.length || 0;
const cidrBadge = cidrCount > 0
? `<span class="badge bg-info">${cidrCount} CIDR${cidrCount !== 1 ? 's' : ''}</span>`
: '<span class="badge bg-secondary">No CIDRs</span>';
const ipCount = site.ip_count || 0;
const ipBadge = ipCount > 0
? `<span class="badge bg-info">${ipCount} IP${ipCount !== 1 ? 's' : ''}</span>`
: '<span class="badge bg-secondary">No IPs</span>';
return `
<tr>
<td><strong style="color: #60a5fa;">${site.name}</strong></td>
<td style="color: #94a3b8;">${site.description || '<em>No description</em>'}</td>
<td>${cidrBadge}</td>
<td>${ipBadge}</td>
<td>${formatDate(site.created_at)}</td>
<td>
<div class="btn-group btn-group-sm" role="group">
@@ -420,31 +594,6 @@ async function createSite() {
return;
}
// Collect CIDRs
const cidrGroups = document.querySelectorAll('.cidr-input-group');
const cidrs = [];
cidrGroups.forEach(group => {
const cidrValue = group.querySelector('.cidr-value').value.trim();
if (!cidrValue) return;
const pingValue = group.querySelector('.cidr-ping').value;
const tcpPorts = parsePortList(group.querySelector('.cidr-tcp-ports').value);
const udpPorts = parsePortList(group.querySelector('.cidr-udp-ports').value);
cidrs.push({
cidr: cidrValue,
expected_ping: pingValue === 'true' ? true : (pingValue === 'false' ? false : null),
expected_tcp_ports: tcpPorts,
expected_udp_ports: udpPorts
});
});
if (cidrs.length === 0) {
showAlert('warning', 'At least one CIDR is required');
return;
}
const response = await fetch('/api/sites', {
method: 'POST',
headers: {
@@ -452,8 +601,7 @@ async function createSite() {
},
body: JSON.stringify({
name: name,
description: description || null,
cidrs: cidrs
description: description || null
})
});
@@ -462,19 +610,22 @@ async function createSite() {
throw new Error(error.message || `HTTP ${response.status}`);
}
// Hide modal
const newSite = await response.json();
// Hide create modal
bootstrap.Modal.getInstance(document.getElementById('createSiteModal')).hide();
// Reset form
document.getElementById('create-site-form').reset();
document.getElementById('cidrs-container').innerHTML = '';
cidrInputCounter = 0;
// Reload sites
await loadSites();
// Show success message
showAlert('success', `Site "${name}" created successfully`);
// Show success message and open view modal
showAlert('success', `Site "${name}" created successfully. Now add some IP addresses!`);
// Open the view modal to add IPs
viewSite(newSite.id);
} catch (error) {
console.error('Error creating site:', error);
@@ -485,6 +636,7 @@ async function createSite() {
// View site details
async function viewSite(siteId) {
try {
currentViewingSiteId = siteId;
const response = await fetch(`/api/sites/${siteId}`);
if (!response.ok) {
throw new Error(`Failed to load site: ${response.statusText}`);
@@ -495,31 +647,8 @@ async function viewSite(siteId) {
document.getElementById('view-site-name').textContent = site.name;
document.getElementById('view-site-description').textContent = site.description || 'No description';
// Render CIDRs
const cidrsHtml = site.cidrs && site.cidrs.length > 0
? `<table class="table table-sm">
<thead>
<tr>
<th>CIDR</th>
<th>Ping</th>
<th>TCP Ports</th>
<th>UDP Ports</th>
</tr>
</thead>
<tbody>
${site.cidrs.map(cidr => `
<tr>
<td><code>${cidr.cidr}</code></td>
<td>${cidr.expected_ping ? '<span class="badge bg-success">Yes</span>' : '<span class="badge bg-secondary">No</span>'}</td>
<td>${cidr.expected_tcp_ports?.length > 0 ? cidr.expected_tcp_ports.join(', ') : '-'}</td>
<td>${cidr.expected_udp_ports?.length > 0 ? cidr.expected_udp_ports.join(', ') : '-'}</td>
</tr>
`).join('')}
</tbody>
</table>`
: '<p style="color: #94a3b8;"><em>No CIDRs defined</em></p>';
document.getElementById('view-site-cidrs').innerHTML = cidrsHtml;
// Load IPs
await loadSiteIps(siteId);
// Load usage
document.getElementById('view-site-usage').innerHTML = '<p style="color: #94a3b8;"><i class="bi bi-hourglass"></i> Loading usage...</p>';
@@ -538,7 +667,7 @@ async function viewSite(siteId) {
document.getElementById('view-site-usage').innerHTML = usageHtml;
}
new bootstrap.Modal(document.getElementById('viewSiteModal')).show();
showModal('viewSiteModal');
} catch (error) {
console.error('Error viewing site:', error);
@@ -546,6 +675,250 @@ async function viewSite(siteId) {
}
}
// Load IPs for a site
async function loadSiteIps(siteId) {
try {
document.getElementById('ips-loading').style.display = 'block';
const response = await fetch(`/api/sites/${siteId}/ips?per_page=100`);
if (!response.ok) {
throw new Error('Failed to load IPs');
}
const data = await response.json();
const ips = data.ips || [];
document.getElementById('ip-count').textContent = data.total || ips.length;
// Render flat IP table
if (ips.length === 0) {
document.getElementById('view-site-ips').innerHTML = `
<div class="text-center py-4" style="color: #94a3b8;">
<i class="bi bi-hdd-network" style="font-size: 2rem;"></i>
<p class="mt-2"><em>No IPs added yet</em></p>
<p class="text-muted" style="font-size: 0.875rem;">Use the "Add IPs" button above to get started</p>
</div>
`;
} else {
const tableHtml = `
<table class="table table-sm table-hover">
<thead style="position: sticky; top: 0; background-color: #1e293b;">
<tr>
<th>IP Address</th>
<th>Ping</th>
<th>TCP Ports</th>
<th>UDP Ports</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${ips.map(ip => `
<tr>
<td><code>${ip.ip_address}</code></td>
<td>${ip.expected_ping ? '<span class="badge bg-success">Yes</span>' : '<span class="badge bg-secondary">No</span>'}</td>
<td style="font-size: 0.875rem;">${ip.expected_tcp_ports?.length > 0 ? ip.expected_tcp_ports.join(', ') : '-'}</td>
<td style="font-size: 0.875rem;">${ip.expected_udp_ports?.length > 0 ? ip.expected_udp_ports.join(', ') : '-'}</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-primary" onclick="editIp(${siteId}, ${ip.id}, '${ip.ip_address}', ${ip.expected_ping}, ${JSON.stringify(ip.expected_tcp_ports || [])}, ${JSON.stringify(ip.expected_udp_ports || [])})" title="Edit">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-outline-danger" onclick="confirmDeleteIp(${siteId}, ${ip.id}, '${ip.ip_address}')" title="Delete IP">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('view-site-ips').innerHTML = tableHtml;
}
document.getElementById('ips-loading').style.display = 'none';
} catch (error) {
console.error('Error loading IPs:', error);
document.getElementById('view-site-ips').innerHTML = `<p style="color: #f87171;">Error loading IPs: ${error.message}</p>`;
document.getElementById('ips-loading').style.display = 'none';
}
}
// Add IPs from CIDR
async function addIpsFromCidr() {
try {
const cidr = document.getElementById('bulk-cidr').value.trim();
if (!cidr) {
showAlert('warning', 'CIDR is required');
return;
}
const pingValue = document.getElementById('bulk-cidr-ping').value;
const expectedPing = pingValue === 'null' ? null : (pingValue === 'true');
const expectedTcpPorts = parsePortList(document.getElementById('bulk-cidr-tcp-ports').value);
const expectedUdpPorts = parsePortList(document.getElementById('bulk-cidr-udp-ports').value);
const response = await fetch(`/api/sites/${currentViewingSiteId}/ips/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_type: 'cidr',
cidr: cidr,
expected_ping: expectedPing,
expected_tcp_ports: expectedTcpPorts,
expected_udp_ports: expectedUdpPorts
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
const result = await response.json();
hideAllAddForms();
await loadSiteIps(currentViewingSiteId);
await loadSites(); // Refresh stats
showAlert('success', `Added ${result.ip_count} IPs from CIDR ${cidr}${result.ips_skipped.length > 0 ? ` (${result.ips_skipped.length} duplicates skipped)` : ''}`);
} catch (error) {
console.error('Error adding IPs from CIDR:', error);
showAlert('danger', `Error: ${error.message}`);
}
}
// Add individual IP
async function addIndividualIp() {
try {
const ipAddress = document.getElementById('individual-ip').value.trim();
if (!ipAddress) {
showAlert('warning', 'IP address is required');
return;
}
const pingValue = document.getElementById('individual-ping').value;
const expectedPing = pingValue === 'null' ? null : (pingValue === 'true');
const expectedTcpPorts = parsePortList(document.getElementById('individual-tcp-ports').value);
const expectedUdpPorts = parsePortList(document.getElementById('individual-udp-ports').value);
const response = await fetch(`/api/sites/${currentViewingSiteId}/ips`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ip_address: ipAddress,
expected_ping: expectedPing,
expected_tcp_ports: expectedTcpPorts,
expected_udp_ports: expectedUdpPorts
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
hideAllAddForms();
await loadSiteIps(currentViewingSiteId);
await loadSites(); // Refresh stats
showAlert('success', `IP ${ipAddress} added successfully`);
} catch (error) {
console.error('Error adding IP:', error);
showAlert('danger', `Error: ${error.message}`);
}
}
// Add IPs from bulk import
async function addIpsFromBulk() {
try {
const bulkText = document.getElementById('bulk-ips').value.trim();
if (!bulkText) {
showAlert('warning', 'IP list is required');
return;
}
// Parse IPs from text (supports newlines, commas, spaces)
const ipList = bulkText.split(/[\n,\s]+/).map(ip => ip.trim()).filter(ip => ip);
if (ipList.length === 0) {
showAlert('warning', 'No valid IPs found');
return;
}
const pingValue = document.getElementById('bulk-ping').value;
const expectedPing = pingValue === 'null' ? null : (pingValue === 'true');
const expectedTcpPorts = parsePortList(document.getElementById('bulk-tcp-ports').value);
const expectedUdpPorts = parsePortList(document.getElementById('bulk-udp-ports').value);
const response = await fetch(`/api/sites/${currentViewingSiteId}/ips/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_type: 'list',
ips: ipList,
expected_ping: expectedPing,
expected_tcp_ports: expectedTcpPorts,
expected_udp_ports: expectedUdpPorts
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
const result = await response.json();
hideAllAddForms();
await loadSiteIps(currentViewingSiteId);
await loadSites(); // Refresh stats
let message = `Added ${result.ip_count} IPs`;
if (result.ips_skipped.length > 0) message += ` (${result.ips_skipped.length} duplicates skipped)`;
if (result.errors.length > 0) message += ` (${result.errors.length} errors)`;
showAlert(result.errors.length > 0 ? 'warning' : 'success', message);
} catch (error) {
console.error('Error adding IPs from bulk:', error);
showAlert('danger', `Error: ${error.message}`);
}
}
// Confirm delete IP
function confirmDeleteIp(siteId, ipId, ipAddress) {
if (confirm(`Are you sure you want to delete IP ${ipAddress}?`)) {
deleteIp(siteId, ipId);
}
}
// Delete IP
async function deleteIp(siteId, ipId) {
try {
const response = await fetch(`/api/sites/${siteId}/ips/${ipId}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
await loadSiteIps(siteId);
await loadSites(); // Refresh stats
showAlert('success', 'IP deleted successfully');
} catch (error) {
console.error('Error deleting IP:', error);
showAlert('danger', `Error deleting IP: ${error.message}`);
}
}
// Edit site
async function editSite(siteId) {
try {
@@ -563,7 +936,7 @@ async function editSite(siteId) {
document.getElementById('edit-site-error').style.display = 'none';
// Show modal
new bootstrap.Modal(document.getElementById('editSiteModal')).show();
showModal('editSiteModal');
} catch (error) {
console.error('Error loading site:', error);
showAlert('danger', `Error loading site: ${error.message}`);
@@ -638,7 +1011,7 @@ async function confirmDelete(siteId, siteName) {
console.error('Error checking site usage:', e);
}
new bootstrap.Modal(document.getElementById('deleteModal')).show();
showModal('deleteModal');
}
// Delete site
@@ -670,6 +1043,69 @@ async function deleteSite() {
}
}
// Edit IP settings
function editIp(siteId, ipId, ipAddress, expectedPing, expectedTcpPorts, expectedUdpPorts) {
// Populate modal
document.getElementById('edit-ip-site-id').value = siteId;
document.getElementById('edit-ip-id').value = ipId;
document.getElementById('edit-ip-address').value = ipAddress;
// Set ping value
const pingValue = expectedPing === null ? 'null' : (expectedPing ? 'true' : 'false');
document.getElementById('edit-ip-ping').value = pingValue;
// Set ports
document.getElementById('edit-ip-tcp-ports').value = expectedTcpPorts && expectedTcpPorts.length > 0 ? expectedTcpPorts.join(',') : '';
document.getElementById('edit-ip-udp-ports').value = expectedUdpPorts && expectedUdpPorts.length > 0 ? expectedUdpPorts.join(',') : '';
// Show modal
showModal('editIpModal');
}
// Save IP settings
async function saveIp() {
try {
const siteId = document.getElementById('edit-ip-site-id').value;
const ipId = document.getElementById('edit-ip-id').value;
const ipAddress = document.getElementById('edit-ip-address').value;
const pingValue = document.getElementById('edit-ip-ping').value;
const expectedPing = pingValue === 'null' ? null : (pingValue === 'true');
const expectedTcpPorts = parsePortList(document.getElementById('edit-ip-tcp-ports').value);
const expectedUdpPorts = parsePortList(document.getElementById('edit-ip-udp-ports').value);
const response = await fetch(`/api/sites/${siteId}/ips/${ipId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
expected_ping: expectedPing,
expected_tcp_ports: expectedTcpPorts,
expected_udp_ports: expectedUdpPorts
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
// Hide modal
bootstrap.Modal.getInstance(document.getElementById('editIpModal')).hide();
// Reload the view site modal
await viewSite(siteId);
showAlert('success', `IP ${ipAddress} updated successfully`);
} catch (error) {
console.error('Error saving IP:', error);
showAlert('danger', `Error saving IP: ${error.message}`);
}
}
// Show alert
function showAlert(type, message) {
const alertHtml = `
@@ -711,13 +1147,16 @@ document.getElementById('search-input').addEventListener('input', function(e) {
// Setup delete button
document.getElementById('confirm-delete-btn').addEventListener('click', deleteSite);
// Initialize modal
document.getElementById('createSiteModal').addEventListener('show.bs.modal', function() {
// Reset form and add one CIDR input
document.getElementById('create-site-form').reset();
document.getElementById('cidrs-container').innerHTML = '';
cidrInputCounter = 0;
addCidrInput();
// Setup save IP button
document.getElementById('save-ip-btn').addEventListener('click', saveIp);
// Add cleanup listeners to all modals
['createSiteModal', 'viewSiteModal', 'editSiteModal', 'deleteModal', 'editIpModal'].forEach(modalId => {
const modalElement = document.getElementById(modalId);
modalElement.addEventListener('hidden.bs.modal', function() {
// Clean up any stray backdrops when modal is fully hidden
setTimeout(cleanupModalBackdrops, 100);
});
});
// Load sites on page load

View File

@@ -4,7 +4,7 @@ Pagination utilities for SneakyScanner web application.
Provides helper functions for paginating SQLAlchemy queries.
"""
from typing import Any, Dict, List
from typing import Any, Callable, Dict, List, Optional
from sqlalchemy.orm import Query
@@ -114,6 +114,7 @@ class PaginatedResult:
def paginate(query: Query, page: int = 1, per_page: int = 20,
transform: Optional[Callable[[Any], Dict[str, Any]]] = None,
max_per_page: int = 100) -> PaginatedResult:
"""
Paginate a SQLAlchemy query.
@@ -122,6 +123,7 @@ def paginate(query: Query, page: int = 1, per_page: int = 20,
query: SQLAlchemy query to paginate
page: Page number (1-indexed, default: 1)
per_page: Items per page (default: 20)
transform: Optional function to transform each item (default: None)
max_per_page: Maximum items per page (default: 100)
Returns:
@@ -133,6 +135,11 @@ def paginate(query: Query, page: int = 1, per_page: int = 20,
>>> result = paginate(query, page=1, per_page=20)
>>> scans = result.items
>>> total_pages = result.pages
>>> # With transform function
>>> def scan_to_dict(scan):
... return {'id': scan.id, 'name': scan.name}
>>> result = paginate(query, page=1, per_page=20, transform=scan_to_dict)
"""
# Validate and sanitize parameters
page = max(1, page) # Page must be at least 1
@@ -147,6 +154,10 @@ def paginate(query: Query, page: int = 1, per_page: int = 20,
# Execute query with limit and offset
items = query.limit(per_page).offset(offset).all()
# Apply transform if provided
if transform is not None:
items = [transform(item) for item in items]
return PaginatedResult(
items=items,
total=total,