diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 732b1b7..4b471cd 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -50,7 +50,7 @@ jobs: run: safety check --json || true - name: Trivy vulnerability scan - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@0.31.0 with: scan-type: 'fs' scan-ref: '.' diff --git a/config.yaml b/config.yaml index 9d736e5..dd61720 100644 --- a/config.yaml +++ b/config.yaml @@ -33,6 +33,9 @@ backups: exports: path: "exports" +logging: + level: "DEBUG" # DEBUG, INFO, WARNING, ERROR, CRITICAL + database: path: "data/krawl.db" retention_days: 30 diff --git a/helm/Chart.yaml b/helm/Chart.yaml index 26ce1ef..15ffe7c 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: krawl-chart description: A Helm chart for Krawl honeypot server type: application -version: 1.0.9 -appVersion: 1.0.9 +version: 1.0.10 +appVersion: 1.0.10 keywords: - honeypot - security diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index ed38d8d..73ffbb5 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -28,6 +28,8 @@ data: enabled: {{ .Values.config.backups.enabled }} exports: path: {{ .Values.config.exports.path | quote }} + logging: + level: {{ .Values.config.logging.level | quote }} database: path: {{ .Values.config.database.path | quote }} retention_days: {{ .Values.config.database.retention_days }} diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index f24261c..3676817 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -8,6 +8,8 @@ spec: {{- if not .Values.autoscaling.enabled }} replicas: {{ .Values.replicaCount }} {{- end }} + strategy: + type: Recreate selector: matchLabels: {{- include "krawl.selectorLabels" . | nindent 6 }} @@ -29,7 +31,7 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} containers: - - name: {{ .Chart.Name }} + - name: krawl {{- with .Values.securityContext }} securityContext: {{- toYaml . | nindent 12 }} diff --git a/helm/values.yaml b/helm/values.yaml index 20e7b3f..df4df23 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -86,10 +86,12 @@ config: secret_path: null # Auto-generated if not set, or set to "/my-secret-dashboard" backups: path: "backups" - enabled: true + enabled: false cron: "*/30 * * * *" exports: path: "exports" + logging: + level: "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL database: path: "data/krawl.db" retention_days: 30 diff --git a/kubernetes/krawl-all-in-one-deploy.yaml b/kubernetes/krawl-all-in-one-deploy.yaml index 767c080..38e9c9b 100644 --- a/kubernetes/krawl-all-in-one-deploy.yaml +++ b/kubernetes/krawl-all-in-one-deploy.yaml @@ -68,6 +68,14 @@ data: token_tries: 10 dashboard: secret_path: null + backups: + path: "backups" + cron: "*/30 * * * *" + enabled: false + exports: + path: "exports" + logging: + level: "INFO" database: path: "data/krawl.db" retention_days: 30 @@ -154,6 +162,8 @@ metadata: app.kubernetes.io/version: "1.0.0" spec: replicas: 1 + strategy: + type: Recreate selector: matchLabels: app.kubernetes.io/name: krawl diff --git a/kubernetes/manifests/configmap.yaml b/kubernetes/manifests/configmap.yaml index cdf6f1b..7782c9a 100644 --- a/kubernetes/manifests/configmap.yaml +++ b/kubernetes/manifests/configmap.yaml @@ -26,6 +26,14 @@ data: token_tries: 10 dashboard: secret_path: null + backups: + path: "backups" + cron: "*/30 * * * *" + enabled: false + exports: + path: "exports" + logging: + level: "INFO" database: path: "data/krawl.db" retention_days: 30 diff --git a/kubernetes/manifests/deployment.yaml b/kubernetes/manifests/deployment.yaml index 4c87a73..aff7469 100644 --- a/kubernetes/manifests/deployment.yaml +++ b/kubernetes/manifests/deployment.yaml @@ -10,6 +10,8 @@ metadata: app.kubernetes.io/version: "1.0.0" spec: replicas: 1 + strategy: + type: Recreate selector: matchLabels: app.kubernetes.io/name: krawl diff --git a/src/app.py b/src/app.py index 788bcf2..2b2df92 100644 --- a/src/app.py +++ b/src/app.py @@ -26,7 +26,7 @@ async def lifespan(app: FastAPI): config = get_config() # Initialize logging - initialize_logging() + initialize_logging(log_level=config.log_level) app_logger = get_app_logger() # Initialize database and run pending migrations before accepting traffic diff --git a/src/config.py b/src/config.py index 3bdf7e5..8344883 100644 --- a/src/config.py +++ b/src/config.py @@ -56,6 +56,8 @@ class Config: user_agents_used_threshold: float = None attack_urls_threshold: float = None + log_level: str = "INFO" + _server_ip: Optional[str] = None _server_ip_cache_time: float = 0 _ip_cache_ttl: int = 300 @@ -163,6 +165,7 @@ class Config: behavior = data.get("behavior", {}) analyzer = data.get("analyzer") or {} crawl = data.get("crawl", {}) + logging_cfg = data.get("logging", {}) # Handle dashboard_secret_path - auto-generate if null/not set dashboard_path = dashboard.get("secret_path") @@ -217,6 +220,9 @@ class Config: ), max_pages_limit=crawl.get("max_pages_limit", 250), ban_duration_seconds=crawl.get("ban_duration_seconds", 600), + log_level=os.getenv( + "KRAWL_LOG_LEVEL", logging_cfg.get("level", "INFO") + ).upper(), ) diff --git a/src/database.py b/src/database.py index 9daca49..cbee4a0 100644 --- a/src/database.py +++ b/src/database.py @@ -815,8 +815,8 @@ class DatabaseManager: def flag_stale_ips_for_reevaluation(self) -> int: """ Flag IPs for reevaluation where: - - last_seen is between 15 and 30 days ago - - last_analysis is more than 10 days ago (or never analyzed) + - last_seen is between 5 and 30 days ago + - last_analysis is more than 5 days ago Returns: Number of IPs flagged for reevaluation @@ -825,18 +825,15 @@ class DatabaseManager: try: now = datetime.now() last_seen_lower = now - timedelta(days=30) - last_seen_upper = now - timedelta(days=15) - last_analysis_cutoff = now - timedelta(days=10) + last_seen_upper = now - timedelta(days=5) + last_analysis_cutoff = now - timedelta(days=5) count = ( session.query(IpStats) .filter( IpStats.last_seen >= last_seen_lower, IpStats.last_seen <= last_seen_upper, - or_( - IpStats.last_analysis <= last_analysis_cutoff, - IpStats.last_analysis.is_(None), - ), + IpStats.last_analysis <= last_analysis_cutoff, IpStats.need_reevaluation == False, IpStats.manual_category == False, ) @@ -850,6 +847,99 @@ class DatabaseManager: except Exception as e: session.rollback() raise + + def flag_all_ips_for_reevaluation(self) -> int: + """ + Flag ALL IPs for reevaluation, regardless of staleness. + Skips IPs that have a manual category set. + + Returns: + Number of IPs flagged for reevaluation + """ + session = self.session + try: + count = ( + session.query(IpStats) + .filter( + IpStats.need_reevaluation == False, + IpStats.manual_category == False, + ) + .update( + {IpStats.need_reevaluation: True}, + synchronize_session=False, + ) + ) + session.commit() + return count + except Exception as e: + session.rollback() + raise + + def get_access_logs_paginated( + self, + page: int = 1, + page_size: int = 25, + ip_filter: Optional[str] = None, + suspicious_only: bool = False, + since_minutes: Optional[int] = None, + ) -> Dict[str, Any]: + """ + Retrieve access logs with pagination and optional filtering. + + Args: + page: Page to retrieve + page_size: Number of records for page + ip_filter: Filter by IP address + suspicious_only: Only return suspicious requests + since_minutes: Only return logs from the last N minutes + + Returns: + List of access log dictionaries + """ + session = self.session + try: + offset = (page - 1) * page_size + query = session.query(AccessLog).order_by(AccessLog.timestamp.desc()) + + if ip_filter: + query = query.filter(AccessLog.ip == sanitize_ip(ip_filter)) + if suspicious_only: + query = query.filter(AccessLog.is_suspicious == True) + if since_minutes is not None: + cutoff_time = datetime.now() - timedelta(minutes=since_minutes) + query = query.filter(AccessLog.timestamp >= cutoff_time) + + logs = query.offset(offset).limit(page_size).all() + # Get total count of attackers + total_access_logs = ( + session.query(AccessLog) + .filter(AccessLog.ip == sanitize_ip(ip_filter)) + .count() + ) + total_pages = (total_access_logs + page_size - 1) // page_size + + return { + "access_logs": [ + { + "id": log.id, + "ip": log.ip, + "path": log.path, + "user_agent": log.user_agent, + "method": log.method, + "is_suspicious": log.is_suspicious, + "is_honeypot_trigger": log.is_honeypot_trigger, + "timestamp": log.timestamp.isoformat(), + "attack_types": [d.attack_type for d in log.attack_detections], + } + for log in logs + ], + "pagination": { + "page": page, + "page_size": page_size, + "total_logs": total_access_logs, + "total_pages": total_pages, + }, + } finally: self.close_session() @@ -1018,6 +1108,8 @@ class DatabaseManager: "region": stat.region, "region_name": stat.region_name, "timezone": stat.timezone, + "latitude": stat.latitude, + "longitude": stat.longitude, "isp": stat.isp, "reverse": stat.reverse, "asn": stat.asn, @@ -1316,26 +1408,16 @@ class DatabaseManager: """ session = self.session try: - # Get server IP to filter it out from config import get_config config = get_config() server_ip = config.get_server_ip() - results = ( - session.query(AccessLog.ip, func.count(AccessLog.id).label("count")) - .group_by(AccessLog.ip) - .order_by(func.count(AccessLog.id).desc()) - .all() - ) + query = session.query(IpStats.ip, IpStats.total_requests) + query = self._public_ip_filter(query, IpStats.ip, server_ip) + results = query.order_by(IpStats.total_requests.desc()).limit(limit).all() - # Filter out local/private IPs and server IP, then limit results - filtered = [ - (row.ip, row.count) - for row in results - if is_valid_public_ip(row.ip, server_ip) - ] - return filtered[:limit] + return [(row.ip, row.total_requests) for row in results] finally: self.close_session() @@ -1402,23 +1484,18 @@ class DatabaseManager: """ session = self.session try: - # Get server IP to filter it out from config import get_config config = get_config() server_ip = config.get_server_ip() - logs = ( + query = ( session.query(AccessLog) .filter(AccessLog.is_suspicious == True) .order_by(AccessLog.timestamp.desc()) - .all() ) - - # Filter out local/private IPs and server IP - filtered_logs = [ - log for log in logs if is_valid_public_ip(log.ip, server_ip) - ] + query = self._public_ip_filter(query, AccessLog.ip, server_ip) + logs = query.limit(limit).all() return [ { @@ -1427,7 +1504,7 @@ class DatabaseManager: "user_agent": log.user_agent, "timestamp": log.timestamp.isoformat(), } - for log in filtered_logs[:limit] + for log in logs ] finally: self.close_session() @@ -1532,44 +1609,59 @@ class DatabaseManager: offset = (page - 1) * page_size - # Get honeypot triggers grouped by IP - results = ( - session.query(AccessLog.ip, AccessLog.path) - .filter(AccessLog.is_honeypot_trigger == True) - .all() + # Count distinct paths per IP using SQL GROUP BY + count_col = func.count(distinct(AccessLog.path)).label("path_count") + base_query = session.query(AccessLog.ip, count_col).filter( + AccessLog.is_honeypot_trigger == True + ) + base_query = self._public_ip_filter(base_query, AccessLog.ip, server_ip) + base_query = base_query.group_by(AccessLog.ip) + + # Get total count of distinct honeypot IPs + total_honeypots = base_query.count() + + # Apply sorting + if sort_by == "count": + order_expr = ( + count_col.desc() if sort_order == "desc" else count_col.asc() + ) + else: + order_expr = ( + AccessLog.ip.desc() if sort_order == "desc" else AccessLog.ip.asc() + ) + + ip_rows = ( + base_query.order_by(order_expr).offset(offset).limit(page_size).all() ) - # Group paths by IP, filtering out invalid IPs - ip_paths: Dict[str, List[str]] = {} - for row in results: - if not is_valid_public_ip(row.ip, server_ip): - continue - if row.ip not in ip_paths: - ip_paths[row.ip] = [] - if row.path not in ip_paths[row.ip]: - ip_paths[row.ip].append(row.path) - - # Create list and sort - honeypot_list = [ - {"ip": ip, "paths": paths, "count": len(paths)} - for ip, paths in ip_paths.items() - ] - - if sort_by == "count": - honeypot_list.sort( - key=lambda x: x["count"], reverse=(sort_order == "desc") - ) - else: # sort by ip - honeypot_list.sort( - key=lambda x: x["ip"], reverse=(sort_order == "desc") + # Fetch distinct paths only for the paginated IPs + paginated_ips = [row.ip for row in ip_rows] + honeypot_list = [] + if paginated_ips: + path_rows = ( + session.query(AccessLog.ip, AccessLog.path) + .filter( + AccessLog.is_honeypot_trigger == True, + AccessLog.ip.in_(paginated_ips), + ) + .distinct(AccessLog.ip, AccessLog.path) + .all() ) + ip_paths: Dict[str, List[str]] = {} + for row in path_rows: + ip_paths.setdefault(row.ip, []).append(row.path) - total_honeypots = len(honeypot_list) - paginated = honeypot_list[offset : offset + page_size] - total_pages = (total_honeypots + page_size - 1) // page_size + # Preserve the order from the sorted query + for row in ip_rows: + paths = ip_paths.get(row.ip, []) + honeypot_list.append( + {"ip": row.ip, "paths": paths, "count": row.path_count} + ) + + total_pages = max(1, (total_honeypots + page_size - 1) // page_size) return { - "honeypots": paginated, + "honeypots": honeypot_list, "pagination": { "page": page, "page_size": page_size, @@ -1668,6 +1760,9 @@ class DatabaseManager: """ Retrieve paginated list of top IP addresses by access count. + Uses the IpStats table (which already stores total_requests per IP) + instead of doing a costly GROUP BY on the large access_logs table. + Args: page: Page number (1-indexed) page_size: Number of results per page @@ -1686,30 +1781,34 @@ class DatabaseManager: offset = (page - 1) * page_size - results = ( - session.query(AccessLog.ip, func.count(AccessLog.id).label("count")) - .group_by(AccessLog.ip) - .all() - ) + base_query = session.query(IpStats) + base_query = self._public_ip_filter(base_query, IpStats.ip, server_ip) - # Filter out local/private IPs and server IP, then sort - filtered = [ - {"ip": row.ip, "count": row.count} - for row in results - if is_valid_public_ip(row.ip, server_ip) - ] + total_ips = base_query.count() if sort_by == "count": - filtered.sort(key=lambda x: x["count"], reverse=(sort_order == "desc")) - else: # sort by ip - filtered.sort(key=lambda x: x["ip"], reverse=(sort_order == "desc")) + order_col = IpStats.total_requests + else: + order_col = IpStats.ip - total_ips = len(filtered) - paginated = filtered[offset : offset + page_size] - total_pages = (total_ips + page_size - 1) // page_size + if sort_order == "desc": + base_query = base_query.order_by(order_col.desc()) + else: + base_query = base_query.order_by(order_col.asc()) + + results = base_query.offset(offset).limit(page_size).all() + + total_pages = max(1, (total_ips + page_size - 1) // page_size) return { - "ips": paginated, + "ips": [ + { + "ip": row.ip, + "count": row.total_requests, + "category": row.category or "unknown", + } + for row in results + ], "pagination": { "page": page, "page_size": page_size, @@ -1743,28 +1842,32 @@ class DatabaseManager: try: offset = (page - 1) * page_size - results = ( - session.query(AccessLog.path, func.count(AccessLog.id).label("count")) - .group_by(AccessLog.path) - .all() + count_col = func.count(AccessLog.id).label("count") + + # Get total number of distinct paths + total_paths = ( + session.query(func.count(distinct(AccessLog.path))).scalar() or 0 ) - # Create list and sort - paths_list = [{"path": row.path, "count": row.count} for row in results] + # Build query with SQL-level sorting and pagination + query = session.query(AccessLog.path, count_col).group_by(AccessLog.path) if sort_by == "count": - paths_list.sort( - key=lambda x: x["count"], reverse=(sort_order == "desc") + order_expr = ( + count_col.desc() if sort_order == "desc" else count_col.asc() + ) + else: + order_expr = ( + AccessLog.path.desc() + if sort_order == "desc" + else AccessLog.path.asc() ) - else: # sort by path - paths_list.sort(key=lambda x: x["path"], reverse=(sort_order == "desc")) - total_paths = len(paths_list) - paginated = paths_list[offset : offset + page_size] - total_pages = (total_paths + page_size - 1) // page_size + results = query.order_by(order_expr).offset(offset).limit(page_size).all() + total_pages = max(1, (total_paths + page_size - 1) // page_size) return { - "paths": paginated, + "paths": [{"path": row.path, "count": row.count} for row in results], "pagination": { "page": page, "page_size": page_size, @@ -1798,33 +1901,44 @@ class DatabaseManager: try: offset = (page - 1) * page_size - results = ( - session.query( - AccessLog.user_agent, func.count(AccessLog.id).label("count") - ) - .filter(AccessLog.user_agent.isnot(None), AccessLog.user_agent != "") - .group_by(AccessLog.user_agent) - .all() + count_col = func.count(AccessLog.id).label("count") + + base_filter = [AccessLog.user_agent.isnot(None), AccessLog.user_agent != ""] + + # Get total number of distinct user agents + total_uas = ( + session.query(func.count(distinct(AccessLog.user_agent))) + .filter(*base_filter) + .scalar() + or 0 ) - # Create list and sort - ua_list = [ - {"user_agent": row.user_agent, "count": row.count} for row in results - ] + # Build query with SQL-level sorting and pagination + query = ( + session.query(AccessLog.user_agent, count_col) + .filter(*base_filter) + .group_by(AccessLog.user_agent) + ) if sort_by == "count": - ua_list.sort(key=lambda x: x["count"], reverse=(sort_order == "desc")) - else: # sort by user_agent - ua_list.sort( - key=lambda x: x["user_agent"], reverse=(sort_order == "desc") + order_expr = ( + count_col.desc() if sort_order == "desc" else count_col.asc() + ) + else: + order_expr = ( + AccessLog.user_agent.desc() + if sort_order == "desc" + else AccessLog.user_agent.asc() ) - total_uas = len(ua_list) - paginated = ua_list[offset : offset + page_size] - total_pages = (total_uas + page_size - 1) // page_size + results = query.order_by(order_expr).offset(offset).limit(page_size).all() + total_pages = max(1, (total_uas + page_size - 1) // page_size) return { - "user_agents": paginated, + "user_agents": [ + {"user_agent": row.user_agent, "count": row.count} + for row in results + ], "pagination": { "page": page, "page_size": page_size, @@ -1841,6 +1955,7 @@ class DatabaseManager: page_size: int = 5, sort_by: str = "timestamp", sort_order: str = "desc", + ip_filter: Optional[str] = None, ) -> Dict[str, Any]: """ Retrieve paginated list of detected attack types with access logs. @@ -1850,6 +1965,7 @@ class DatabaseManager: page_size: Number of results per page sort_by: Field to sort by (timestamp, ip, attack_type) sort_order: Sort order (asc or desc) + ip_filter: Optional IP address to filter results Returns: Dictionary with attacks list and pagination info @@ -1865,18 +1981,22 @@ class DatabaseManager: sort_order.lower() if sort_order.lower() in {"asc", "desc"} else "desc" ) + # Base query filter + base_filters = [] + if ip_filter: + base_filters.append(AccessLog.ip == ip_filter) + # Count total unique access logs with attack detections - total_attacks = ( - session.query(AccessLog) - .join(AttackDetection) - .distinct(AccessLog.id) - .count() - ) + count_query = session.query(AccessLog).join(AttackDetection) + if base_filters: + count_query = count_query.filter(*base_filters) + total_attacks = count_query.distinct(AccessLog.id).count() # Get paginated access logs with attack detections - query = ( - session.query(AccessLog).join(AttackDetection).distinct(AccessLog.id) - ) + query = session.query(AccessLog).join(AttackDetection) + if base_filters: + query = query.filter(*base_filters) + query = query.distinct(AccessLog.id) if sort_by == "timestamp": query = query.order_by( @@ -1939,12 +2059,15 @@ class DatabaseManager: finally: self.close_session() - def get_attack_types_stats(self, limit: int = 20) -> Dict[str, Any]: + def get_attack_types_stats( + self, limit: int = 20, ip_filter: str | None = None + ) -> Dict[str, Any]: """ Get aggregated statistics for attack types (efficient for large datasets). Args: limit: Maximum number of attack types to return + ip_filter: Optional IP address to filter results for Returns: Dictionary with attack type counts @@ -1954,12 +2077,18 @@ class DatabaseManager: from sqlalchemy import func # Aggregate attack types with count + query = session.query( + AttackDetection.attack_type, + func.count(AttackDetection.id).label("count"), + ) + + if ip_filter: + query = query.join( + AccessLog, AttackDetection.access_log_id == AccessLog.id + ).filter(AccessLog.ip == ip_filter) + results = ( - session.query( - AttackDetection.attack_type, - func.count(AttackDetection.id).label("count"), - ) - .group_by(AttackDetection.attack_type) + query.group_by(AttackDetection.attack_type) .order_by(func.count(AttackDetection.id).desc()) .limit(limit) .all() @@ -1973,6 +2102,126 @@ class DatabaseManager: finally: self.close_session() + def search_attacks_and_ips( + self, + query: str, + page: int = 1, + page_size: int = 20, + ) -> Dict[str, Any]: + """ + Search attacks and IPs matching a query string. + + Searches across AttackDetection (attack_type, matched_pattern), + AccessLog (ip, path), and IpStats (ip, city, country, isp, asn_org). + + Args: + query: Search term (partial match) + page: Page number (1-indexed) + page_size: Results per page + + Returns: + Dictionary with matching attacks, ips, and pagination info + """ + session = self.session + try: + offset = (page - 1) * page_size + like_q = f"%{query}%" + + # --- Search attacks (AccessLog + AttackDetection) --- + attack_query = ( + session.query(AccessLog) + .join(AttackDetection) + .filter( + or_( + AccessLog.ip.ilike(like_q), + AccessLog.path.ilike(like_q), + AttackDetection.attack_type.ilike(like_q), + AttackDetection.matched_pattern.ilike(like_q), + ) + ) + .distinct(AccessLog.id) + ) + + total_attacks = attack_query.count() + attack_logs = ( + attack_query.order_by(AccessLog.timestamp.desc()) + .offset(offset) + .limit(page_size) + .all() + ) + + attacks = [ + { + "id": log.id, + "ip": log.ip, + "path": log.path, + "user_agent": log.user_agent, + "timestamp": log.timestamp.isoformat() if log.timestamp else None, + "attack_types": [d.attack_type for d in log.attack_detections], + "log_id": log.id, + } + for log in attack_logs + ] + + # --- Search IPs (IpStats) --- + ip_query = session.query(IpStats).filter( + or_( + IpStats.ip.ilike(like_q), + IpStats.city.ilike(like_q), + IpStats.country.ilike(like_q), + IpStats.country_code.ilike(like_q), + IpStats.isp.ilike(like_q), + IpStats.asn_org.ilike(like_q), + IpStats.reverse.ilike(like_q), + ) + ) + + total_ips = ip_query.count() + ips = ( + ip_query.order_by(IpStats.total_requests.desc()) + .offset(offset) + .limit(page_size) + .all() + ) + + ip_results = [ + { + "ip": stat.ip, + "total_requests": stat.total_requests, + "first_seen": ( + stat.first_seen.isoformat() if stat.first_seen else None + ), + "last_seen": stat.last_seen.isoformat() if stat.last_seen else None, + "country_code": stat.country_code, + "city": stat.city, + "category": stat.category, + "isp": stat.isp, + "asn_org": stat.asn_org, + } + for stat in ips + ] + + total = total_attacks + total_ips + total_pages = max( + 1, (max(total_attacks, total_ips) + page_size - 1) // page_size + ) + + return { + "attacks": attacks, + "ips": ip_results, + "query": query, + "pagination": { + "page": page, + "page_size": page_size, + "total_attacks": total_attacks, + "total_ips": total_ips, + "total": total, + "total_pages": total_pages, + }, + } + finally: + self.close_session() + # Module-level singleton instance _db_manager = DatabaseManager() diff --git a/src/dependencies.py b/src/dependencies.py index a713738..e1f908f 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -30,7 +30,7 @@ def get_templates() -> Jinja2Templates: return _templates -def _format_ts(value): +def _format_ts(value, time_only=False): """Custom Jinja2 filter for formatting ISO timestamps.""" if not value: return "N/A" @@ -39,6 +39,8 @@ def _format_ts(value): value = datetime.fromisoformat(value) except (ValueError, TypeError): return value + if time_only: + return value.strftime("%H:%M:%S") if value.date() == datetime.now().date(): return value.strftime("%H:%M:%S") return value.strftime("%m/%d/%Y %H:%M:%S") diff --git a/src/logger.py b/src/logger.py index 9762002..d556684 100644 --- a/src/logger.py +++ b/src/logger.py @@ -36,12 +36,13 @@ class LoggerManager: cls._instance._initialized = False return cls._instance - def initialize(self, log_dir: str = "logs") -> None: + def initialize(self, log_dir: str = "logs", log_level: str = "INFO") -> None: """ Initialize the logging system with rotating file handlers.loggers Args: log_dir: Directory for log files (created if not exists) + log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) """ if self._initialized: return @@ -59,9 +60,11 @@ class LoggerManager: max_bytes = 1048576 # 1MB backup_count = 5 + level = getattr(logging, log_level.upper(), logging.INFO) + # Setup application logger self._app_logger = logging.getLogger("krawl.app") - self._app_logger.setLevel(logging.INFO) + self._app_logger.setLevel(level) self._app_logger.handlers.clear() app_file_handler = RotatingFileHandler( @@ -78,7 +81,7 @@ class LoggerManager: # Setup access logger self._access_logger = logging.getLogger("krawl.access") - self._access_logger.setLevel(logging.INFO) + self._access_logger.setLevel(level) self._access_logger.handlers.clear() access_file_handler = RotatingFileHandler( @@ -95,7 +98,7 @@ class LoggerManager: # Setup credential logger (special format, no stream handler) self._credential_logger = logging.getLogger("krawl.credentials") - self._credential_logger.setLevel(logging.INFO) + self._credential_logger.setLevel(level) self._credential_logger.handlers.clear() # Credential logger uses a simple format: timestamp|ip|username|password|path @@ -152,6 +155,6 @@ def get_credential_logger() -> logging.Logger: return _logger_manager.credentials -def initialize_logging(log_dir: str = "logs") -> None: +def initialize_logging(log_dir: str = "logs", log_level: str = "INFO") -> None: """Initialize the logging system.""" - _logger_manager.initialize(log_dir) + _logger_manager.initialize(log_dir, log_level) diff --git a/src/routes/api.py b/src/routes/api.py index 02b52dc..d94b3b6 100644 --- a/src/routes/api.py +++ b/src/routes/api.py @@ -7,7 +7,6 @@ All endpoints are prefixed with the secret dashboard path. """ import os -import json from fastapi import APIRouter, Request, Response, Query from fastapi.responses import JSONResponse, PlainTextResponse @@ -215,12 +214,13 @@ async def top_user_agents( async def attack_types_stats( request: Request, limit: int = Query(20), + ip_filter: str = Query(None), ): db = get_db() limit = min(max(1, limit), 100) try: - result = db.get_attack_types_stats(limit=limit) + result = db.get_attack_types_stats(limit=limit, ip_filter=ip_filter) return JSONResponse(content=result, headers=_no_cache_headers()) except Exception as e: get_app_logger().error(f"Error fetching attack types stats: {e}") diff --git a/src/routes/dashboard.py b/src/routes/dashboard.py index 6f5773b..081336c 100644 --- a/src/routes/dashboard.py +++ b/src/routes/dashboard.py @@ -6,6 +6,8 @@ Renders the main dashboard page with server-side data for initial load. """ from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse +from logger import get_app_logger from dependencies import get_db, get_templates @@ -21,7 +23,7 @@ async def dashboard_page(request: Request): # Get initial data for server-rendered sections stats = db.get_dashboard_counts() - suspicious = db.get_recent_suspicious(limit=20) + suspicious = db.get_recent_suspicious(limit=10) # Get credential count for the stats card cred_result = db.get_credentials_paginated(page=1, page_size=1) @@ -37,3 +39,36 @@ async def dashboard_page(request: Request): "suspicious_activities": suspicious, }, ) + + +@router.get("/ip/{ip_address:path}") +async def ip_page(ip_address: str, request: Request): + db = get_db() + try: + stats = db.get_ip_stats_by_ip(ip_address) + config = request.app.state.config + dashboard_path = "/" + config.dashboard_secret_path.lstrip("/") + + if stats: + # Transform fields for template compatibility + list_on = stats.get("list_on") or {} + stats["blocklist_memberships"] = list(list_on.keys()) if list_on else [] + stats["reverse_dns"] = stats.get("reverse") + + templates = get_templates() + return templates.TemplateResponse( + "dashboard/ip.html", + { + "request": request, + "dashboard_path": dashboard_path, + "stats": stats, + "ip_address": ip_address, + }, + ) + else: + return JSONResponse( + content={"error": "IP not found"}, + ) + except Exception as e: + get_app_logger().error(f"Error fetching IP stats: {e}") + return JSONResponse(content={"error": str(e)}) diff --git a/src/routes/htmx.py b/src/routes/htmx.py index 4013ce5..303bce5 100644 --- a/src/routes/htmx.py +++ b/src/routes/htmx.py @@ -2,7 +2,7 @@ """ HTMX fragment endpoints. -Server-rendered HTML partials for table pagination, sorting, and IP details. +Server-rendered HTML partials for table pagination, sorting, IP details, and search. """ from fastapi import APIRouter, Request, Response, Query @@ -58,7 +58,7 @@ async def htmx_top_ips( ): db = get_db() result = db.get_top_ips_paginated( - page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order + page=max(1, page), page_size=8, sort_by=sort_by, sort_order=sort_order ) templates = get_templates() @@ -167,6 +167,42 @@ async def htmx_attackers( ) +# ── Access logs by ip ──────────────────────────────────────────────────────── + + +@router.get("/htmx/access-logs") +async def htmx_access_logs_by_ip( + request: Request, + page: int = Query(1), + sort_by: str = Query("total_requests"), + sort_order: str = Query("desc"), + ip_filter: str = Query("ip_filter"), +): + db = get_db() + result = db.get_access_logs_paginated( + page=max(1, page), page_size=25, ip_filter=ip_filter + ) + + # Normalize pagination key (DB returns total_attackers, template expects total) + pagination = result["pagination"] + if "total_access_logs" in pagination and "total" not in pagination: + pagination["total"] = pagination["total_access_logs"] + + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/access_by_ip_table.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + "items": result["access_logs"], + "pagination": pagination, + "sort_by": sort_by, + "sort_order": sort_order, + "ip_filter": ip_filter, + }, + ) + + # ── Credentials ────────────────────────────────────────────────────── @@ -205,10 +241,15 @@ async def htmx_attacks( page: int = Query(1), sort_by: str = Query("timestamp"), sort_order: str = Query("desc"), + ip_filter: str = Query(None), ): db = get_db() result = db.get_attack_types_paginated( - page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order + page=max(1, page), + page_size=5, + sort_by=sort_by, + sort_order=sort_order, + ip_filter=ip_filter, ) # Transform attack data for template (join attack_types list, map id to log_id) @@ -235,6 +276,7 @@ async def htmx_attacks( "pagination": result["pagination"], "sort_by": sort_by, "sort_order": sort_order, + "ip_filter": ip_filter or "", }, ) @@ -280,6 +322,34 @@ async def htmx_patterns( ) +# ── IP Insight (full IP page as partial) ───────────────────────────── + + +@router.get("/htmx/ip-insight/{ip_address:path}") +async def htmx_ip_insight(ip_address: str, request: Request): + db = get_db() + stats = db.get_ip_stats_by_ip(ip_address) + + if not stats: + stats = {"ip": ip_address, "total_requests": "N/A"} + + # Transform fields for template compatibility + list_on = stats.get("list_on") or {} + stats["blocklist_memberships"] = list(list_on.keys()) if list_on else [] + stats["reverse_dns"] = stats.get("reverse") + + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/ip_insight.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + "stats": stats, + "ip_address": ip_address, + }, + ) + + # ── IP Detail ──────────────────────────────────────────────────────── @@ -305,3 +375,33 @@ async def htmx_ip_detail(ip_address: str, request: Request): "stats": stats, }, ) + + +# ── Search ─────────────────────────────────────────────────────────── + + +@router.get("/htmx/search") +async def htmx_search( + request: Request, + q: str = Query(""), + page: int = Query(1), +): + q = q.strip() + if not q: + return Response(content="", media_type="text/html") + + db = get_db() + result = db.search_attacks_and_ips(query=q, page=max(1, page), page_size=20) + + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/search_results.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + "attacks": result["attacks"], + "ips": result["ips"], + "query": q, + "pagination": result["pagination"], + }, + ) diff --git a/src/tasks/analyze_ips.py b/src/tasks/analyze_ips.py index 295cd92..7095a13 100644 --- a/src/tasks/analyze_ips.py +++ b/src/tasks/analyze_ips.py @@ -70,7 +70,7 @@ def main(): "risky_http_methods": 6, "robots_violations": 4, "uneven_request_timing": 3, - "different_user_agents": 8, + "different_user_agents": 2, "attack_url": 15, }, "good_crawler": { @@ -84,7 +84,7 @@ def main(): "risky_http_methods": 2, "robots_violations": 7, "uneven_request_timing": 0, - "different_user_agents": 5, + "different_user_agents": 7, "attack_url": 5, }, "regular_user": { diff --git a/src/tasks/flag_stale_ips.py b/src/tasks/flag_stale_ips.py index a9e8e01..0428e15 100644 --- a/src/tasks/flag_stale_ips.py +++ b/src/tasks/flag_stale_ips.py @@ -9,24 +9,37 @@ TASK_CONFIG = { "name": "flag-stale-ips", "cron": "0 2 * * *", # Run daily at 2 AM "enabled": True, - "run_when_loaded": False, + "run_when_loaded": True, } +# Set to True to force all IPs to be flagged for reevaluation on next run. +# Resets to False automatically after execution. +FORCE_IP_RESCAN = False + def main(): + global FORCE_IP_RESCAN + app_logger = get_app_logger() db = get_database() try: - count = db.flag_stale_ips_for_reevaluation() - if count > 0: + if FORCE_IP_RESCAN: + count = db.flag_all_ips_for_reevaluation() + FORCE_IP_RESCAN = False app_logger.info( - f"[Background Task] flag-stale-ips: Flagged {count} stale IPs for reevaluation" + f"[Background Task] flag-stale-ips: FORCE RESCAN - Flagged {count} IPs for reevaluation" ) else: - app_logger.debug( - "[Background Task] flag-stale-ips: No stale IPs found to flag" - ) + count = db.flag_stale_ips_for_reevaluation() + if count > 0: + app_logger.info( + f"[Background Task] flag-stale-ips: Flagged {count} stale IPs for reevaluation" + ) + else: + app_logger.debug( + "[Background Task] flag-stale-ips: No stale IPs found to flag" + ) except Exception as e: app_logger.error( f"[Background Task] flag-stale-ips: Error flagging stale IPs: {e}" diff --git a/src/templates/jinja2/base.html b/src/templates/jinja2/base.html index 1ba2af5..22105c4 100644 --- a/src/templates/jinja2/base.html +++ b/src/templates/jinja2/base.html @@ -5,15 +5,15 @@ Krawl Dashboard - - - + + + - - - - - + + + + + diff --git a/src/templates/jinja2/dashboard/index.html b/src/templates/jinja2/dashboard/index.html index 5ec70f7..fef46c6 100644 --- a/src/templates/jinja2/dashboard/index.html +++ b/src/templates/jinja2/dashboard/index.html @@ -31,29 +31,45 @@ {# Stats cards - server-rendered #} {% include "dashboard/partials/stats_cards.html" %} + {# Search bar #} +
+ +
+
+ {# Tab navigation - Alpine.js #}
Overview Attacks + + IP Insight +
{# ==================== OVERVIEW TAB ==================== #} -
+
- {# Suspicious Activity - server-rendered #} + {# Map section #} + {% include "dashboard/partials/map_section.html" %} + + {# Suspicious Activity - server-rendered (last 10 requests) #} {% include "dashboard/partials/suspicious_table.html" %} - {# Honeypot Triggers - HTMX loaded #} -
-

Honeypot Triggers by IP

-
-
Loading...
-
-
- {# Top IPs + Top User-Agents side by side #}
@@ -91,9 +107,6 @@ {# ==================== ATTACKS TAB ==================== #}
- {# Map section #} - {% include "dashboard/partials/map_section.html" %} - {# Attackers table - HTMX loaded #}

Attackers by Total Requests

@@ -116,6 +129,17 @@
+ {# Honeypot Triggers - HTMX loaded #} +
+

Honeypot Triggers by IP

+
+
Loading...
+
+
+ {# Attack Types table #}

Detected Attack Types

@@ -147,6 +171,19 @@
+ {# ==================== IP INSIGHT TAB ==================== #} +
+ {# IP Insight content - loaded via HTMX when IP is selected #} +
+ +
+
+
+ {# Raw request modal - Alpine.js #} {% include "dashboard/partials/raw_request_modal.html" %} diff --git a/src/templates/jinja2/dashboard/ip.html b/src/templates/jinja2/dashboard/ip.html new file mode 100644 index 0000000..d09ad88 --- /dev/null +++ b/src/templates/jinja2/dashboard/ip.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} + +{% block content %} +
+ + {# GitHub logo #} + + + {# Back to dashboard link #} +
+ + ← Back to Dashboard + +
+ + {% set uid = "ip" %} + {% include "dashboard/partials/_ip_detail.html" %} + + {# Raw Request Modal #} +
+
+
+

Raw Request

+ × +
+
+

+            
+ +
+
+
+{% endblock %} diff --git a/src/templates/jinja2/dashboard/partials/_ip_detail.html b/src/templates/jinja2/dashboard/partials/_ip_detail.html new file mode 100644 index 0000000..1812b1d --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/_ip_detail.html @@ -0,0 +1,295 @@ +{# Shared IP detail content – included by ip.html and ip_insight.html. + Expects: stats, ip_address, dashboard_path, uid (unique prefix for element IDs) #} + +{# Page header #} +
+

+ {{ ip_address }} + {% if stats.category %} + + {{ stats.category | replace('_', ' ') | title }} + + {% endif %} +

+ {% if stats.city or stats.country %} +

+ {{ stats.city | default('') }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) }} +

+ {% endif %} +
+ +{# ── Two-column layout: Info + Radar/Timeline ───── #} +
+ {# Left column: single IP Information card #} +
+
+

IP Information

+ + {# Activity section #} +

Activity

+
+
+
Total Requests
+
{{ stats.total_requests | default('N/A') }}
+
+
+
First Seen
+
{{ stats.first_seen | format_ts }}
+
+
+
Last Seen
+
{{ stats.last_seen | format_ts }}
+
+ {% if stats.last_analysis %} +
+
Last Analysis
+
{{ stats.last_analysis | format_ts }}
+
+ {% endif %} +
+ + {# Geo & Network section #} +

Geo & Network

+
+ {% if stats.city or stats.country %} +
+
Location
+
{{ stats.city | default('') | e }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) | e }}
+
+ {% endif %} + {% if stats.region_name %} +
+
Region
+
{{ stats.region_name | e }}
+
+ {% endif %} + {% if stats.timezone %} +
+
Timezone
+
{{ stats.timezone | e }}
+
+ {% endif %} + {% if stats.isp %} +
+
ISP
+
{{ stats.isp | e }}
+
+ {% endif %} + {% if stats.asn_org %} +
+
Organization
+
{{ stats.asn_org | e }}
+
+ {% endif %} + {% if stats.asn %} +
+
ASN
+
AS{{ stats.asn }}
+
+ {% endif %} + {% if stats.reverse_dns %} +
+
Reverse DNS
+
{{ stats.reverse_dns | e }}
+
+ {% endif %} +
+ + {# Reputation section #} +

Reputation

+
+ {# Flags #} + {% set flags = [] %} + {% if stats.is_proxy %}{% set _ = flags.append('Proxy') %}{% endif %} + {% if stats.is_hosting %}{% set _ = flags.append('Hosting') %}{% endif %} + {% if flags %} +
+ Flags +
+ {% for flag in flags %} + {{ flag }} + {% endfor %} +
+
+ {% endif %} + + {# Blocklists #} +
+ Listed On + {% if stats.blocklist_memberships %} +
+ {% for bl in stats.blocklist_memberships %} + {{ bl | e }} + {% endfor %} +
+ {% else %} + Clean + {% endif %} +
+
+
+
+ + {# Right column: Category Analysis + Timeline + Attack Types #} +
+ {% if stats.category_scores %} +
+

Category Analysis

+
+
+
+
+ {% endif %} + + {# Bottom row: Behavior Timeline + Attack Types side by side #} +
+ {% if stats.category_history %} +
+

Behavior Timeline

+
+
+ {% for entry in stats.category_history %} +
+
+
+ {{ entry.new_category | default('unknown') | replace('_', ' ') | title }} + {% if entry.old_category %} + from {{ entry.old_category | replace('_', ' ') | title }} + {% else %} + initial classification + {% endif %} + {{ entry.timestamp | format_ts }} +
+
+ {% endfor %} +
+
+
+ {% endif %} + +
+

Attack Types

+
+ +
+
+
+
+
+ +{# Location map #} +{% if stats.latitude and stats.longitude %} +
+

Location

+
+
+{% endif %} + +{# Detected Attack Types table – only for attackers #} +{% if stats.category and stats.category | lower == 'attacker' %} +
+

Detected Attack Types

+
+
Loading...
+
+
+{% endif %} + +{# Access History table #} +
+

Access History

+
+
Loading...
+
+
+ +{# Inline init script #} + diff --git a/src/templates/jinja2/dashboard/partials/access_by_ip_table.html b/src/templates/jinja2/dashboard/partials/access_by_ip_table.html new file mode 100644 index 0000000..5e7bd6c --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/access_by_ip_table.html @@ -0,0 +1,63 @@ +{# HTMX fragment: Detected Access logs by ip table #} +
+ Page {{ pagination.page }}/{{ pagination.total_pages }} — {{ pagination.total }} total +
+ + +
+
+ + + + + + + + + + + + {% for log in items %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
#PathUser-Agent + Time +
{{ loop.index + (pagination.page - 1) * pagination.page_size }} +
+ {{ log.path | e }} + {% if log.path | length > 30 %} +
{{ log.path | e }}
+ {% endif %} +
+
{{ (log.user_agent | default(''))[:50] | e }}{{ log.timestamp | format_ts }} + {% if log.id %} + + {% endif %} +
No logs detected
diff --git a/src/templates/jinja2/dashboard/partials/attack_types_table.html b/src/templates/jinja2/dashboard/partials/attack_types_table.html index 8a74572..4ac3369 100644 --- a/src/templates/jinja2/dashboard/partials/attack_types_table.html +++ b/src/templates/jinja2/dashboard/partials/attack_types_table.html @@ -3,12 +3,12 @@ Page {{ pagination.page }}/{{ pagination.total_pages }} — {{ pagination.total }} total
@@ -23,12 +23,12 @@ Attack Types User-Agent Time - Actions + @@ -60,10 +60,13 @@ {{ (attack.user_agent | default(''))[:50] | e }} {{ attack.timestamp | format_ts }} - + {% if attack.log_id %} {% endif %} + @@ -74,7 +77,7 @@ {% else %} - No attacks detected + No attacks detected {% endfor %} diff --git a/src/templates/jinja2/dashboard/partials/attackers_table.html b/src/templates/jinja2/dashboard/partials/attackers_table.html index a235130..1bcbb40 100644 --- a/src/templates/jinja2/dashboard/partials/attackers_table.html +++ b/src/templates/jinja2/dashboard/partials/attackers_table.html @@ -36,6 +36,7 @@ hx-swap="innerHTML"> Last Seen Location + @@ -53,16 +54,21 @@ {{ ip.first_seen | format_ts }} {{ ip.last_seen | format_ts }} {{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }} + + + - +
Loading stats...
{% else %} - No attackers found + No attackers found {% endfor %} diff --git a/src/templates/jinja2/dashboard/partials/credentials_table.html b/src/templates/jinja2/dashboard/partials/credentials_table.html index ccfb364..c7ee193 100644 --- a/src/templates/jinja2/dashboard/partials/credentials_table.html +++ b/src/templates/jinja2/dashboard/partials/credentials_table.html @@ -28,6 +28,7 @@ hx-swap="innerHTML"> Time + @@ -45,16 +46,21 @@ {{ cred.password | default('N/A') | e }} {{ cred.path | default('') | e }} {{ cred.timestamp | format_ts }} + + + - +
Loading stats...
{% else %} - No credentials captured + No credentials captured {% endfor %} diff --git a/src/templates/jinja2/dashboard/partials/honeypot_table.html b/src/templates/jinja2/dashboard/partials/honeypot_table.html index 35676fc..302df69 100644 --- a/src/templates/jinja2/dashboard/partials/honeypot_table.html +++ b/src/templates/jinja2/dashboard/partials/honeypot_table.html @@ -25,6 +25,7 @@ hx-swap="innerHTML"> Honeypot Triggers + @@ -39,16 +40,21 @@ {{ item.ip | e }} {{ item.count }} + + + - +
Loading stats...
{% else %} - No data + No data {% endfor %} diff --git a/src/templates/jinja2/dashboard/partials/ip_insight.html b/src/templates/jinja2/dashboard/partials/ip_insight.html new file mode 100644 index 0000000..e7977b7 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/ip_insight.html @@ -0,0 +1,5 @@ +{# HTMX fragment: IP Insight - inline display within dashboard tabs #} +
+ {% set uid = "insight" %} + {% include "dashboard/partials/_ip_detail.html" %} +
diff --git a/src/templates/jinja2/dashboard/partials/patterns_table.html b/src/templates/jinja2/dashboard/partials/patterns_table.html index 260f31d..003f7e3 100644 --- a/src/templates/jinja2/dashboard/partials/patterns_table.html +++ b/src/templates/jinja2/dashboard/partials/patterns_table.html @@ -37,7 +37,7 @@ {{ pattern.count }} {% else %} - No patterns found + No patterns found {% endfor %} diff --git a/src/templates/jinja2/dashboard/partials/search_results.html b/src/templates/jinja2/dashboard/partials/search_results.html new file mode 100644 index 0000000..1ae0d41 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/search_results.html @@ -0,0 +1,164 @@ +{# HTMX fragment: Search results for attacks and IPs #} +
+ +
+ + Found {{ pagination.total_attacks }} attack{{ 's' if pagination.total_attacks != 1 else '' }} + and {{ pagination.total_ips }} IP{{ 's' if pagination.total_ips != 1 else '' }} + for “{{ query | e }}” + + +
+ + {# ── Matching IPs ─────────────────────────────────── #} + {% if ips %} +
+

Matching IPs

+ + + + + + + + + + + + + + + {% for ip in ips %} + + + + + + + + + + + + + + {% endfor %} + +
#IP AddressRequestsCategoryLocationISP / ASNLast Seen
{{ loop.index + (pagination.page - 1) * pagination.page_size }} + {{ ip.ip | e }} + {{ ip.total_requests }} + {% if ip.category %} + + {{ ip.category | e }} + + {% else %} + unknown + {% endif %} + {{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }}{{ ip.isp | default(ip.asn_org | default('N/A')) | e }}{{ ip.last_seen | format_ts }} + +
+
+ {% endif %} + + {# ── Matching Attacks ─────────────────────────────── #} + {% if attacks %} +
+

Matching Attacks

+ + + + + + + + + + + + + + {% for attack in attacks %} + + + + + + + + + + + + + {% endfor %} + +
#IP AddressPathAttack TypesUser-AgentTimeActions
{{ loop.index + (pagination.page - 1) * pagination.page_size }} + {{ attack.ip | e }} + +
+ {{ attack.path | e }} + {% if attack.path | length > 30 %} +
{{ attack.path | e }}
+ {% endif %} +
+
+
+ {% set types_str = attack.attack_types | join(', ') %} + {{ types_str | e }} + {% if types_str | length > 30 %} +
{{ types_str | e }}
+ {% endif %} +
+
{{ (attack.user_agent | default(''))[:50] | e }}{{ attack.timestamp | format_ts }} + {% if attack.log_id %} + + {% endif %} +
+
+ {% endif %} + + {# ── Pagination ───────────────────────────────────── #} + {% if pagination.total_pages > 1 %} +
+ Page {{ pagination.page }}/{{ pagination.total_pages }} +
+ + +
+
+ {% endif %} + + {# ── No results ───────────────────────────────────── #} + {% if not attacks and not ips %} +
+ No results found for “{{ query | e }}” +
+ {% endif %} + +
diff --git a/src/templates/jinja2/dashboard/partials/suspicious_table.html b/src/templates/jinja2/dashboard/partials/suspicious_table.html index 4884dec..333e8df 100644 --- a/src/templates/jinja2/dashboard/partials/suspicious_table.html +++ b/src/templates/jinja2/dashboard/partials/suspicious_table.html @@ -8,6 +8,7 @@ Path User-Agent Time + @@ -22,17 +23,22 @@ {{ activity.path | e }} {{ (activity.user_agent | default(''))[:80] | e }} - {{ activity.timestamp | format_ts }} + {{ activity.timestamp | format_ts(time_only=True) }} + + + - +
Loading stats...
{% else %} - No suspicious activity detected + No suspicious activity detected {% endfor %} diff --git a/src/templates/jinja2/dashboard/partials/top_ips_table.html b/src/templates/jinja2/dashboard/partials/top_ips_table.html index 84b335f..d4614c2 100644 --- a/src/templates/jinja2/dashboard/partials/top_ips_table.html +++ b/src/templates/jinja2/dashboard/partials/top_ips_table.html @@ -19,12 +19,14 @@ # IP Address + Category Access Count + @@ -38,17 +40,27 @@ @click="toggleIpDetail($event)"> {{ item.ip | e }} + + {% set cat = item.category | default('unknown') %} + {% set cat_colors = {'attacker': '#f85149', 'good_crawler': '#3fb950', 'bad_crawler': '#f0883e', 'regular_user': '#58a6ff', 'unknown': '#8b949e'} %} + + {{ item.count }} + + + - +
Loading stats...
{% else %} - No data + No data {% endfor %} diff --git a/src/templates/jinja2/dashboard/partials/top_paths_table.html b/src/templates/jinja2/dashboard/partials/top_paths_table.html index d1ec6d1..c102410 100644 --- a/src/templates/jinja2/dashboard/partials/top_paths_table.html +++ b/src/templates/jinja2/dashboard/partials/top_paths_table.html @@ -35,7 +35,7 @@ {{ item.count }} {% else %} - No data + No data {% endfor %} diff --git a/src/templates/jinja2/dashboard/partials/top_ua_table.html b/src/templates/jinja2/dashboard/partials/top_ua_table.html index faf487e..2026005 100644 --- a/src/templates/jinja2/dashboard/partials/top_ua_table.html +++ b/src/templates/jinja2/dashboard/partials/top_ua_table.html @@ -35,7 +35,7 @@ {{ item.count }} {% else %} - No data + No data {% endfor %} diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css index c7cd3a5..5074528 100644 --- a/src/templates/static/css/dashboard.css +++ b/src/templates/static/css/dashboard.css @@ -41,6 +41,8 @@ h1 { color: #58a6ff; text-align: center; margin-bottom: 40px; + font-weight: 900; + font-family: 'Google Sans Flex', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } .download-section { position: absolute; @@ -74,20 +76,21 @@ h1 { display: block; width: 100%; padding: 8px 14px; - background: #238636; - color: #ffffff; + background: rgba(35, 134, 54, 0.4); + color: rgba(255, 255, 255, 0.7); text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 13px; - transition: background 0.2s; - border: 1px solid #2ea043; + transition: background 0.2s, color 0.2s; + border: 1px solid rgba(46, 160, 67, 0.4); cursor: pointer; text-align: left; box-sizing: border-box; } .banlist-dropdown-btn:hover { - background: #2ea043; + background: rgba(46, 160, 67, 0.6); + color: #ffffff; } .banlist-dropdown-menu { display: none; @@ -189,8 +192,8 @@ tr:hover { font-weight: bold; } .alert-section { - background: #1c1917; - border-left: 4px solid #f85149; + background: #161b22; + border-left: 6px solid rgba(248, 81, 73, 0.4); } th.sortable { cursor: pointer; @@ -266,19 +269,20 @@ tbody { } .radar-chart { position: relative; - width: 220px; - height: 220px; + width: 280px; + height: 280px; overflow: visible; } .radar-legend { - margin-top: 10px; + margin-top: 0; font-size: 11px; + flex-shrink: 0; } .radar-legend-item { display: flex; align-items: center; gap: 6px; - margin: 3px 0; + margin: 4px 0; } .radar-legend-color { width: 12px; @@ -442,6 +446,373 @@ tbody { .timeline-marker.bad-crawler { background: #f0883e; } .timeline-marker.regular-user { background: #58a6ff; } .timeline-marker.unknown { background: #8b949e; } + +/* ── IP Insight Page Layout ─────────────────────── */ +.ip-insight-content { + animation: fadeIn 0.3s ease-in; +} +.ip-page-header { + margin-bottom: 20px; +} +.ip-page-header h1 { + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 4px 0; +} +.ip-address-title { + font-size: 28px; + font-weight: 700; + color: #e6edf3; + font-family: monospace; +} +.ip-location-subtitle { + color: #8b949e; + font-size: 14px; + margin: 4px 0 0 0; +} + +/* Quick stats bar */ +.ip-stats-bar { + display: flex; + gap: 12px; + margin-bottom: 20px; + flex-wrap: wrap; +} +.ip-stat-chip { + background: #161b22; + border: 1px solid #30363d; + border-radius: 8px; + padding: 12px 20px; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1 1 0; +} +.ip-stat-chip-value { + color: #e6edf3; + font-size: 16px; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ip-stat-chip-label { + color: #8b949e; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 500; +} + +/* Two-column grid */ +.ip-page-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + align-items: stretch; +} +.ip-page-left, +.ip-page-right { + display: flex; + flex-direction: column; + gap: 20px; + min-height: 0; +} +/* Left card fills column height */ +.ip-info-card { + flex: 1; + display: flex; + flex-direction: column; +} +/* Timeline card grows to fill remaining space */ +.ip-timeline-card { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +/* Detail cards */ +.ip-detail-card h2 { + margin-top: 0; + margin-bottom: 16px; +} +/* Remove bottom margin inside grid columns (gap handles spacing) */ +.ip-page-left .table-container, +.ip-page-right .table-container { + margin-bottom: 0; +} + +/* Definition list for IP info */ +.ip-dl { + margin: 0; + display: flex; + flex-direction: column; + gap: 0; +} +.ip-dl-row { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 8px 0; + border-bottom: 1px solid #21262d; + gap: 16px; +} +.ip-dl-row:last-child { + border-bottom: none; +} +.ip-dl dt { + color: #8b949e; + font-size: 13px; + font-weight: 500; + flex-shrink: 0; + min-width: 100px; +} +.ip-dl dd { + margin: 0; + color: #e6edf3; + font-size: 13px; + font-weight: 500; + text-align: right; + word-break: break-word; +} +.ip-dl-mono { + font-family: monospace; + font-size: 12px; +} + +/* Section headings inside IP info card */ +.ip-section-heading { + color: #e6edf3; + font-size: 15px; + font-weight: 700; + margin: 18px 0 8px 0; + padding: 0; +} +.ip-section-heading:first-of-type { + margin-top: 0; +} +/* Highlighted date values */ +.ip-dl-highlight { + color: #58a6ff; +} + +/* Scrollable reputation container */ +.ip-rep-scroll { + max-height: 200px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: #30363d #161b22; +} +.ip-rep-scroll::-webkit-scrollbar { + width: 6px; +} +.ip-rep-scroll::-webkit-scrollbar-track { + background: #161b22; + border-radius: 3px; +} +.ip-rep-scroll::-webkit-scrollbar-thumb { + background: #30363d; + border-radius: 3px; +} +.ip-rep-scroll::-webkit-scrollbar-thumb:hover { + background: #484f58; +} + +/* Scrollable behavior timeline – show ~5 entries max */ +.ip-timeline-scroll { + max-height: 230px; + overflow-y: auto; + min-height: 0; + scrollbar-width: thin; + scrollbar-color: #30363d #161b22; +} +.ip-timeline-scroll::-webkit-scrollbar { + width: 6px; +} +.ip-timeline-scroll::-webkit-scrollbar-track { + background: #161b22; + border-radius: 3px; +} +.ip-timeline-scroll::-webkit-scrollbar-thumb { + background: #30363d; + border-radius: 3px; +} +.ip-timeline-scroll::-webkit-scrollbar-thumb:hover { + background: #484f58; +} + +/* Reputation section */ +.ip-rep-row { + padding: 10px 0; + border-bottom: 1px solid #21262d; + display: flex; + align-items: flex-start; + gap: 16px; +} +.ip-rep-row:last-child { + border-bottom: none; +} +.ip-rep-label { + color: #8b949e; + font-size: 13px; + font-weight: 500; + flex-shrink: 0; + min-width: 80px; + padding-top: 2px; +} +.ip-rep-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +/* Flags & badges */ +.ip-flag { + display: inline-block; + background: #1c2128; + border: 1px solid #f0883e4d; + border-radius: 4px; + padding: 3px 10px; + font-size: 12px; + color: #f0883e; + font-weight: 500; +} +.reputation-score { + font-weight: 700; +} +.reputation-score.bad { color: #f85149; } +.reputation-score.medium { color: #f0883e; } +.reputation-score.good { color: #3fb950; } +.blocklist-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +/* Bottom row: Timeline + Attack Types side by side */ +.ip-bottom-row { + display: flex; + gap: 20px; + flex: 1; + min-height: 0; +} +.ip-bottom-row .ip-timeline-card { + flex: 1; + min-width: 0; +} +.ip-attack-types-card { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} +.ip-attack-chart-wrapper { + flex: 1; + position: relative; + min-height: 180px; +} + +/* Radar chart */ +.radar-chart-container { + display: flex; + align-items: center; + justify-content: center; + padding: 10px 0; +} + +/* ── Behavior Timeline (full-width horizontal) ──── */ +.ip-timeline-hz { + display: flex; + flex-direction: column; + gap: 0; + position: relative; + padding-left: 24px; +} +.ip-timeline-hz::before { + content: ''; + position: absolute; + left: 7px; + top: 8px; + bottom: 8px; + width: 2px; + background: #30363d; +} +.ip-tl-entry { + display: flex; + align-items: flex-start; + gap: 14px; + position: relative; + padding: 10px 0; +} +.ip-tl-entry:not(:last-child) { + border-bottom: 1px solid #161b22; +} +.ip-tl-dot { + width: 14px; + height: 14px; + border-radius: 50%; + flex-shrink: 0; + border: 2px solid #0d1117; + position: absolute; + left: -24px; + top: 12px; + z-index: 1; +} +.ip-tl-dot.attacker { background: #f85149; box-shadow: 0 0 6px #f8514980; } +.ip-tl-dot.good-crawler { background: #3fb950; box-shadow: 0 0 6px #3fb95080; } +.ip-tl-dot.bad-crawler { background: #f0883e; box-shadow: 0 0 6px #f0883e80; } +.ip-tl-dot.regular-user { background: #58a6ff; box-shadow: 0 0 6px #58a6ff80; } +.ip-tl-dot.unknown { background: #8b949e; } +.ip-tl-content { + display: flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; + min-width: 0; +} +.ip-tl-cat { + color: #e6edf3; + font-weight: 600; + font-size: 14px; +} +.ip-tl-from { + color: #8b949e; + font-size: 13px; +} +.ip-tl-time { + color: #484f58; + font-size: 12px; + margin-left: auto; + white-space: nowrap; +} + +/* Legacy compat (unused) */ + +@media (max-width: 900px) { + .ip-page-grid { + grid-template-columns: 1fr; + } + .ip-stats-bar { + flex-direction: column; + } + .ip-stat-chip { + flex: 1 1 auto; + } + .ip-bottom-row { + flex-direction: column; + } + .ip-tl-content { + flex-direction: column; + gap: 2px; + } + .ip-tl-time { + margin-left: 0; + } +} + .tabs-container { border-bottom: 1px solid #30363d; margin-bottom: 30px; @@ -474,6 +845,15 @@ tbody { color: #58a6ff; border-bottom-color: #58a6ff; } +.tab-button.disabled { + color: #484f58; + cursor: not-allowed; + opacity: 0.6; +} +.tab-button.disabled:hover { + color: #484f58; + background: transparent; +} .tab-content { display: none; } @@ -1210,6 +1590,27 @@ tbody { background: #30363d; border-color: #58a6ff; } +.inspect-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px; + background: none; + border: none; + color: #8b949e; + cursor: pointer; + border-radius: 4px; + transition: color 0.2s, background 0.2s; +} +.inspect-btn svg { + width: 16px; + height: 16px; + fill: currentColor; +} +.inspect-btn:hover { + color: #58a6ff; + background: rgba(88, 166, 255, 0.1); +} .pagination-btn { padding: 6px 14px; background: #21262d; @@ -1253,3 +1654,137 @@ tbody { [x-cloak] { display: none !important; } + +/* ── Search Bar ────────────────────────────────────── */ +.search-bar-container { + max-width: 100%; + margin: 0 0 20px 0; +} +.search-bar { + position: relative; + display: flex; + align-items: center; +} +.search-icon { + position: absolute; + left: 14px; + width: 18px; + height: 18px; + color: #8b949e; + pointer-events: none; +} +.search-bar input[type="search"] { + width: 100%; + padding: 12px 40px 12px 42px; + background: #0d1117; + border: 1px solid #30363d; + border-radius: 6px; + color: #c9d1d9; + font-size: 14px; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} +.search-bar input[type="search"]::placeholder { + color: #6e7681; +} +.search-bar input[type="search"]:focus { + border-color: #58a6ff; + box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15); +} +.search-bar input[type="search"]::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%238b949e'%3E%3Cpath d='M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z'/%3E%3C/svg%3E") center/contain no-repeat; + cursor: pointer; +} +.search-spinner { + position: absolute; + right: 14px; + width: 16px; + height: 16px; + padding: 0; + border: 2px solid #30363d; + border-top-color: #58a6ff; + border-radius: 50%; + animation: spin 0.6s linear infinite; +} +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ── Search Results ───────────────────────────────── */ +.search-results { + margin-top: 12px; + background: #161b22; + border: 1px solid #30363d; + border-radius: 6px; + padding: 16px; + animation: fadeIn 0.3s ease-in; +} +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} +.search-results-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 14px; + padding-bottom: 10px; + border-bottom: 1px solid #30363d; +} +.search-results-summary { + color: #8b949e; + font-size: 13px; +} +.search-results-summary strong { + color: #58a6ff; +} +.search-close-btn { + background: none; + border: none; + color: #8b949e; + font-size: 22px; + cursor: pointer; + padding: 0 4px; + line-height: 1; + transition: color 0.2s; +} +.search-close-btn:hover { + color: #f85149; +} +.search-section { + margin-bottom: 16px; +} +.search-section:last-of-type { + margin-bottom: 0; +} +.search-section-title { + color: #58a6ff; + font-size: 14px; + font-weight: 600; + margin: 0 0 8px 0; +} +.search-pagination { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid #30363d; +} +.search-no-results { + text-align: center; + color: #4a515a; + padding: 24px 0; + font-size: 14px; +} + +/* ── Empty State (no data rows) ───────────────────── */ +.empty-state { + text-align: center; + color: #4a515a; + padding: 20px 12px; +} diff --git a/src/templates/static/js/charts.js b/src/templates/static/js/charts.js index 93122bb..749019b 100644 --- a/src/templates/static/js/charts.js +++ b/src/templates/static/js/charts.js @@ -4,14 +4,25 @@ let attackTypesChart = null; let attackTypesChartLoaded = false; -async function loadAttackTypesChart() { +/** + * Load an attack types doughnut chart into a canvas element. + * @param {string} [canvasId='attack-types-chart'] - Canvas element ID + * @param {string} [ipFilter] - Optional IP address to scope results + * @param {string} [legendPosition='right'] - Legend position + */ +async function loadAttackTypesChart(canvasId, ipFilter, legendPosition) { + canvasId = canvasId || 'attack-types-chart'; + legendPosition = legendPosition || 'right'; const DASHBOARD_PATH = window.__DASHBOARD_PATH__ || ''; try { - const canvas = document.getElementById('attack-types-chart'); + const canvas = document.getElementById(canvasId); if (!canvas) return; - const response = await fetch(DASHBOARD_PATH + '/api/attack-types-stats?limit=10', { + let url = DASHBOARD_PATH + '/api/attack-types-stats?limit=10'; + if (ipFilter) url += '&ip_filter=' + encodeURIComponent(ipFilter); + + const response = await fetch(url, { cache: 'no-store', headers: { 'Cache-Control': 'no-cache', @@ -25,7 +36,7 @@ async function loadAttackTypesChart() { const attackTypes = data.attack_types || []; if (attackTypes.length === 0) { - canvas.style.display = 'none'; + canvas.parentElement.innerHTML = '
No attack data
'; return; } @@ -63,13 +74,14 @@ async function loadAttackTypesChart() { const borderColors = labels.map(label => generateColorFromHash(label).border); const hoverColors = labels.map(label => generateColorFromHash(label).hover); - // Create or update chart - if (attackTypesChart) { - attackTypesChart.destroy(); + // Create or update chart (track per canvas) + if (!loadAttackTypesChart._instances) loadAttackTypesChart._instances = {}; + if (loadAttackTypesChart._instances[canvasId]) { + loadAttackTypesChart._instances[canvasId].destroy(); } const ctx = canvas.getContext('2d'); - attackTypesChart = new Chart(ctx, { + const chartInstance = new Chart(ctx, { type: 'doughnut', data: { labels: labels, @@ -88,7 +100,7 @@ async function loadAttackTypesChart() { maintainAspectRatio: false, plugins: { legend: { - position: 'right', + position: legendPosition, labels: { color: '#c9d1d9', font: { @@ -160,6 +172,8 @@ async function loadAttackTypesChart() { }] }); + loadAttackTypesChart._instances[canvasId] = chartInstance; + attackTypesChart = chartInstance; attackTypesChartLoaded = true; } catch (err) { console.error('Error loading attack types chart:', err); diff --git a/src/templates/static/js/dashboard.js b/src/templates/static/js/dashboard.js index b74a51d..e6e848b 100644 --- a/src/templates/static/js/dashboard.js +++ b/src/templates/static/js/dashboard.js @@ -17,19 +17,26 @@ document.addEventListener('alpine:init', () => { // Chart state chartLoaded: false, + // IP Insight state + insightIp: null, + init() { // Handle hash-based tab routing const hash = window.location.hash.slice(1); if (hash === 'ip-stats' || hash === 'attacks') { this.switchToAttacks(); } + // ip-insight tab is only accessible via lens buttons, not direct hash navigation window.addEventListener('hashchange', () => { const h = window.location.hash.slice(1); if (h === 'ip-stats' || h === 'attacks') { this.switchToAttacks(); - } else { - this.switchToOverview(); + } else if (h !== 'ip-insight') { + // Don't switch away from ip-insight via hash if already there + if (this.tab !== 'ip-insight') { + this.switchToOverview(); + } } }); }, @@ -38,15 +45,9 @@ document.addEventListener('alpine:init', () => { this.tab = 'attacks'; window.location.hash = '#ip-stats'; - // Delay initialization to ensure the container is visible and - // the browser has reflowed after x-show removes display:none. - // Leaflet and Chart.js need visible containers with real dimensions. + // Delay chart initialization to ensure the container is visible this.$nextTick(() => { setTimeout(() => { - if (!this.mapInitialized && typeof initializeAttackerMap === 'function') { - initializeAttackerMap(); - this.mapInitialized = true; - } if (!this.chartLoaded && typeof loadAttackTypesChart === 'function') { loadAttackTypesChart(); this.chartLoaded = true; @@ -60,6 +61,31 @@ document.addEventListener('alpine:init', () => { window.location.hash = '#overview'; }, + switchToIpInsight() { + // Only allow switching if an IP is selected + if (!this.insightIp) return; + this.tab = 'ip-insight'; + window.location.hash = '#ip-insight'; + }, + + openIpInsight(ip) { + // Set the IP and load the insight content + this.insightIp = ip; + this.tab = 'ip-insight'; + window.location.hash = '#ip-insight'; + + // Load IP insight content via HTMX + this.$nextTick(() => { + const container = document.getElementById('ip-insight-htmx-container'); + if (container && typeof htmx !== 'undefined') { + htmx.ajax('GET', `${this.dashboardPath}/htmx/ip-insight/${encodeURIComponent(ip)}`, { + target: '#ip-insight-htmx-container', + swap: 'innerHTML' + }); + } + }); + }, + async viewRawRequest(logId) { try { const resp = await fetch( @@ -110,6 +136,19 @@ document.addEventListener('alpine:init', () => { })); }); +// Global function for opening IP Insight (used by map popups) +window.openIpInsight = function(ip) { + // Find the Alpine component and call openIpInsight + const container = document.querySelector('[x-data="dashboardApp()"]'); + if (container) { + // Try Alpine 3.x API first, then fall back to older API + const data = Alpine.$data ? Alpine.$data(container) : (container._x_dataStack && container._x_dataStack[0]); + if (data && typeof data.openIpInsight === 'function') { + data.openIpInsight(ip); + } + } +}; + // Utility function for formatting timestamps (used by map popups) function formatTimestamp(isoTimestamp) { if (!isoTimestamp) return 'N/A'; diff --git a/src/templates/static/js/map.js b/src/templates/static/js/map.js index 5181295..1350bb9 100644 --- a/src/templates/static/js/map.js +++ b/src/templates/static/js/map.js @@ -36,14 +36,45 @@ function createClusterIcon(cluster) { gradientStops.push(`${color} ${start.toFixed(1)}deg ${end.toFixed(1)}deg`); }); - const size = Math.max(32, Math.min(50, 32 + Math.log2(total) * 5)); - const inner = size - 10; - const offset = 5; // (size - inner) / 2 + const size = Math.max(20, Math.min(44, 20 + Math.log2(total) * 4)); + const centerSize = size - 8; + const centerOffset = 4; + const ringWidth = 4; + const radius = (size / 2) - (ringWidth / 2); + const cx = size / 2; + const cy = size / 2; + const gapDeg = 8; + + // Build SVG arc segments with gaps - glow layer first, then sharp layer + let glowSegments = ''; + let segments = ''; + let currentAngle = -90; + sorted.forEach(([cat, count], idx) => { + const sliceDeg = (count / total) * 360; + if (sliceDeg < gapDeg) return; + const startAngle = currentAngle + (gapDeg / 2); + const endAngle = currentAngle + sliceDeg - (gapDeg / 2); + const startRad = (startAngle * Math.PI) / 180; + const endRad = (endAngle * Math.PI) / 180; + const x1 = cx + radius * Math.cos(startRad); + const y1 = cy + radius * Math.sin(startRad); + const x2 = cx + radius * Math.cos(endRad); + const y2 = cy + radius * Math.sin(endRad); + const largeArc = (endAngle - startAngle) > 180 ? 1 : 0; + const color = categoryColors[cat] || '#8b949e'; + // Glow layer - subtle + glowSegments += ``; + // Sharp layer + segments += ``; + currentAngle += sliceDeg; + }); return L.divIcon({ html: `
` + - `
` + - `
${total}
` + + `` + + `` + + `${glowSegments}${segments}` + + `
${total}
` + `
`, className: 'ip-cluster-icon', iconSize: L.point(size, size) @@ -180,11 +211,11 @@ function buildMapMarkers(ips) { // Single cluster group with custom pie-chart icons clusterGroup = L.markerClusterGroup({ - maxClusterRadius: 20, + maxClusterRadius: 35, spiderfyOnMaxZoom: true, showCoverageOnHover: false, zoomToBoundsOnClick: true, - disableClusteringAtZoom: 10, + disableClusteringAtZoom: 8, iconCreateFunction: createClusterIcon }); @@ -284,8 +315,13 @@ function buildMapMarkers(ips) { let popupContent = `
-
+
${ip.ip} + +
+
${categoryLabels[category]} @@ -315,8 +351,13 @@ function buildMapMarkers(ips) { console.error('Error fetching IP stats:', err); const errorPopup = `
-
+
${ip.ip} + +
+
${categoryLabels[category]} diff --git a/src/templates/static/js/radar.js b/src/templates/static/js/radar.js index f531046..fbe4974 100644 --- a/src/templates/static/js/radar.js +++ b/src/templates/static/js/radar.js @@ -11,11 +11,13 @@ * @param {Object} categoryScores - Object with keys: attacker, good_crawler, bad_crawler, regular_user, unknown * @param {number} [size=200] - Width/height of the SVG in pixels * @param {boolean} [showLegend=true] - Whether to show the legend below the chart + * @param {string} [legendPosition='below'] - 'below' or 'side' (side = legend to the right of the chart) * @returns {string} HTML string containing the SVG radar chart */ -function generateRadarChart(categoryScores, size, showLegend) { +function generateRadarChart(categoryScores, size, showLegend, legendPosition) { size = size || 200; if (showLegend === undefined) showLegend = true; + legendPosition = legendPosition || 'below'; if (!categoryScores || Object.keys(categoryScores).length === 0) { return '
No category data available
'; @@ -55,7 +57,8 @@ function generateRadarChart(categoryScores, size, showLegend) { const cx = 100, cy = 100, maxRadius = 75; - let html = '
'; + const flexDir = legendPosition === 'side' ? 'row' : 'column'; + let html = `
`; html += ``; // Draw concentric circles (grid) diff --git a/src/templates/static/vendor/css/MarkerCluster.Default.css b/src/templates/static/vendor/css/MarkerCluster.Default.css new file mode 100644 index 0000000..bbc8c9f --- /dev/null +++ b/src/templates/static/vendor/css/MarkerCluster.Default.css @@ -0,0 +1,60 @@ +.marker-cluster-small { + background-color: rgba(181, 226, 140, 0.6); + } +.marker-cluster-small div { + background-color: rgba(110, 204, 57, 0.6); + } + +.marker-cluster-medium { + background-color: rgba(241, 211, 87, 0.6); + } +.marker-cluster-medium div { + background-color: rgba(240, 194, 12, 0.6); + } + +.marker-cluster-large { + background-color: rgba(253, 156, 115, 0.6); + } +.marker-cluster-large div { + background-color: rgba(241, 128, 23, 0.6); + } + + /* IE 6-8 fallback colors */ +.leaflet-oldie .marker-cluster-small { + background-color: rgb(181, 226, 140); + } +.leaflet-oldie .marker-cluster-small div { + background-color: rgb(110, 204, 57); + } + +.leaflet-oldie .marker-cluster-medium { + background-color: rgb(241, 211, 87); + } +.leaflet-oldie .marker-cluster-medium div { + background-color: rgb(240, 194, 12); + } + +.leaflet-oldie .marker-cluster-large { + background-color: rgb(253, 156, 115); + } +.leaflet-oldie .marker-cluster-large div { + background-color: rgb(241, 128, 23); +} + +.marker-cluster { + background-clip: padding-box; + border-radius: 20px; + } +.marker-cluster div { + width: 30px; + height: 30px; + margin-left: 5px; + margin-top: 5px; + + text-align: center; + border-radius: 15px; + font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif; + } +.marker-cluster span { + line-height: 30px; + } \ No newline at end of file diff --git a/src/templates/static/vendor/css/MarkerCluster.css b/src/templates/static/vendor/css/MarkerCluster.css new file mode 100644 index 0000000..c60d71b --- /dev/null +++ b/src/templates/static/vendor/css/MarkerCluster.css @@ -0,0 +1,14 @@ +.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow { + -webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in; + -moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in; + -o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in; + transition: transform 0.3s ease-out, opacity 0.3s ease-in; +} + +.leaflet-cluster-spider-leg { + /* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */ + -webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in; + -moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in; + -o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in; + transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in; +} diff --git a/src/templates/static/vendor/css/images/layers-2x.png b/src/templates/static/vendor/css/images/layers-2x.png new file mode 100644 index 0000000..200c333 Binary files /dev/null and b/src/templates/static/vendor/css/images/layers-2x.png differ diff --git a/src/templates/static/vendor/css/images/layers.png b/src/templates/static/vendor/css/images/layers.png new file mode 100644 index 0000000..1a72e57 Binary files /dev/null and b/src/templates/static/vendor/css/images/layers.png differ diff --git a/src/templates/static/vendor/css/images/marker-icon-2x.png b/src/templates/static/vendor/css/images/marker-icon-2x.png new file mode 100644 index 0000000..88f9e50 Binary files /dev/null and b/src/templates/static/vendor/css/images/marker-icon-2x.png differ diff --git a/src/templates/static/vendor/css/images/marker-icon.png b/src/templates/static/vendor/css/images/marker-icon.png new file mode 100644 index 0000000..950edf2 Binary files /dev/null and b/src/templates/static/vendor/css/images/marker-icon.png differ diff --git a/src/templates/static/vendor/css/images/marker-shadow.png b/src/templates/static/vendor/css/images/marker-shadow.png new file mode 100644 index 0000000..9fd2979 Binary files /dev/null and b/src/templates/static/vendor/css/images/marker-shadow.png differ diff --git a/src/templates/static/vendor/css/leaflet.min.css b/src/templates/static/vendor/css/leaflet.min.css new file mode 100644 index 0000000..d9ee57d --- /dev/null +++ b/src/templates/static/vendor/css/leaflet.min.css @@ -0,0 +1 @@ +.leaflet-image-layer,.leaflet-layer,.leaflet-marker-icon,.leaflet-marker-shadow,.leaflet-pane,.leaflet-pane>canvas,.leaflet-pane>svg,.leaflet-tile,.leaflet-tile-container,.leaflet-zoom-box{position:absolute;left:0;top:0}.leaflet-container{overflow:hidden}.leaflet-marker-icon,.leaflet-marker-shadow,.leaflet-tile{-webkit-user-select:none;-moz-user-select:none;user-select:none;-webkit-user-drag:none}.leaflet-tile::selection{background:0 0}.leaflet-safari .leaflet-tile{image-rendering:-webkit-optimize-contrast}.leaflet-safari .leaflet-tile-container{width:1600px;height:1600px;-webkit-transform-origin:0 0}.leaflet-marker-icon,.leaflet-marker-shadow{display:block}.leaflet-container .leaflet-overlay-pane svg{max-width:none!important;max-height:none!important}.leaflet-container .leaflet-marker-pane img,.leaflet-container .leaflet-shadow-pane img,.leaflet-container .leaflet-tile,.leaflet-container .leaflet-tile-pane img,.leaflet-container img.leaflet-image-layer{max-width:none!important;max-height:none!important;width:auto;padding:0}.leaflet-container img.leaflet-tile{mix-blend-mode:plus-lighter}.leaflet-container.leaflet-touch-zoom{-ms-touch-action:pan-x pan-y;touch-action:pan-x pan-y}.leaflet-container.leaflet-touch-drag{-ms-touch-action:pinch-zoom;touch-action:none;touch-action:pinch-zoom}.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom{-ms-touch-action:none;touch-action:none}.leaflet-container{-webkit-tap-highlight-color:transparent}.leaflet-container a{-webkit-tap-highlight-color:rgba(51,181,229,.4)}.leaflet-tile{filter:inherit;visibility:hidden}.leaflet-tile-loaded{visibility:inherit}.leaflet-zoom-box{width:0;height:0;-moz-box-sizing:border-box;box-sizing:border-box;z-index:800}.leaflet-overlay-pane svg{-moz-user-select:none}.leaflet-pane{z-index:400}.leaflet-tile-pane{z-index:200}.leaflet-overlay-pane{z-index:400}.leaflet-shadow-pane{z-index:500}.leaflet-marker-pane{z-index:600}.leaflet-tooltip-pane{z-index:650}.leaflet-popup-pane{z-index:700}.leaflet-map-pane canvas{z-index:100}.leaflet-map-pane svg{z-index:200}.leaflet-vml-shape{width:1px;height:1px}.lvml{behavior:url(#default#VML);display:inline-block;position:absolute}.leaflet-control{position:relative;z-index:800;pointer-events:visiblePainted;pointer-events:auto}.leaflet-bottom,.leaflet-top{position:absolute;z-index:1000;pointer-events:none}.leaflet-top{top:0}.leaflet-right{right:0}.leaflet-bottom{bottom:0}.leaflet-left{left:0}.leaflet-control{float:left;clear:both}.leaflet-right .leaflet-control{float:right}.leaflet-top .leaflet-control{margin-top:10px}.leaflet-bottom .leaflet-control{margin-bottom:10px}.leaflet-left .leaflet-control{margin-left:10px}.leaflet-right .leaflet-control{margin-right:10px}.leaflet-fade-anim .leaflet-popup{opacity:0;-webkit-transition:opacity .2s linear;-moz-transition:opacity .2s linear;transition:opacity .2s linear}.leaflet-fade-anim .leaflet-map-pane .leaflet-popup{opacity:1}.leaflet-zoom-animated{-webkit-transform-origin:0 0;-ms-transform-origin:0 0;transform-origin:0 0}svg.leaflet-zoom-animated{will-change:transform}.leaflet-zoom-anim .leaflet-zoom-animated{-webkit-transition:-webkit-transform .25s cubic-bezier(0,0,.25,1);-moz-transition:-moz-transform .25s cubic-bezier(0,0,.25,1);transition:transform .25s cubic-bezier(0,0,.25,1)}.leaflet-pan-anim .leaflet-tile,.leaflet-zoom-anim .leaflet-tile{-webkit-transition:none;-moz-transition:none;transition:none}.leaflet-zoom-anim .leaflet-zoom-hide{visibility:hidden}.leaflet-interactive{cursor:pointer}.leaflet-grab{cursor:-webkit-grab;cursor:-moz-grab;cursor:grab}.leaflet-crosshair,.leaflet-crosshair .leaflet-interactive{cursor:crosshair}.leaflet-control,.leaflet-popup-pane{cursor:auto}.leaflet-dragging .leaflet-grab,.leaflet-dragging .leaflet-grab .leaflet-interactive,.leaflet-dragging .leaflet-marker-draggable{cursor:move;cursor:-webkit-grabbing;cursor:-moz-grabbing;cursor:grabbing}.leaflet-image-layer,.leaflet-marker-icon,.leaflet-marker-shadow,.leaflet-pane>svg path,.leaflet-tile-container{pointer-events:none}.leaflet-image-layer.leaflet-interactive,.leaflet-marker-icon.leaflet-interactive,.leaflet-pane>svg path.leaflet-interactive,svg.leaflet-image-layer.leaflet-interactive path{pointer-events:visiblePainted;pointer-events:auto}.leaflet-container{background:#ddd;outline-offset:1px}.leaflet-container a{color:#0078a8}.leaflet-zoom-box{border:2px dotted #38f;background:rgba(255,255,255,.5)}.leaflet-container{font-family:"Helvetica Neue",Arial,Helvetica,sans-serif;font-size:12px;font-size:.75rem;line-height:1.5}.leaflet-bar{box-shadow:0 1px 5px rgba(0,0,0,.65);border-radius:4px}.leaflet-bar a{background-color:#fff;border-bottom:1px solid #ccc;width:26px;height:26px;line-height:26px;display:block;text-align:center;text-decoration:none;color:#000}.leaflet-bar a,.leaflet-control-layers-toggle{background-position:50% 50%;background-repeat:no-repeat;display:block}.leaflet-bar a:focus,.leaflet-bar a:hover{background-color:#f4f4f4}.leaflet-bar a:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.leaflet-bar a:last-child{border-bottom-left-radius:4px;border-bottom-right-radius:4px;border-bottom:none}.leaflet-bar a.leaflet-disabled{cursor:default;background-color:#f4f4f4;color:#bbb}.leaflet-touch .leaflet-bar a{width:30px;height:30px;line-height:30px}.leaflet-touch .leaflet-bar a:first-child{border-top-left-radius:2px;border-top-right-radius:2px}.leaflet-touch .leaflet-bar a:last-child{border-bottom-left-radius:2px;border-bottom-right-radius:2px}.leaflet-control-zoom-in,.leaflet-control-zoom-out{font:bold 18px 'Lucida Console',Monaco,monospace;text-indent:1px}.leaflet-touch .leaflet-control-zoom-in,.leaflet-touch .leaflet-control-zoom-out{font-size:22px}.leaflet-control-layers{box-shadow:0 1px 5px rgba(0,0,0,.4);background:#fff;border-radius:5px}.leaflet-control-layers-toggle{background-image:url(images/layers.png);width:36px;height:36px}.leaflet-retina .leaflet-control-layers-toggle{background-image:url(images/layers-2x.png);background-size:26px 26px}.leaflet-touch .leaflet-control-layers-toggle{width:44px;height:44px}.leaflet-control-layers .leaflet-control-layers-list,.leaflet-control-layers-expanded .leaflet-control-layers-toggle{display:none}.leaflet-control-layers-expanded .leaflet-control-layers-list{display:block;position:relative}.leaflet-control-layers-expanded{padding:6px 10px 6px 6px;color:#333;background:#fff}.leaflet-control-layers-scrollbar{overflow-y:scroll;overflow-x:hidden;padding-right:5px}.leaflet-control-layers-selector{margin-top:2px;position:relative;top:1px}.leaflet-control-layers label{display:block;font-size:13px;font-size:1.08333em}.leaflet-control-layers-separator{height:0;border-top:1px solid #ddd;margin:5px -10px 5px -6px}.leaflet-default-icon-path{background-image:url(images/marker-icon.png)}.leaflet-container .leaflet-control-attribution{background:#fff;background:rgba(255,255,255,.8);margin:0}.leaflet-control-attribution,.leaflet-control-scale-line{padding:0 5px;color:#333;line-height:1.4}.leaflet-control-attribution a{text-decoration:none}.leaflet-control-attribution a:focus,.leaflet-control-attribution a:hover{text-decoration:underline}.leaflet-attribution-flag{display:inline!important;vertical-align:baseline!important;width:1em;height:.6669em}.leaflet-left .leaflet-control-scale{margin-left:5px}.leaflet-bottom .leaflet-control-scale{margin-bottom:5px}.leaflet-control-scale-line{border:2px solid #777;border-top:none;line-height:1.1;padding:2px 5px 1px;white-space:nowrap;-moz-box-sizing:border-box;box-sizing:border-box;background:rgba(255,255,255,.8);text-shadow:1px 1px #fff}.leaflet-control-scale-line:not(:first-child){border-top:2px solid #777;border-bottom:none;margin-top:-2px}.leaflet-control-scale-line:not(:first-child):not(:last-child){border-bottom:2px solid #777}.leaflet-touch .leaflet-bar,.leaflet-touch .leaflet-control-attribution,.leaflet-touch .leaflet-control-layers{box-shadow:none}.leaflet-touch .leaflet-bar,.leaflet-touch .leaflet-control-layers{border:2px solid rgba(0,0,0,.2);background-clip:padding-box}.leaflet-popup{position:absolute;text-align:center;margin-bottom:20px}.leaflet-popup-content-wrapper{padding:1px;text-align:left;border-radius:12px}.leaflet-popup-content{margin:13px 24px 13px 20px;line-height:1.3;font-size:13px;font-size:1.08333em;min-height:1px}.leaflet-popup-content p{margin:17px 0;margin:1.3em 0}.leaflet-popup-tip-container{width:40px;height:20px;position:absolute;left:50%;margin-top:-1px;margin-left:-20px;overflow:hidden;pointer-events:none}.leaflet-popup-tip{width:17px;height:17px;padding:1px;margin:-10px auto 0;pointer-events:auto;-webkit-transform:rotate(45deg);-moz-transform:rotate(45deg);-ms-transform:rotate(45deg);transform:rotate(45deg)}.leaflet-popup-content-wrapper,.leaflet-popup-tip{background:#fff;color:#333;box-shadow:0 3px 14px rgba(0,0,0,.4)}.leaflet-container a.leaflet-popup-close-button{position:absolute;top:0;right:0;border:none;text-align:center;width:24px;height:24px;font:16px/24px Tahoma,Verdana,sans-serif;color:#757575;text-decoration:none;background:0 0}.leaflet-container a.leaflet-popup-close-button:focus,.leaflet-container a.leaflet-popup-close-button:hover{color:#585858}.leaflet-popup-scrolled{overflow:auto}.leaflet-oldie .leaflet-popup-content-wrapper{-ms-zoom:1}.leaflet-oldie .leaflet-popup-tip{width:24px;margin:0 auto}.leaflet-oldie .leaflet-control-layers,.leaflet-oldie .leaflet-control-zoom,.leaflet-oldie .leaflet-popup-content-wrapper,.leaflet-oldie .leaflet-popup-tip{border:1px solid #999}.leaflet-div-icon{background:#fff;border:1px solid #666}.leaflet-tooltip{position:absolute;padding:6px;background-color:#fff;border:1px solid #fff;border-radius:3px;color:#222;white-space:nowrap;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;pointer-events:none;box-shadow:0 1px 3px rgba(0,0,0,.4)}.leaflet-tooltip.leaflet-interactive{cursor:pointer;pointer-events:auto}.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before,.leaflet-tooltip-top:before{position:absolute;pointer-events:none;border:6px solid transparent;background:0 0;content:""}.leaflet-tooltip-bottom{margin-top:6px}.leaflet-tooltip-top{margin-top:-6px}.leaflet-tooltip-bottom:before,.leaflet-tooltip-top:before{left:50%;margin-left:-6px}.leaflet-tooltip-top:before{bottom:0;margin-bottom:-12px;border-top-color:#fff}.leaflet-tooltip-bottom:before{top:0;margin-top:-12px;margin-left:-6px;border-bottom-color:#fff}.leaflet-tooltip-left{margin-left:-6px}.leaflet-tooltip-right{margin-left:6px}.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{top:50%;margin-top:-6px}.leaflet-tooltip-left:before{right:0;margin-right:-12px;border-left-color:#fff}.leaflet-tooltip-right:before{left:0;margin-left:-12px;border-right-color:#fff}@media print{.leaflet-control{-webkit-print-color-adjust:exact;print-color-adjust:exact}} \ No newline at end of file diff --git a/src/templates/static/vendor/js/alpine.min.js b/src/templates/static/vendor/js/alpine.min.js new file mode 100644 index 0000000..a3be81c --- /dev/null +++ b/src/templates/static/vendor/js/alpine.min.js @@ -0,0 +1,5 @@ +(()=>{var nt=!1,it=!1,W=[],ot=-1;function Ut(e){Rn(e)}function Rn(e){W.includes(e)||W.push(e),Mn()}function Wt(e){let t=W.indexOf(e);t!==-1&&t>ot&&W.splice(t,1)}function Mn(){!it&&!nt&&(nt=!0,queueMicrotask(Nn))}function Nn(){nt=!1,it=!0;for(let e=0;ee.effect(t,{scheduler:r=>{st?Ut(r):r()}}),at=e.raw}function ct(e){N=e}function Yt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),$(i))},i},()=>{t()}]}function ve(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>$(i)}var Xt=[],Zt=[],Qt=[];function er(e){Qt.push(e)}function te(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Zt.push(t))}function Ae(e){Xt.push(e)}function Oe(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function lt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function tr(e){for(e._x_effects?.forEach(Wt);e._x_cleanups?.length;)e._x_cleanups.pop()()}var ut=new MutationObserver(mt),ft=!1;function ue(){ut.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ft=!0}function dt(){kn(),ut.disconnect(),ft=!1}var le=[];function kn(){let e=ut.takeRecords();le.push(()=>e.length>0&&mt(e));let t=le.length;queueMicrotask(()=>{if(le.length===t)for(;le.length>0;)le.shift()()})}function m(e){if(!ft)return e();dt();let t=e();return ue(),t}var pt=!1,Se=[];function rr(){pt=!0}function nr(){pt=!1,mt(Se),Se=[]}function mt(e){if(pt){Se=Se.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{lt(s,o)}),n.forEach((o,s)=>{Xt.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Zt.forEach(s=>s(o));for(let o of t)o.isConnected&&Qt.forEach(s=>s(o));t=null,r=null,n=null,i=null}function Ce(e){return z(B(e))}function k(e,t,r){return e._x_dataStack=[t,...B(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function B(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?B(e.host):e.parentNode?B(e.parentNode):[]}function z(e){return new Proxy({objects:e},Dn)}var Dn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Pn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Pn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Te(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Re(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>In(n,i),s=>ht(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function In(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function ht(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),ht(e[t[0]],t.slice(1),r)}}var ir={};function y(e,t){ir[e]=t}function fe(e,t){let r=Ln(t);return Object.entries(ir).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function Ln(e){let[t,r]=_t(e),n={interceptor:Re,...t};return te(e,r),n}function or(e,t,r,...n){try{return r(...n)}catch(i){re(i,e,t)}}function re(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${r?'Expression: "'+r+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var Me=!0;function ke(e){let t=Me;Me=!1;let r=e();return Me=t,r}function R(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return sr(...e)}var sr=xt;function ar(e){sr=e}function xt(e,t){let r={};fe(r,e);let n=[r,...B(e)],i=typeof t=="function"?$n(n,t):Fn(n,t,e);return or.bind(null,e,t,i)}function $n(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(z([n,...e]),i);Ne(r,o)}}var gt={};function jn(e,t){if(gt[e])return gt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return re(s,t,e),Promise.resolve()}})();return gt[e]=o,o}function Fn(e,t,r){let n=jn(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=z([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>re(l,r,t));n.finished?(Ne(i,n.result,a,s,r),n.result=void 0):c.then(l=>{Ne(i,l,a,s,r)}).catch(l=>re(l,r,t)).finally(()=>n.result=void 0)}}}function Ne(e,t,r,n,i){if(Me&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Ne(e,s,r,n)).catch(s=>re(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var wt="x-";function C(e=""){return wt+e}function cr(e){wt=e}var De={};function d(e,t){return De[e]=t,{before(r){if(!De[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,e)}}}function lr(e){return Object.keys(De).includes(e)}function pe(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Et(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(dr((o,s)=>n[o]=s)).filter(mr).map(zn(n,r)).sort(Kn).map(o=>Bn(e,o))}function Et(e){return Array.from(e).map(dr()).filter(t=>!mr(t))}var yt=!1,de=new Map,ur=Symbol();function fr(e){yt=!0;let t=Symbol();ur=t,de.set(t,[]);let r=()=>{for(;de.get(t).length;)de.get(t).shift()();de.delete(t)},n=()=>{yt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Yt(e);return t.push(i),[{Alpine:K,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:R.bind(R,e)},()=>t.forEach(a=>a())]}function Bn(e,t){let r=()=>{},n=De[t.type]||r,[i,o]=_t(e);Oe(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),yt?de.get(ur).push(n):n())};return s.runCleanups=o,s}var Pe=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Ie=e=>e;function dr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=pr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var pr=[];function ne(e){pr.push(e)}function mr({name:e}){return hr().test(e)}var hr=()=>new RegExp(`^${wt}([^:^.]+)\\b`);function zn(e,t){return({name:r,value:n})=>{let i=r.match(hr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var bt="DEFAULT",G=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",bt,"teleport"];function Kn(e,t){let r=G.indexOf(e.type)===-1?bt:e.type,n=G.indexOf(t.type)===-1?bt:t.type;return G.indexOf(r)-G.indexOf(n)}function J(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function D(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>D(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)D(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var _r=!1;function gr(){_r&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),_r=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `