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/kubernetes/krawl-all-in-one-deploy.yaml b/kubernetes/krawl-all-in-one-deploy.yaml index 767c080..99e30fc 100644 --- a/kubernetes/krawl-all-in-one-deploy.yaml +++ b/kubernetes/krawl-all-in-one-deploy.yaml @@ -154,6 +154,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/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/database.py b/src/database.py index 5b8d685..6bd282b 100644 --- a/src/database.py +++ b/src/database.py @@ -850,6 +850,72 @@ class DatabaseManager: 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 +1084,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, @@ -1687,14 +1755,23 @@ class DatabaseManager: offset = (page - 1) * page_size results = ( - session.query(AccessLog.ip, func.count(AccessLog.id).label("count")) - .group_by(AccessLog.ip) + session.query( + AccessLog.ip, + func.count(AccessLog.id).label("count"), + IpStats.category, + ) + .outerjoin(IpStats, AccessLog.ip == IpStats.ip) + .group_by(AccessLog.ip, IpStats.category) .all() ) # Filter out local/private IPs and server IP, then sort filtered = [ - {"ip": row.ip, "count": row.count} + { + "ip": row.ip, + "count": row.count, + "category": row.category or "unknown", + } for row in results if is_valid_public_ip(row.ip, server_ip) ] diff --git a/src/routes/api.py b/src/routes/api.py index 02b52dc..a4e6a7a 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 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 0023598..976fc35 100644 --- a/src/routes/htmx.py +++ b/src/routes/htmx.py @@ -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 ────────────────────────────────────────────────────── @@ -280,6 +316,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 ──────────────────────────────────────────────────────── diff --git a/src/templates/jinja2/dashboard/index.html b/src/templates/jinja2/dashboard/index.html index 83d708c..97e7ce1 100644 --- a/src/templates/jinja2/dashboard/index.html +++ b/src/templates/jinja2/dashboard/index.html @@ -56,25 +56,20 @@
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 #}
@@ -112,9 +107,6 @@ {# ==================== ATTACKS TAB ==================== #}
- {# Map section #} - {% include "dashboard/partials/map_section.html" %} - {# Attackers table - HTMX loaded #}

Attackers by Total Requests

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

Honeypot Triggers by IP

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

Detected Attack Types

@@ -168,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..8a0e70f --- /dev/null +++ b/src/templates/jinja2/dashboard/ip.html @@ -0,0 +1,310 @@ +{% extends "base.html" %} + +{% block content %} +
+ + {# GitHub logo #} + + + {# Back to dashboard link #} +
+ + ← Back to Dashboard + +
+ + {# 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 %} +
+ + {# Main content grid #} +
+ {# Left column: IP Info + Map #} +
+ {# IP Information Card #} +
+

IP Information

+
+
+

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 %} +
+ +
+

Location

+ {% if stats.city %} +
+ City: + {{ stats.city | e }} +
+ {% endif %} + {% if stats.region_name %} +
+ Region: + {{ stats.region_name | e }} +
+ {% endif %} + {% if stats.country %} +
+ Country: + {{ stats.country | e }} ({{ stats.country_code | default('') | e }}) +
+ {% endif %} + {% if stats.timezone %} +
+ Timezone: + {{ stats.timezone | e }} +
+ {% endif %} +
+ +
+

Network

+ {% 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 %} +
+ +
+

Flags & Reputation

+ {% 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 %} + {% if stats.reputation_score is not none %} +
+ Reputation: + + {{ stats.reputation_score }}/100 + +
+ {% endif %} + {% if stats.blocklist_memberships %} +
+ Listed On: +
+ {% for bl in stats.blocklist_memberships %} + {{ bl | e }} + {% endfor %} +
+
+ {% else %} +
+ Blocklists: + Clean +
+ {% endif %} +
+
+
+ + {# Single IP Map #} +
+

Location

+
+
+
+ + {# Right column: Radar Chart + Timeline #} +
+ {# Category Analysis Card #} + {% if stats.category_scores %} +
+

Category Analysis

+
+
+
+
+ {% endif %} + + {# Behavior Timeline #} + {% 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 }} + {% endif %} +
{{ entry.timestamp | format_ts }} +
+
+ {% endfor %} +
+
+ {% endif %} +
+
+ + {# Access History table #} +
+

Access History

+
+
Loading...
+
+
+ + {# Raw Request Modal #} +
+
+
+

Raw Request

+ × +
+
+

+            
+ +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} 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 befd6e6..e149bfc 100644 --- a/src/templates/jinja2/dashboard/partials/attack_types_table.html +++ b/src/templates/jinja2/dashboard/partials/attack_types_table.html @@ -28,7 +28,7 @@ hx-swap="innerHTML"> Time - Actions + @@ -60,10 +60,13 @@ {{ (attack.user_agent | default(''))[:50] | e }} {{ attack.timestamp | format_ts }} - + {% if attack.log_id %} {% endif %} + diff --git a/src/templates/jinja2/dashboard/partials/attackers_table.html b/src/templates/jinja2/dashboard/partials/attackers_table.html index 656a341..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,9 +54,14 @@ {{ 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...
diff --git a/src/templates/jinja2/dashboard/partials/credentials_table.html b/src/templates/jinja2/dashboard/partials/credentials_table.html index 49c3abc..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,9 +46,14 @@ {{ cred.password | default('N/A') | e }} {{ cred.path | default('') | e }} {{ cred.timestamp | format_ts }} + + + - +
Loading stats...
diff --git a/src/templates/jinja2/dashboard/partials/honeypot_table.html b/src/templates/jinja2/dashboard/partials/honeypot_table.html index 53ac150..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,9 +40,14 @@ {{ item.ip | e }} {{ item.count }} + + + - +
Loading stats...
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..ae82f61 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/ip_insight.html @@ -0,0 +1,279 @@ +{# HTMX fragment: IP Insight - full IP detail view for inline display #} +
+ {# 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 %} +
+ + {# Main content grid #} +
+ {# Left column: IP Info + Map #} +
+ {# IP Information Card #} +
+

IP Information

+
+
+

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 %} +
+ +
+

Location

+ {% if stats.city %} +
+ City: + {{ stats.city | e }} +
+ {% endif %} + {% if stats.region_name %} +
+ Region: + {{ stats.region_name | e }} +
+ {% endif %} + {% if stats.country %} +
+ Country: + {{ stats.country | e }} ({{ stats.country_code | default('') | e }}) +
+ {% endif %} + {% if stats.timezone %} +
+ Timezone: + {{ stats.timezone | e }} +
+ {% endif %} +
+ +
+

Network

+ {% 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 %} +
+ +
+

Flags & Reputation

+ {% 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 %} + {% if stats.reputation_score is not none %} +
+ Reputation: + + {{ stats.reputation_score }}/100 + +
+ {% endif %} + {% if stats.blocklist_memberships %} +
+ Listed On: +
+ {% for bl in stats.blocklist_memberships %} + {{ bl | e }} + {% endfor %} +
+
+ {% else %} +
+ Blocklists: + Clean +
+ {% endif %} +
+
+
+ +
+ + {# Right column: Radar Chart + Timeline #} +
+ {# Category Analysis Card #} + {% if stats.category_scores %} +
+

Category Analysis

+
+
+
+
+ {% endif %} + + {# Behavior Timeline #} + {% 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 }} + {% endif %} +
{{ entry.timestamp | format_ts }} +
+
+ {% endfor %} +
+
+ {% endif %} +
+
+ + {# Single IP Map - full width #} +
+

Location

+
+
+ + {# Access History table #} +
+

Access History

+
+
Loading...
+
+
+
+ +{# Inline script for initializing map and chart after HTMX swap #} + diff --git a/src/templates/jinja2/dashboard/partials/suspicious_table.html b/src/templates/jinja2/dashboard/partials/suspicious_table.html index 97a11c8..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,10 +23,15 @@ {{ activity.path | e }} {{ (activity.user_agent | default(''))[:80] | e }} - {{ activity.timestamp | format_ts }} + {{ activity.timestamp | format_ts(time_only=True) }} + + + - +
Loading stats...
diff --git a/src/templates/jinja2/dashboard/partials/top_ips_table.html b/src/templates/jinja2/dashboard/partials/top_ips_table.html index cbfc959..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,10 +40,20 @@ @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...
diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css index 4ca038f..a4dae5f 100644 --- a/src/templates/static/css/dashboard.css +++ b/src/templates/static/css/dashboard.css @@ -477,6 +477,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; } 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..aa4e5ab 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 }); @@ -309,6 +340,15 @@ function buildMapMarkers(ips) { `; } + // Add inspect button + popupContent += ` +
+ +
+ `; + popupContent += '
'; marker.setPopupContent(popupContent); } catch (err) { @@ -332,6 +372,11 @@ function buildMapMarkers(ips) {
Failed to load chart: ${err.message}
+
+ +
`; marker.setPopupContent(errorPopup);