From 486d02fbd4c7a1b839f2bcbdb10c947aa8751ba9 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 22 Feb 2026 17:33:57 +0100 Subject: [PATCH 01/25] feat: add deployment strategy type as Recreate in multiple deployment files --- helm/templates/deployment.yaml | 2 ++ kubernetes/krawl-all-in-one-deploy.yaml | 2 ++ kubernetes/manifests/deployment.yaml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index f24261c..11328f4 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 }} 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 From 75722051d69e3258457a59fac3995454e5d0090b Mon Sep 17 00:00:00 2001 From: carnivuth Date: Sun, 22 Feb 2026 21:53:13 +0100 Subject: [PATCH 02/25] added first version of single ip page breakdiwn --- src/database.py | 63 +++++++++++++++++++ src/routes/api.py | 1 - src/routes/dashboard.py | 31 +++++++++ src/routes/htmx.py | 36 +++++++++++ src/templates/jinja2/dashboard/ip.html | 30 +++++++++ .../partials/access_by_ip_table.html | 63 +++++++++++++++++++ 6 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 src/templates/jinja2/dashboard/ip.html create mode 100644 src/templates/jinja2/dashboard/partials/access_by_ip_table.html diff --git a/src/database.py b/src/database.py index 9daca49..ba34cb5 100644 --- a/src/database.py +++ b/src/database.py @@ -850,6 +850,69 @@ 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() 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..c8c482e 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 @@ -37,3 +39,32 @@ 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: + + 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..ddf08f6 100644 --- a/src/routes/htmx.py +++ b/src/routes/htmx.py @@ -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 ────────────────────────────────────────────────────── diff --git a/src/templates/jinja2/dashboard/ip.html b/src/templates/jinja2/dashboard/ip.html new file mode 100644 index 0000000..c9d73c7 --- /dev/null +++ b/src/templates/jinja2/dashboard/ip.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} + +{% block content %} +
+ + {# GitHub logo #} + + +

Krawl {{ ip_address }} analysis

+ + + {% include "dashboard/partials/map_section.html" %} + {% include "dashboard/partials/ip_detail.html" %} + + {# Attack Types table #} +
+

{{ip_address}} Access History

+
+
Loading...
+
+
+ +
+{% 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..69de4a8 --- /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 + Actions
{{ 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.log_id %} + + {% endif %} +
No logs detected
From f7416518fe72293ab13ea53cbde206d5005c98a5 Mon Sep 17 00:00:00 2001 From: carnivuth Date: Sun, 22 Feb 2026 21:53:18 +0100 Subject: [PATCH 03/25] added first version of single ip page breakdiwn --- src/database.py | 31 +++++++++++++++++-------------- src/routes/dashboard.py | 4 ++-- src/routes/htmx.py | 2 +- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/database.py b/src/database.py index ba34cb5..179dab0 100644 --- a/src/database.py +++ b/src/database.py @@ -888,31 +888,34 @@ class DatabaseManager: 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() + session.query(AccessLog) + .filter(AccessLog.ip == sanitize_ip(ip_filter)) + .count() ) total_pages = (total_access_logs + page_size - 1) // page_size return { - "access_logs": [ + "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 ], + "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() diff --git a/src/routes/dashboard.py b/src/routes/dashboard.py index c8c482e..da6846b 100644 --- a/src/routes/dashboard.py +++ b/src/routes/dashboard.py @@ -40,6 +40,7 @@ async def dashboard_page(request: Request): }, ) + @router.get("/ip/{ip_address:path}") async def ip_page(ip_address: str, request: Request): db = get_db() @@ -57,7 +58,7 @@ async def ip_page(ip_address: str, request: Request): "request": request, "dashboard_path": dashboard_path, "stats": stats, - "ip_address": ip_address + "ip_address": ip_address, }, ) else: @@ -67,4 +68,3 @@ async def ip_page(ip_address: str, request: Request): 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 ddf08f6..28de8cd 100644 --- a/src/routes/htmx.py +++ b/src/routes/htmx.py @@ -180,7 +180,7 @@ async def htmx_access_logs_by_ip( ): db = get_db() result = db.get_access_logs_paginated( - page=max(1, page),page_size=25, ip_filter=ip_filter + page=max(1, page), page_size=25, ip_filter=ip_filter ) # Normalize pagination key (DB returns total_attackers, template expects total) From 4194c4585b4dba330b6e23eec49ee06197739c53 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sat, 28 Feb 2026 15:35:48 +0100 Subject: [PATCH 04/25] fix: update container name to 'krawl' in deployment.yaml --- helm/templates/deployment.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index 11328f4..3676817 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -31,7 +31,7 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} containers: - - name: {{ .Chart.Name }} + - name: krawl {{- with .Values.securityContext }} securityContext: {{- toYaml . | nindent 12 }} From ce713d8072b36546f972a826c4d30e0213779525 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sat, 28 Feb 2026 16:45:07 +0100 Subject: [PATCH 05/25] tweaked map markers --- src/templates/static/js/map.js | 45 ++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/src/templates/static/js/map.js b/src/templates/static/js/map.js index 5181295..aaf613b 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 }); From d9ae55c0aa1e6a7b4e455792d19a6af658a5a651 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sat, 28 Feb 2026 17:43:50 +0100 Subject: [PATCH 06/25] feat: add IP insight feature with detailed view and actions - Updated various tables to include "Actions" column with inspect buttons for IP insights. - Created a new IP insight template for displaying detailed information about an IP address. - Implemented JavaScript functions to handle opening the IP insight view and loading data via HTMX. - Enhanced map markers to include inspect buttons for quick access to IP insights. - Added styles for the new IP insight page and buttons to maintain UI consistency. --- src/database.py | 2 + src/routes/dashboard.py | 4 + src/routes/htmx.py | 30 +- src/templates/jinja2/dashboard/index.html | 16 + src/templates/jinja2/dashboard/ip.html | 294 ++++++++++++++- .../partials/access_by_ip_table.html | 4 +- .../partials/attack_types_table.html | 5 +- .../dashboard/partials/attackers_table.html | 10 +- .../dashboard/partials/credentials_table.html | 10 +- .../dashboard/partials/honeypot_table.html | 10 +- .../jinja2/dashboard/partials/ip_insight.html | 279 ++++++++++++++ .../dashboard/partials/suspicious_table.html | 10 +- .../dashboard/partials/top_ips_table.html | 10 +- src/templates/static/css/dashboard.css | 346 ++++++++++++++++++ src/templates/static/js/dashboard.js | 49 ++- src/templates/static/js/map.js | 14 + 16 files changed, 1070 insertions(+), 23 deletions(-) create mode 100644 src/templates/jinja2/dashboard/partials/ip_insight.html diff --git a/src/database.py b/src/database.py index 179dab0..0e5b40b 100644 --- a/src/database.py +++ b/src/database.py @@ -1084,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, diff --git a/src/routes/dashboard.py b/src/routes/dashboard.py index da6846b..0e93873 100644 --- a/src/routes/dashboard.py +++ b/src/routes/dashboard.py @@ -50,6 +50,10 @@ async def ip_page(ip_address: str, request: Request): 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( diff --git a/src/routes/htmx.py b/src/routes/htmx.py index 28de8cd..ef2d5c1 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() @@ -316,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 5ec70f7..a0dbf8c 100644 --- a/src/templates/jinja2/dashboard/index.html +++ b/src/templates/jinja2/dashboard/index.html @@ -35,6 +35,9 @@ {# ==================== OVERVIEW TAB ==================== #} @@ -147,6 +150,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 index c9d73c7..8a0e70f 100644 --- a/src/templates/jinja2/dashboard/ip.html +++ b/src/templates/jinja2/dashboard/ip.html @@ -9,22 +9,302 @@ Krawl -

Krawl {{ ip_address }} analysis

+ {# Back to dashboard link #} + + {# 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 %} +
- {% include "dashboard/partials/map_section.html" %} - {% include "dashboard/partials/ip_detail.html" %} + {# 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 %} +
- {# Attack Types table #} -
-

{{ip_address}} Access History

+
+

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 index 69de4a8..34306bc 100644 --- a/src/templates/jinja2/dashboard/partials/access_by_ip_table.html +++ b/src/templates/jinja2/dashboard/partials/access_by_ip_table.html @@ -44,8 +44,8 @@ {{ (log.user_agent | default(''))[:50] | e }} {{ log.timestamp | format_ts }} - {% if log.log_id %} - + {% if log.id %} + {% endif %} diff --git a/src/templates/jinja2/dashboard/partials/attack_types_table.html b/src/templates/jinja2/dashboard/partials/attack_types_table.html index 8a74572..9d8bb30 100644 --- a/src/templates/jinja2/dashboard/partials/attack_types_table.html +++ b/src/templates/jinja2/dashboard/partials/attack_types_table.html @@ -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 632137d..4e8a987 100644 --- a/src/templates/jinja2/dashboard/partials/attackers_table.html +++ b/src/templates/jinja2/dashboard/partials/attackers_table.html @@ -28,6 +28,7 @@ First Seen Last Seen Location + Actions @@ -45,16 +46,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..92af527 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 + Actions @@ -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..f7cd1da 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 + Actions @@ -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..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 72a0480..172e6c8 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 + Actions @@ -23,16 +24,21 @@ {{ activity.path | e }} {{ (activity.user_agent | default(''))[:80] | e }} {{ 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..d014668 100644 --- a/src/templates/jinja2/dashboard/partials/top_ips_table.html +++ b/src/templates/jinja2/dashboard/partials/top_ips_table.html @@ -25,6 +25,7 @@ hx-swap="innerHTML"> Access Count + Actions @@ -39,16 +40,21 @@ {{ item.ip | e }} {{ item.count }} + + + - +
Loading stats...
{% else %} - No data + No data {% endfor %} diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css index c7cd3a5..9ed0186 100644 --- a/src/templates/static/css/dashboard.css +++ b/src/templates/static/css/dashboard.css @@ -474,6 +474,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; } @@ -1253,3 +1262,340 @@ tbody { [x-cloak] { display: none !important; } + +/* ======================================== + Single IP Page Styles + ======================================== */ + +.ip-page-header { + text-align: center; + margin-bottom: 30px; + padding-top: 20px; +} + +.ip-page-header h1 { + display: flex; + align-items: center; + justify-content: center; + gap: 15px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.ip-address-title { + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 32px; + color: #58a6ff; +} + +.ip-location-subtitle { + color: #8b949e; + font-size: 16px; + margin: 0; +} + +.ip-page-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 20px; + margin-bottom: 20px; +} + +.ip-page-left, +.ip-page-right { + display: flex; + flex-direction: column; + gap: 20px; +} + +.ip-info-card h2 { + margin-bottom: 20px; +} + +.ip-info-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; +} + +.ip-info-section { + background: #0d1117; + border: 1px solid #21262d; + border-radius: 6px; + padding: 15px; +} + +.ip-info-section h3 { + color: #58a6ff; + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + margin: 0 0 12px 0; + padding-bottom: 8px; + border-bottom: 1px solid #21262d; +} + +.ip-flag { + display: inline-block; + padding: 2px 8px; + background: #f0883e1a; + color: #f0883e; + border: 1px solid #f0883e4d; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + margin-right: 4px; +} + +.reputation-score { + font-weight: 700; + padding: 2px 8px; + border-radius: 4px; +} + +.reputation-score.bad { + background: #f851491a; + color: #f85149; +} + +.reputation-score.medium { + background: #f0883e1a; + color: #f0883e; +} + +.reputation-score.good { + background: #3fb9501a; + color: #3fb950; +} + +.radar-chart-container { + display: flex; + justify-content: center; + padding: 20px 0; +} + +/* Single IP page: radar chart with legend on the right */ +.ip-page-right .radar-chart-container { + padding: 10px 0; + justify-content: flex-start; +} + +/* Target the wrapper div injected by generateRadarChart inside radar chart containers */ +.ip-page-right #ip-radar-chart > div, +.ip-page-right #insight-radar-chart > div { + display: flex !important; + flex-direction: row !important; + align-items: center !important; + gap: 15px; +} + +.ip-page-right #ip-radar-chart > div > svg, +.ip-page-right #insight-radar-chart > div > svg { + flex-shrink: 0; +} + +.ip-page-right #ip-radar-chart .radar-legend, +.ip-page-right #insight-radar-chart .radar-legend { + margin-top: 0; + text-align: left; +} + +/* Single IP page: limit timeline height to match map */ +.ip-page-right .timeline { + max-height: 250px; + overflow-y: auto; + padding-right: 10px; +} + +/* Dark theme scrollbar for timeline */ +.ip-page-right .timeline::-webkit-scrollbar { + width: 6px; +} + +.ip-page-right .timeline::-webkit-scrollbar-track { + background: #21262d; + border-radius: 3px; +} + +.ip-page-right .timeline::-webkit-scrollbar-thumb { + background: #484f58; + border-radius: 3px; +} + +.ip-page-right .timeline::-webkit-scrollbar-thumb:hover { + background: #6e7681; +} + +.single-ip-marker { + background: none !important; + border: none !important; +} + +/* Mobile responsiveness for IP page */ +@media (max-width: 1024px) { + .ip-page-grid { + grid-template-columns: 1fr; + } + + .ip-page-right { + order: -1; + } + + .ip-info-grid { + grid-template-columns: 1fr; + } + + /* On mobile, stack legend below chart again */ + .ip-page-right #ip-radar-chart > div, + .ip-page-right #insight-radar-chart > div { + flex-direction: column !important; + } + + .ip-page-right #ip-radar-chart .radar-legend, + .ip-page-right #insight-radar-chart .radar-legend { + margin-top: 10px; + text-align: center; + } + + /* On mobile, remove timeline height limit */ + .ip-page-right .timeline { + max-height: none; + } +} + +@media (max-width: 768px) { + .ip-address-title { + font-size: 24px; + } + + .ip-page-header h1 { + flex-direction: column; + gap: 10px; + } + + .ip-info-section { + padding: 12px; + } + + .ip-info-section h3 { + font-size: 12px; + } +} + +@media (max-width: 480px) { + .ip-address-title { + font-size: 18px; + } + + .ip-location-subtitle { + font-size: 14px; + } +} + +/* ======================================== + IP Lookup Panel + ======================================== */ + +.ip-lookup-panel { + background: #161b22; + border: 1px solid #30363d; + border-radius: 8px; + padding: 20px; + margin-bottom: 30px; + display: flex; + justify-content: center; +} + +.ip-lookup-form { + display: flex; + gap: 12px; + width: 100%; + max-width: 500px; +} + +.ip-lookup-input { + flex: 1; + padding: 12px 16px; + background: #0d1117; + border: 1px solid #30363d; + border-radius: 6px; + color: #c9d1d9; + font-size: 14px; + font-family: 'SF Mono', Monaco, Consolas, monospace; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.ip-lookup-input:focus { + border-color: #58a6ff; + box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15); +} + +.ip-lookup-input::placeholder { + color: #6e7681; +} + +.ip-lookup-btn { + padding: 12px 24px; + background: #238636; + color: #ffffff; + border: 1px solid #2ea043; + border-radius: 6px; + font-weight: 600; + font-size: 14px; + cursor: pointer; + transition: background 0.2s; + white-space: nowrap; +} + +.ip-lookup-btn:hover { + background: #2ea043; +} + +.ip-lookup-btn:active { + background: #1f7a2f; +} + +@media (max-width: 480px) { + .ip-lookup-form { + flex-direction: column; + } + + .ip-lookup-btn { + width: 100%; + } +} + +/* ======================================== + Inspect Button + ======================================== */ + +.inspect-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 4px 10px; + background: #21262d; + color: #58a6ff; + border: 1px solid #30363d; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; + white-space: nowrap; +} + +.inspect-btn:hover { + background: #30363d; + border-color: #58a6ff; + color: #79c0ff; +} + +.inspect-btn svg { + width: 12px; + height: 12px; + fill: currentColor; +} diff --git a/src/templates/static/js/dashboard.js b/src/templates/static/js/dashboard.js index b74a51d..eec56ca 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(); + } } }); }, @@ -60,6 +67,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 +142,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 aaf613b..aa4e5ab 100644 --- a/src/templates/static/js/map.js +++ b/src/templates/static/js/map.js @@ -340,6 +340,15 @@ function buildMapMarkers(ips) { `; } + // Add inspect button + popupContent += ` +
+ +
+ `; + popupContent += ''; marker.setPopupContent(popupContent); } catch (err) { @@ -363,6 +372,11 @@ function buildMapMarkers(ips) {
Failed to load chart: ${err.message}
+
+ +
`; marker.setPopupContent(errorPopup); From 3d8178ff0eff31f3e481697ffb24ae88dc41f8a7 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sat, 28 Feb 2026 18:04:26 +0100 Subject: [PATCH 07/25] feat: enhance dashboard with IP category display and improved data tables --- src/database.py | 11 ++++-- src/templates/jinja2/dashboard/index.html | 34 +++++++++---------- .../partials/access_by_ip_table.html | 6 ++-- .../partials/attack_types_table.html | 2 +- .../dashboard/partials/attackers_table.html | 2 +- .../dashboard/partials/credentials_table.html | 2 +- .../dashboard/partials/honeypot_table.html | 2 +- .../dashboard/partials/suspicious_table.html | 2 +- .../dashboard/partials/top_ips_table.html | 12 +++++-- src/templates/static/js/dashboard.js | 8 +---- 10 files changed, 43 insertions(+), 38 deletions(-) diff --git a/src/database.py b/src/database.py index 0e5b40b..ffd2592 100644 --- a/src/database.py +++ b/src/database.py @@ -1755,14 +1755,19 @@ 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/templates/jinja2/dashboard/index.html b/src/templates/jinja2/dashboard/index.html index a0dbf8c..d4cb7d3 100644 --- a/src/templates/jinja2/dashboard/index.html +++ b/src/templates/jinja2/dashboard/index.html @@ -41,21 +41,10 @@ {# ==================== OVERVIEW TAB ==================== #} -
+
- {# Suspicious Activity - server-rendered #} - {% include "dashboard/partials/suspicious_table.html" %} - - {# Honeypot Triggers - HTMX loaded #} -
-

Honeypot Triggers by IP

-
-
Loading...
-
-
+ {# Map section #} + {% include "dashboard/partials/map_section.html" %} {# Top IPs + Top User-Agents side by side #}
@@ -89,14 +78,14 @@
Loading...
+ + {# Suspicious Activity - server-rendered #} + {% include "dashboard/partials/suspicious_table.html" %}
{# ==================== ATTACKS TAB ==================== #}
- {# Map section #} - {% include "dashboard/partials/map_section.html" %} - {# Attackers table - HTMX loaded #}

Attackers by Total Requests

@@ -119,6 +108,17 @@
+ {# Honeypot Triggers - HTMX loaded #} +
+

Honeypot Triggers by IP

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

Detected Attack Types

diff --git a/src/templates/jinja2/dashboard/partials/access_by_ip_table.html b/src/templates/jinja2/dashboard/partials/access_by_ip_table.html index 34306bc..5e7bd6c 100644 --- a/src/templates/jinja2/dashboard/partials/access_by_ip_table.html +++ b/src/templates/jinja2/dashboard/partials/access_by_ip_table.html @@ -26,7 +26,7 @@ hx-swap="innerHTML"> Time - Actions + @@ -50,14 +50,14 @@ - +
Loading stats...
{% else %} - No logs detected + No logs detected {% endfor %} diff --git a/src/templates/jinja2/dashboard/partials/attack_types_table.html b/src/templates/jinja2/dashboard/partials/attack_types_table.html index 9d8bb30..9a2451a 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 + diff --git a/src/templates/jinja2/dashboard/partials/attackers_table.html b/src/templates/jinja2/dashboard/partials/attackers_table.html index 4e8a987..23c61e8 100644 --- a/src/templates/jinja2/dashboard/partials/attackers_table.html +++ b/src/templates/jinja2/dashboard/partials/attackers_table.html @@ -28,7 +28,7 @@ First Seen Last Seen Location - Actions + diff --git a/src/templates/jinja2/dashboard/partials/credentials_table.html b/src/templates/jinja2/dashboard/partials/credentials_table.html index 92af527..491b5b2 100644 --- a/src/templates/jinja2/dashboard/partials/credentials_table.html +++ b/src/templates/jinja2/dashboard/partials/credentials_table.html @@ -28,7 +28,7 @@ hx-swap="innerHTML"> Time - Actions + diff --git a/src/templates/jinja2/dashboard/partials/honeypot_table.html b/src/templates/jinja2/dashboard/partials/honeypot_table.html index f7cd1da..3215bc7 100644 --- a/src/templates/jinja2/dashboard/partials/honeypot_table.html +++ b/src/templates/jinja2/dashboard/partials/honeypot_table.html @@ -25,7 +25,7 @@ hx-swap="innerHTML"> Honeypot Triggers - Actions + diff --git a/src/templates/jinja2/dashboard/partials/suspicious_table.html b/src/templates/jinja2/dashboard/partials/suspicious_table.html index 172e6c8..c10502c 100644 --- a/src/templates/jinja2/dashboard/partials/suspicious_table.html +++ b/src/templates/jinja2/dashboard/partials/suspicious_table.html @@ -8,7 +8,7 @@ Path User-Agent Time - Actions + diff --git a/src/templates/jinja2/dashboard/partials/top_ips_table.html b/src/templates/jinja2/dashboard/partials/top_ips_table.html index d014668..afcf26b 100644 --- a/src/templates/jinja2/dashboard/partials/top_ips_table.html +++ b/src/templates/jinja2/dashboard/partials/top_ips_table.html @@ -19,13 +19,14 @@ # IP Address + Category Access Count - Actions + @@ -39,6 +40,11 @@ @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 }}
{# ==================== ATTACKS TAB ==================== #} From 62bb091926d24b96fe6a97443b54b49b7ba768c9 Mon Sep 17 00:00:00 2001 From: BlessedRebuS Date: Sat, 28 Feb 2026 18:43:09 +0100 Subject: [PATCH 09/25] added search bar feature, refactored the dashboard --- src/database.py | 116 +++++++++++++ src/routes/htmx.py | 32 +++- src/templates/jinja2/base.html | 1 + src/templates/jinja2/dashboard/index.html | 21 +++ .../partials/attack_types_table.html | 2 +- .../dashboard/partials/attackers_table.html | 2 +- .../dashboard/partials/credentials_table.html | 2 +- .../dashboard/partials/honeypot_table.html | 2 +- .../dashboard/partials/patterns_table.html | 2 +- .../dashboard/partials/search_results.html | 158 ++++++++++++++++++ .../dashboard/partials/suspicious_table.html | 2 +- .../dashboard/partials/top_ips_table.html | 2 +- .../dashboard/partials/top_paths_table.html | 2 +- .../dashboard/partials/top_ua_table.html | 4 +- src/templates/static/css/dashboard.css | 148 +++++++++++++++- 15 files changed, 478 insertions(+), 18 deletions(-) create mode 100644 src/templates/jinja2/dashboard/partials/search_results.html diff --git a/src/database.py b/src/database.py index 9daca49..5b8d685 100644 --- a/src/database.py +++ b/src/database.py @@ -1973,6 +1973,122 @@ 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/routes/htmx.py b/src/routes/htmx.py index 4013ce5..0023598 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 @@ -305,3 +305,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/templates/jinja2/base.html b/src/templates/jinja2/base.html index 1ba2af5..48097f0 100644 --- a/src/templates/jinja2/base.html +++ b/src/templates/jinja2/base.html @@ -8,6 +8,7 @@ + diff --git a/src/templates/jinja2/dashboard/index.html b/src/templates/jinja2/dashboard/index.html index 5ec70f7..83d708c 100644 --- a/src/templates/jinja2/dashboard/index.html +++ b/src/templates/jinja2/dashboard/index.html @@ -31,6 +31,27 @@ {# Stats cards - server-rendered #} {% include "dashboard/partials/stats_cards.html" %} + {# Search bar #} +
+ +
+
+ {# Tab navigation - Alpine.js #}
Overview diff --git a/src/templates/jinja2/dashboard/partials/attack_types_table.html b/src/templates/jinja2/dashboard/partials/attack_types_table.html index 8a74572..befd6e6 100644 --- a/src/templates/jinja2/dashboard/partials/attack_types_table.html +++ b/src/templates/jinja2/dashboard/partials/attack_types_table.html @@ -74,7 +74,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..656a341 100644 --- a/src/templates/jinja2/dashboard/partials/attackers_table.html +++ b/src/templates/jinja2/dashboard/partials/attackers_table.html @@ -62,7 +62,7 @@ {% 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..49c3abc 100644 --- a/src/templates/jinja2/dashboard/partials/credentials_table.html +++ b/src/templates/jinja2/dashboard/partials/credentials_table.html @@ -54,7 +54,7 @@ {% 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..53ac150 100644 --- a/src/templates/jinja2/dashboard/partials/honeypot_table.html +++ b/src/templates/jinja2/dashboard/partials/honeypot_table.html @@ -48,7 +48,7 @@ {% else %} - No data + No data {% endfor %} 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..a1e4046 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/search_results.html @@ -0,0 +1,158 @@ +{# 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..97a11c8 100644 --- a/src/templates/jinja2/dashboard/partials/suspicious_table.html +++ b/src/templates/jinja2/dashboard/partials/suspicious_table.html @@ -32,7 +32,7 @@ {% 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..cbfc959 100644 --- a/src/templates/jinja2/dashboard/partials/top_ips_table.html +++ b/src/templates/jinja2/dashboard/partials/top_ips_table.html @@ -48,7 +48,7 @@ {% 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..dc38e90 100644 --- a/src/templates/jinja2/dashboard/partials/top_ua_table.html +++ b/src/templates/jinja2/dashboard/partials/top_ua_table.html @@ -31,11 +31,11 @@ {% for item in items %} {{ loop.index + (pagination.page - 1) * pagination.page_size }} - {{ item.user_agent | e }} + {{ item.user_agent | e }} {{ 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..4ca038f 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; @@ -1253,3 +1256,134 @@ 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; + font-size: 18px; + color: #58a6ff; + animation: spin 0.8s linear infinite; +} +@keyframes spin { + from { transform: rotate(0deg); } + 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; +} From 4900c3dd496d5504d7adac2ca0b73600c3bd5ca2 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sat, 28 Feb 2026 19:42:15 +0100 Subject: [PATCH 10/25] fix: layout improvement after merge --- src/dependencies.py | 4 +- src/templates/jinja2/dashboard/index.html | 2 +- src/templates/jinja2/dashboard/ip.html | 36 ++--- .../jinja2/dashboard/partials/ip_insight.html | 36 ++--- .../dashboard/partials/search_results.html | 10 +- .../dashboard/partials/top_ua_table.html | 2 +- src/templates/static/css/dashboard.css | 150 +++++++++++++++++- src/templates/static/js/map.js | 28 ++-- src/templates/static/js/radar.js | 7 +- 9 files changed, 194 insertions(+), 81 deletions(-) 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/templates/jinja2/dashboard/index.html b/src/templates/jinja2/dashboard/index.html index 97e7ce1..fef46c6 100644 --- a/src/templates/jinja2/dashboard/index.html +++ b/src/templates/jinja2/dashboard/index.html @@ -47,7 +47,7 @@ hx-target="#search-results-container" hx-swap="innerHTML" hx-indicator="#search-spinner" /> - +
diff --git a/src/templates/jinja2/dashboard/ip.html b/src/templates/jinja2/dashboard/ip.html index 8a0e70f..371b771 100644 --- a/src/templates/jinja2/dashboard/ip.html +++ b/src/templates/jinja2/dashboard/ip.html @@ -64,23 +64,11 @@
-

Location

- {% if stats.city %} +

Geo & Network

+ {% if stats.city or stats.country %}
- 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 }}) + Location: + {{ stats.city | default('') | e }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) | e }}
{% endif %} {% if stats.timezone %} @@ -89,10 +77,6 @@ {{ stats.timezone | e }}
{% endif %} - - -
-

Network

{% if stats.isp %}
ISP: @@ -120,7 +104,7 @@
-

Flags & Reputation

+

Reputation

{% set flags = [] %} {% if stats.is_proxy %}{% set _ = flags.append('Proxy') %}{% endif %} {% if stats.is_hosting %}{% set _ = flags.append('Hosting') %}{% endif %} @@ -136,16 +120,16 @@ {% endif %} {% if stats.reputation_score is not none %}
- Reputation: + Score: {{ stats.reputation_score }}/100
{% endif %} {% if stats.blocklist_memberships %} -
+
Listed On: -
+
{% for bl in stats.blocklist_memberships %} {{ bl | e }} {% endfor %} @@ -184,7 +168,7 @@ {% if stats.category_history %}

Behavior Timeline

-
+
{% for entry in stats.category_history %}
@@ -240,7 +224,7 @@ const scores = {{ stats.category_scores | tojson }}; const container = document.getElementById('ip-radar-chart'); if (container && typeof generateRadarChart === 'function') { - container.innerHTML = generateRadarChart(scores, 220, true); + container.innerHTML = generateRadarChart(scores, 220, true, 'side'); } {% endif %} diff --git a/src/templates/jinja2/dashboard/partials/ip_insight.html b/src/templates/jinja2/dashboard/partials/ip_insight.html index ae82f61..8c8ab13 100644 --- a/src/templates/jinja2/dashboard/partials/ip_insight.html +++ b/src/templates/jinja2/dashboard/partials/ip_insight.html @@ -48,23 +48,11 @@
-

Location

- {% if stats.city %} +

Geo & Network

+ {% if stats.city or stats.country %}
- 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 }}) + Location: + {{ stats.city | default('') | e }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) | e }}
{% endif %} {% if stats.timezone %} @@ -73,10 +61,6 @@ {{ stats.timezone | e }}
{% endif %} -
- -
-

Network

{% if stats.isp %}
ISP: @@ -104,7 +88,7 @@
-

Flags & Reputation

+

Reputation

{% set flags = [] %} {% if stats.is_proxy %}{% set _ = flags.append('Proxy') %}{% endif %} {% if stats.is_hosting %}{% set _ = flags.append('Hosting') %}{% endif %} @@ -120,16 +104,16 @@ {% endif %} {% if stats.reputation_score is not none %}
- Reputation: + Score: {{ stats.reputation_score }}/100
{% endif %} {% if stats.blocklist_memberships %} -
+
Listed On: -
+
{% for bl in stats.blocklist_memberships %} {{ bl | e }} {% endfor %} @@ -163,7 +147,7 @@ {% if stats.category_history %}

Behavior Timeline

-
+
{% for entry in stats.category_history %}
@@ -208,7 +192,7 @@ const scores = {{ stats.category_scores | tojson }}; const container = document.getElementById('insight-radar-chart'); if (container && typeof generateRadarChart === 'function') { - container.innerHTML = generateRadarChart(scores, 220, true); + container.innerHTML = generateRadarChart(scores, 220, true, 'side'); } {% endif %} diff --git a/src/templates/jinja2/dashboard/partials/search_results.html b/src/templates/jinja2/dashboard/partials/search_results.html index a1e4046..1ae0d41 100644 --- a/src/templates/jinja2/dashboard/partials/search_results.html +++ b/src/templates/jinja2/dashboard/partials/search_results.html @@ -24,6 +24,7 @@ Location ISP / ASN Last Seen + @@ -40,7 +41,7 @@ {{ ip.total_requests }} {% if ip.category %} - + {{ ip.category | e }} {% else %} @@ -50,9 +51,14 @@ {{ 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 }} + + + - +
Loading stats...
diff --git a/src/templates/jinja2/dashboard/partials/top_ua_table.html b/src/templates/jinja2/dashboard/partials/top_ua_table.html index dc38e90..2026005 100644 --- a/src/templates/jinja2/dashboard/partials/top_ua_table.html +++ b/src/templates/jinja2/dashboard/partials/top_ua_table.html @@ -31,7 +31,7 @@ {% for item in items %} {{ loop.index + (pagination.page - 1) * pagination.page_size }} - {{ item.user_agent | e }} + {{ item.user_agent | e }} {{ item.count }} {% else %} diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css index a4dae5f..2a58750 100644 --- a/src/templates/static/css/dashboard.css +++ b/src/templates/static/css/dashboard.css @@ -274,14 +274,15 @@ tbody { 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; @@ -445,6 +446,119 @@ 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: 24px; +} +.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; +} +.ip-page-grid { + display: grid; + grid-template-columns: 3fr 2fr; + gap: 20px; + align-items: start; +} +.ip-page-left, +.ip-page-right { + display: flex; + flex-direction: column; + gap: 20px; +} +.ip-info-card h2 { + margin-top: 0; +} +.ip-info-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 0; +} +.ip-info-section { + padding: 14px 16px; + border-right: 1px solid #21262d; +} +.ip-info-section:last-child { + border-right: none; +} +.ip-info-section h3 { + color: #58a6ff; + font-size: 13px; + font-weight: 600; + margin: 0 0 10px 0; + padding-bottom: 6px; + border-bottom: 1px solid #21262d; +} +.ip-info-section .stat-row { + padding: 3px 0; + font-size: 13px; +} +.blocklist-badges { + display: flex; + flex-wrap: wrap; + gap: 5px; + max-height: 120px; + overflow-y: auto; +} +.ip-flag { + display: inline-block; + background: #1c2128; + border: 1px solid #30363d; + border-radius: 4px; + padding: 2px 8px; + font-size: 11px; + color: #f0883e; + margin-right: 4px; +} +.reputation-score { + font-weight: 700; +} +.reputation-score.bad { color: #f85149; } +.reputation-score.medium { color: #f0883e; } +.reputation-score.good { color: #3fb950; } +.radar-chart-container { + display: flex; + align-items: center; + justify-content: center; + padding: 10px 0; +} +.ip-timeline-scroll { + max-height: 280px; + overflow-y: auto; +} +@media (max-width: 900px) { + .ip-page-grid { + grid-template-columns: 1fr; + } + .ip-info-grid { + grid-template-columns: 1fr; + } + .ip-info-section { + border-right: none; + border-bottom: 1px solid #21262d; + } + .ip-info-section:last-child { + border-bottom: none; + } +} + .tabs-container { border-bottom: 1px solid #30363d; margin-bottom: 30px; @@ -1222,6 +1336,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; @@ -1313,12 +1448,15 @@ tbody { .search-spinner { position: absolute; right: 14px; - font-size: 18px; - color: #58a6ff; - animation: spin 0.8s linear infinite; + 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 { - from { transform: rotate(0deg); } to { transform: rotate(360deg); } } diff --git a/src/templates/static/js/map.js b/src/templates/static/js/map.js index aa4e5ab..1350bb9 100644 --- a/src/templates/static/js/map.js +++ b/src/templates/static/js/map.js @@ -315,8 +315,13 @@ function buildMapMarkers(ips) { let popupContent = `
-
+
${ip.ip} + +
+
${categoryLabels[category]} @@ -340,23 +345,19 @@ function buildMapMarkers(ips) { `; } - // Add inspect button - popupContent += ` -
- -
- `; - popupContent += '
'; marker.setPopupContent(popupContent); } catch (err) { console.error('Error fetching IP stats:', err); const errorPopup = `
-
+
${ip.ip} + +
+
${categoryLabels[category]} @@ -372,11 +373,6 @@ function buildMapMarkers(ips) {
Failed to load chart: ${err.message}
-
- -
`; marker.setPopupContent(errorPopup); 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) From b8f0cc25d01fb772046e1c639eefa09846387499 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sat, 28 Feb 2026 19:42:32 +0100 Subject: [PATCH 11/25] style: format code for better readability in DatabaseManager class --- src/database.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/database.py b/src/database.py index 6bd282b..ec4aef9 100644 --- a/src/database.py +++ b/src/database.py @@ -2136,7 +2136,9 @@ class DatabaseManager: { "ip": stat.ip, "total_requests": stat.total_requests, - "first_seen": stat.first_seen.isoformat() if stat.first_seen else None, + "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, @@ -2148,7 +2150,9 @@ class DatabaseManager: ] total = total_attacks + total_ips - total_pages = max(1, (max(total_attacks, total_ips) + page_size - 1) // page_size) + total_pages = max( + 1, (max(total_attacks, total_ips) + page_size - 1) // page_size + ) return { "attacks": attacks, From 740178384730f057ff49764e0770cfdf93af4dcc Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 1 Mar 2026 15:57:40 +0100 Subject: [PATCH 12/25] refactor: optimize database queries by utilizing IpStats for performance improvements --- src/database.py | 225 ++++++++++++++++++++++++------------------------ 1 file changed, 114 insertions(+), 111 deletions(-) diff --git a/src/database.py b/src/database.py index ec4aef9..39b4581 100644 --- a/src/database.py +++ b/src/database.py @@ -1384,26 +1384,20 @@ 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() + query = session.query(IpStats.ip, IpStats.total_requests) + query = self._public_ip_filter(query, IpStats.ip, server_ip) results = ( - session.query(AccessLog.ip, func.count(AccessLog.id).label("count")) - .group_by(AccessLog.ip) - .order_by(func.count(AccessLog.id).desc()) + 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() @@ -1470,23 +1464,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 [ { @@ -1495,7 +1484,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() @@ -1600,44 +1589,54 @@ class DatabaseManager: offset = (page - 1) * page_size - # Get honeypot triggers grouped by IP - results = ( - session.query(AccessLog.ip, AccessLog.path) + # 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) - .all() ) + base_query = self._public_ip_filter(base_query, AccessLog.ip, server_ip) + base_query = base_query.group_by(AccessLog.ip) - # 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() - ] + # Get total count of distinct honeypot IPs + total_honeypots = base_query.count() + # Apply sorting 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") - ) + 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() - total_honeypots = len(honeypot_list) - paginated = honeypot_list[offset : offset + page_size] - total_pages = (total_honeypots + page_size - 1) // page_size + ip_rows = base_query.order_by(order_expr).offset(offset).limit(page_size).all() + + # 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) + + # 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, @@ -1736,6 +1735,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 @@ -1754,39 +1756,34 @@ class DatabaseManager: offset = (page - 1) * page_size - results = ( - 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() - ) + 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, - "category": row.category or "unknown", - } - 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, @@ -1820,28 +1817,27 @@ class DatabaseManager: try: offset = (page - 1) * page_size - results = ( - session.query(AccessLog.path, func.count(AccessLog.id).label("count")) + 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 + + # Build query with SQL-level sorting and pagination + query = ( + session.query(AccessLog.path, count_col) .group_by(AccessLog.path) - .all() ) - # Create list and sort - paths_list = [{"path": row.path, "count": row.count} for row in results] - if sort_by == "count": - paths_list.sort( - key=lambda x: x["count"], reverse=(sort_order == "desc") - ) - else: # sort by path - paths_list.sort(key=lambda x: x["path"], 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() - 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, @@ -1875,33 +1871,40 @@ 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, From 95ab55c4283cb95c106ddd7365b4ee7a4ad1b1ab Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 1 Mar 2026 15:57:54 +0100 Subject: [PATCH 13/25] lint code --- src/database.py | 54 +++++++++++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/src/database.py b/src/database.py index 39b4581..e3c1406 100644 --- a/src/database.py +++ b/src/database.py @@ -1391,11 +1391,7 @@ class DatabaseManager: 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() - ) + results = query.order_by(IpStats.total_requests.desc()).limit(limit).all() return [(row.ip, row.total_requests) for row in results] finally: @@ -1591,9 +1587,8 @@ class DatabaseManager: # 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 = 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) @@ -1603,11 +1598,17 @@ class DatabaseManager: # Apply sorting if sort_by == "count": - order_expr = count_col.desc() if sort_order == "desc" else count_col.asc() + 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() + 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() + ip_rows = ( + base_query.order_by(order_expr).offset(offset).limit(page_size).all() + ) # Fetch distinct paths only for the paginated IPs paginated_ips = [row.ip for row in ip_rows] @@ -1820,18 +1821,23 @@ class DatabaseManager: 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 - - # Build query with SQL-level sorting and pagination - query = ( - session.query(AccessLog.path, count_col) - .group_by(AccessLog.path) + total_paths = ( + session.query(func.count(distinct(AccessLog.path))).scalar() or 0 ) + # Build query with SQL-level sorting and pagination + query = session.query(AccessLog.path, count_col).group_by(AccessLog.path) + if sort_by == "count": - order_expr = count_col.desc() if sort_order == "desc" else count_col.asc() + 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() + order_expr = ( + AccessLog.path.desc() + if sort_order == "desc" + else AccessLog.path.asc() + ) results = query.order_by(order_expr).offset(offset).limit(page_size).all() total_pages = max(1, (total_paths + page_size - 1) // page_size) @@ -1879,7 +1885,8 @@ class DatabaseManager: total_uas = ( session.query(func.count(distinct(AccessLog.user_agent))) .filter(*base_filter) - .scalar() or 0 + .scalar() + or 0 ) # Build query with SQL-level sorting and pagination @@ -1890,10 +1897,13 @@ class DatabaseManager: ) if sort_by == "count": - order_expr = count_col.desc() if sort_order == "desc" else count_col.asc() + order_expr = ( + count_col.desc() if sort_order == "desc" else count_col.asc() + ) else: order_expr = ( - AccessLog.user_agent.desc() if sort_order == "desc" + AccessLog.user_agent.desc() + if sort_order == "desc" else AccessLog.user_agent.asc() ) From cecdbda1d9a6ebe5c3c3c2a753de56f98153564f Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 1 Mar 2026 15:58:32 +0100 Subject: [PATCH 14/25] Bundle of static resources instead of external loading --- src/templates/jinja2/base.html | 17 +++-- .../vendor/css/MarkerCluster.Default.css | 60 ++++++++++++++++++ .../static/vendor/css/MarkerCluster.css | 14 ++++ .../static/vendor/css/images/layers-2x.png | Bin 0 -> 1259 bytes .../static/vendor/css/images/layers.png | Bin 0 -> 696 bytes .../vendor/css/images/marker-icon-2x.png | Bin 0 -> 2464 bytes .../static/vendor/css/images/marker-icon.png | Bin 0 -> 1466 bytes .../vendor/css/images/marker-shadow.png | Bin 0 -> 618 bytes .../static/vendor/css/leaflet.min.css | 1 + src/templates/static/vendor/js/alpine.min.js | 5 ++ src/templates/static/vendor/js/chart.min.js | 13 ++++ src/templates/static/vendor/js/htmx.min.js | 1 + .../static/vendor/js/leaflet.markercluster.js | 2 + src/templates/static/vendor/js/leaflet.min.js | 1 + 14 files changed, 105 insertions(+), 9 deletions(-) create mode 100644 src/templates/static/vendor/css/MarkerCluster.Default.css create mode 100644 src/templates/static/vendor/css/MarkerCluster.css create mode 100644 src/templates/static/vendor/css/images/layers-2x.png create mode 100644 src/templates/static/vendor/css/images/layers.png create mode 100644 src/templates/static/vendor/css/images/marker-icon-2x.png create mode 100644 src/templates/static/vendor/css/images/marker-icon.png create mode 100644 src/templates/static/vendor/css/images/marker-shadow.png create mode 100644 src/templates/static/vendor/css/leaflet.min.css create mode 100644 src/templates/static/vendor/js/alpine.min.js create mode 100644 src/templates/static/vendor/js/chart.min.js create mode 100644 src/templates/static/vendor/js/htmx.min.js create mode 100644 src/templates/static/vendor/js/leaflet.markercluster.js create mode 100644 src/templates/static/vendor/js/leaflet.min.js diff --git a/src/templates/jinja2/base.html b/src/templates/jinja2/base.html index 48097f0..22105c4 100644 --- a/src/templates/jinja2/base.html +++ b/src/templates/jinja2/base.html @@ -5,16 +5,15 @@ Krawl Dashboard - - - - + + + - - - - - + + + + + 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 0000000000000000000000000000000000000000..200c333dca9652ac4cba004d609e5af4eee168c1 GIT binary patch literal 1259 zcmVFhCYNy;#0irRPomHqW|G1C*;4?@4#E?jH>?v@U%cy?3dQAc-DchXVErpOh~ z-jbon+tNbnl6hoEb;)TVk+%hTDDi_G%i3*RZ&15!$Fjr^f;Ke&A@|?=`2&+{zr+3a z{D*=t(`AXyS%X7N z%a#RZw6vD^t_rnM`L4E>m=U&R!A-&}nZIi$BOPvkhrCuUe@BN~-lRD)f44;J%TwgE zcze8u!PQ_NR7?o(NylLXVTfDO zxs5=@|GsYEsNo4M#nT%N!UE(?dnS)t2+{ELYAFp*3=iF=|EQnTp`#vlSXuGVraYo? z+RCzXo6h3qA8{KG?S4nE(lM+;Eb4nT3XV;7gcAxUi5m)`k5tv}cPy()8ZR3TLW3I- zAS^}cq-IJvL7a4RgR!yk@~RT%$lA7{L5ES*hyx)M4(yxI$Ub(4f)K|^v1>zvwQY!_ zIrWw8q9GS^!Dp~}+?mbnB6jDF8mVlbQ!jFKDY;w=7;XO{9bq7>LXGK24WA`;rL)_Z z)&j}pbV(;6gY;VMhbxgvn`X;6x}VUEE-7 z%)7j-%t8S=ZL3yc)HbXDAqJZvBTPoiW_A-+a8m3_Z?v{DN7Tnr#O_VUMT0UBt$;p` zDh6JbGHN8JJ*JN%y2%msb97@_S>9!%Egwk;?PEkU9ntz&3uR}%Fj5d$JHQbQb3}a{ zSzFT^#n=VInPpcAS}CNxj?_ zVscANk5Cfz(51EI1pz};AWWb|kgbYNb4wCEGUn3+eMUMV?1-{=I4TlmLJMot@rd07 zZuo2hk1ccu{YmGkcYdWAVdk{Z4Nm?^cTD&}jGm+Q1SYIXMwmG*oO*83&#>l%nbR`G zhh=lZ%xIb7kU3#;TBbfECrnC9P=-XpL|TG2BoZdj61*XiFbW8?1Z_wp%#;>${SUIy V$8qr;L*)Pf002ovPDHLkV1hYLS~36t literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1a72e5784b2b456eac5d7670738db80697af3377 GIT binary patch literal 696 zcmV;p0!RIcP)*@&l2<6p=!C&s@#ZL+%BQvF&b?w6S%wp=I>1QHj7AP5C)IWy#b znXXB;g;j=$a-tW89K%FbDceHVq&unY*Wx3L#=EGWH=rjqnp|4c_Ulec!ql3#G-5ZF zVlbBA@XP=)C8U&+Lrc)S4O5%1$&{(;7R^K(CSnvSr$v;+B$8q&7Bf|h$#PARo1^%M zf1H^nG-EiXVXr07OH(*8R)xa|FD;lXUlg_-%)~ZGsL2cX0NXaAzN2q%jqLRR6ruVk8`Jb7n#{`T;o@`F= z#3YcynIR^s83UNF3D!f5m#Mg)NJ24&Qfrqb&_z=yF;=B)#9Iq7u-@^O!(mW{D;qvr zPc)gVb%aowtS8m@ElL4A9G>w#ffQ~q{i&_i)*6f^)Sz|C?C>zb4Uo?H<-&Hz@a?J; z$ml@zGygWofb9$ZBj6aLjpLhsT2AzjOu=-*u_gSCUYnU^5s62$4H-fe}gSR(=wKRaTHh!@*b)YV6mo|a4Fn6Rgc&Rpk zvn_X|3VY?v=>nJ{slE^V1GaGWk}m@aIWGIpghbfPh8m@aIWEo_%AZI>==moIFVE^L=C zZJ91?mo03UEp3-BY?wBGur6$uD{Yr9Y?m%SHF8Fk1pc(Nva%QJ+{FLkalfypz3&M|||Fn`7|g3c~4(nXHKFmRnwn$J#_$xE8i z|Ns9!kC;(oC1qQk>LMp3_a2(odYyMT@>voX=UI)k>1cJdn;gjmJ-|6v4nb1Oryh)eQMwHP(i@!36%vGJyFK(JTj?Vb{{C=jx&)@1l zlFmnw%0`&bqruifkkHKC=vbiAM3&E`#Mv>2%tw;VK8?_|&E89cs{a1}$J*!f_xd-C z&F%B|oxRgPlh0F!txkxrQjNA`m9~?&&|jw4W0<`_iNHsX$VQXVK!B}Xkh4>av|f_8 zLY2?t?ejE=%(TnfV5iqOjm?d;&qI~ZGl|SzU77a)002XDQchC<95+*MjE@82?VLm= z3xf6%Vd@99z|q|-ua5l3kJxvZwan-8K1cPiwQAtlcNX~ZqLeoMB+a;7)WA|O#HOB% zg6SX;754xD1{Fy}K~#8Ntklac&zTpadXZ& zC*_=T&g7hfbI$R?v%9?sknIb97gJOJ=`-8YyS3ndqN+Jm+x33!p&Hc@@L$w))s2@N ztv~i}Emc?DykgwFWwma($8+~b>l?tqj$dh13R^nMZnva9 zn0Vflzv2Dvp`oVQw{Guby~i`JGbyBGTEC{y>yzCkg>K&CIeQ$u;lyQ+M{O~gEJ^)Z zrF3p)^>|uT;57}WY&IRwyOQ=dq%Az}_t=_hKowP!Z79q0;@Zu(SWEJJcHY+5T6I({ zw)wj*SNi4wrd+POUfZe4gF77vW?j zoFS}|r2n&$U9Y!S4VEOyN}OpZZi|?cr1VcE_tHsDQgp-ga(SwkBrkCm{|*-yb=}ZW zvcYvLvfA90TPn|!-TuYJV<6`}+RJeRgP3EA=qQcF9k0*#*{f&I_pjam%I6Dd#YE|G zqB!R}tW-K!wV1w+4JcFA_s6~=@9F&j8`u$-ifLN3vK;`lvaA-`jRn_}(8|)!3?-}I zvFi{H;@A$gEZYh?%|Qr_y#*UkOPjwiRCsJQ>mb6h5yGIk6C5_XA=8T?IBfm_?+P0; zhhUs)-(0R*H<&Kku(1>#cGtOpk&Z&kQcw&SJv-4VY<+;=8hYnoX zfNJMCa9)^5Z0;2dCUk;x-%#yS!I~Jr3pNuI!g_tHz!$hKwt1GL~sFvx)3u4TA zv>CLGdQtoZ7Du7ctJRfTqY;FPxs1G{ZJ?73D5J@OO{6BHcPbk{_mjg&p2QFeke%QI zlAJ-kvjuwy1<5D-6>su68A+i998aSZNnQX)+Q}6(GK-C%8G-!1bOJBONU{gT%IOOE z;Yk24YC@^lFW77>r6x7eS1Omc;8=GUp#&zLQ&L{ zv8$hGC`wp~$9pR>f%-_Ps3>YhzP(+vC(E*zr1CVO8ChN^MI-VGMX7+|(r!SGZ9gd5 zzO9sQd>sm|f1|X&oh=8lOzd6+ITvo zCXInR?>RZ#>Hb*PO=7dI!dZ(wY4O}ZGv zdfQFio7+0~PN*RFCZGM6@9-o~y*@?;k00NvOsw54t1^tt{*ATMs^2j}4Wp=4t3RH* z_+8b`F-{E=0sOgM<;VHTo!Ij3u zmmI`2?K7g(GOcGA)@h?$SW&pwHdtj1n57PLI8&6RHhx4R%Q7b z^JEqR)@06V!pbS*@D_ZyRMo_LlT}r{#sXOx4kM-V<_V{!5SSuM^SIVCA37|nY7LWQ zZA#B1h4l`6asz=Lvax_#GMRX|NF>=$=p{Qn0i@ExX1jGhy@B8a*_uR+ODEbVi8ObL zezG?azy>E~S~dl43&8<$(2H}P&*tuBdESUP83KQ?8B z?K(!uS>H1wlWQz;qOfB`T#TZ=EoSp~vZ5XtCvwm1h*Ex6mzTsn_y@_=xREIslV-%- zpdWkEzMjeNOGWrSM32gpBt27*O29NdhGzuDgYxcf`Jjjqw@B;Vmdb@fxdhCRi`Kg> zmUTr$=&@#i!%F4Q6mb&4QKfR^95KJ!<6~fqx-f^66AV!|ywG{6D^Vay-3b99>XOe# e-I|>x8~*?ZhF3snGbtJX0000cOl4 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..950edf24677ded147df13b26f91baa2b0fa70513 GIT binary patch literal 1466 zcmV;r1x5OaP)P001cn1^@s6z>|W`000GnNklGNuHDcIX17Zdjl&3`L?0sTjIws<{((Dh&g-s0<@jYQyl?D*X^?%13;ml^gy> ziMrY_^1WI=(g@LMizu=zCoA>C`6|QEq1eV92k*7m>G65*&@&6)aC&e}G zI)pf-Za|N`DT&Cn1J|o`19mumxW~hiKiKyc-P`S@q)rdTo84@QI@;0yXrG%9uhI>A zG5QHb6s4=<6xy{1 z@NMxEkryp{LS44%z$3lP^cX!9+2-;CTt3wM4(k*#C{aiIiLuB>jJj;KPhPzIC00bL zU3a#;aJld94lCW=`4&aAy8M7PY=HQ>O%$YEP4c4UY#CRxfgbE~(|uiI=YS8q;O9y6 zmIkXzR`}p7ti|PrM3a}WMnR=3NVnWdAAR>b9X@)DKL6=YsvmH%?I24wdq?Gh54_;# z$?_LvgjEdspdQlft#4CQ z`2Zyvy?*)N1Ftw|{_hakhG9WjS?Az@I@+IZ8JbWewR!XUK4&6346+d#~gsE0SY(LX8&JfY>Aj)RxGy96nwhs2rv zzW6pTnMpFkDSkT*a*6Dx|u@ds6ISVn0@^RmIsKZ5Y;bazbc;tTSq(kg(=481ODrPyNB6n z-$+U}(w$m6U6H$w17Bw+wDaFIe~GvNMYvnw31MpY0eQKT9l>SU``8k7w4)z!GZKMI z#_cEKq7k~i%nlK@6c-K?+R;B#5$?T#YpKD`t_4bAs^#E+@5QW$@OX3*`;(#{U^d-vY)&xEE>n5lYl&T?Amke9$Lam@{1K@O ze*LXqlKQHiv=gx+V^Cbb2?z@ISBQ*3amF;9UJ3SBg(N|710TLamQmYZ&Qjn2LuO<* zCZlB4n%@pc&7NNnY1}x+NWpHlq`OJEo|`aYN9<`RBUB+79g;>dgb6YlfN#kGL?lO_ z!6~M^7sOnbsUkKk<@Ysie&`G>ruxH&Mgy&8;i=A zB9OO!xR{AyODw>DS-q5YM{0ExFEAzt zm>RdS+ssW(-8|?xr0(?$vBVB*%(xDLtq3Hf0I5yFm<_g=W2`QWAax{1rWVH=I!VrP zs(rTFX@W#t$hXNvbgX`gK&^w_YD;CQ!B@e0QbLIWaKAXQe2-kkloo;{iF#6}z!4=W zi$giRj1{ zt;2w`VSCF#WE&*ev7jpsC=6175@(~nTE2;7M-L((0bH@yG}-TB$R~WXd?tA$s3|%y zA`9$sA(>F%J3ioz<-LJl*^o1|w84l>HBR`>3l9c8$5Xr@xCiIQ7{x$fMCzOk_-M=% z+{a_Q#;42`#KfUte@$NT77uaTz?b-fBe)1s5XE$yA79fm?KqM^VgLXD07*qoM6N<$ Ef<_J(9smFU literal 0 HcmV?d00001 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 ` -{% 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..42bdcf1 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/_ip_detail.html @@ -0,0 +1,282 @@ +{# 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 %} + +{# Access History table #} +
+

Access History

+
+
Loading...
+
+
+ +{# Inline init script #} + diff --git a/src/templates/jinja2/dashboard/partials/ip_insight.html b/src/templates/jinja2/dashboard/partials/ip_insight.html index 8c8ab13..e7977b7 100644 --- a/src/templates/jinja2/dashboard/partials/ip_insight.html +++ b/src/templates/jinja2/dashboard/partials/ip_insight.html @@ -1,263 +1,5 @@ -{# HTMX fragment: IP Insight - full IP detail view for inline display #} +{# HTMX fragment: IP Insight - inline display within dashboard tabs #}
- {# 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 %} -
- -
-

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.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

- {% 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 %} -
- Score: - - {{ 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...
-
-
+ {% set uid = "insight" %} + {% include "dashboard/partials/_ip_detail.html" %}
- -{# Inline script for initializing map and chart after HTMX swap #} - diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css index 2a58750..5074528 100644 --- a/src/templates/static/css/dashboard.css +++ b/src/templates/static/css/dashboard.css @@ -269,8 +269,8 @@ tbody { } .radar-chart { position: relative; - width: 220px; - height: 220px; + width: 280px; + height: 280px; overflow: visible; } .radar-legend { @@ -452,7 +452,7 @@ tbody { animation: fadeIn 0.3s ease-in; } .ip-page-header { - margin-bottom: 24px; + margin-bottom: 20px; } .ip-page-header h1 { display: flex; @@ -471,61 +471,214 @@ tbody { 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: 3fr 2fr; + grid-template-columns: 1fr 1fr; gap: 20px; - align-items: start; + align-items: stretch; } .ip-page-left, .ip-page-right { display: flex; flex-direction: column; gap: 20px; + min-height: 0; } -.ip-info-card h2 { +/* 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; } -.ip-info-grid { - display: grid; - grid-template-columns: 1fr 1fr 1fr; +/* 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-info-section { - padding: 14px 16px; - border-right: 1px solid #21262d; -} -.ip-info-section:last-child { - border-right: none; -} -.ip-info-section h3 { - color: #58a6ff; - font-size: 13px; - font-weight: 600; - margin: 0 0 10px 0; - padding-bottom: 6px; +.ip-dl-row { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 8px 0; border-bottom: 1px solid #21262d; + gap: 16px; } -.ip-info-section .stat-row { - padding: 3px 0; +.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; } -.blocklist-badges { +.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: 5px; - max-height: 120px; - overflow-y: auto; + gap: 6px; } + +/* Flags & badges */ .ip-flag { display: inline-block; background: #1c2128; - border: 1px solid #30363d; + border: 1px solid #f0883e4d; border-radius: 4px; - padding: 2px 8px; - font-size: 11px; + padding: 3px 10px; + font-size: 12px; color: #f0883e; - margin-right: 4px; + font-weight: 500; } .reputation-score { font-weight: 700; @@ -533,29 +686,130 @@ tbody { .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; } -.ip-timeline-scroll { - max-height: 280px; - overflow-y: auto; + +/* ── 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-info-grid { - grid-template-columns: 1fr; + .ip-stats-bar { + flex-direction: column; } - .ip-info-section { - border-right: none; - border-bottom: 1px solid #21262d; + .ip-stat-chip { + flex: 1 1 auto; } - .ip-info-section:last-child { - border-bottom: none; + .ip-bottom-row { + flex-direction: column; + } + .ip-tl-content { + flex-direction: column; + gap: 2px; + } + .ip-tl-time { + margin-left: 0; } } 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); From 43d3b96364e81af9b0bc8139e48900a047901513 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 1 Mar 2026 17:36:15 +0100 Subject: [PATCH 17/25] fix: Improve stale IP flagging logic to support forced rescan --- src/tasks/flag_stale_ips.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/tasks/flag_stale_ips.py b/src/tasks/flag_stale_ips.py index 3bf6f82..0428e15 100644 --- a/src/tasks/flag_stale_ips.py +++ b/src/tasks/flag_stale_ips.py @@ -12,21 +12,34 @@ TASK_CONFIG = { "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}" From fbc757f0a6186202e05ac2ab6ef855a3e2d7fc84 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 1 Mar 2026 17:36:29 +0100 Subject: [PATCH 18/25] feat: Enhance logging configuration to support dynamic log levels --- src/app.py | 2 +- src/config.py | 6 ++++++ src/database.py | 27 +++++++++++++++++++++++++++ src/logger.py | 15 +++++++++------ 4 files changed, 43 insertions(+), 7 deletions(-) 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 50fd5a0..2119893 100644 --- a/src/database.py +++ b/src/database.py @@ -848,6 +848,33 @@ class DatabaseManager: 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, 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) From 1b637277456caba72c84457a0c6f285532e4901f Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 1 Mar 2026 17:43:13 +0100 Subject: [PATCH 19/25] fix: Improve command-line argument parsing in test_insert_fake_ips.py --- tests/test_insert_fake_ips.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_insert_fake_ips.py b/tests/test_insert_fake_ips.py index 1eba765..a9a3ea2 100644 --- a/tests/test_insert_fake_ips.py +++ b/tests/test_insert_fake_ips.py @@ -548,12 +548,13 @@ def generate_fake_data( if __name__ == "__main__": import sys - # Allow command-line arguments for customization - num_ips = int(sys.argv[1]) if len(sys.argv) > 1 else 20 - logs_per_ip = int(sys.argv[2]) if len(sys.argv) > 2 else 15 - credentials_per_ip = int(sys.argv[3]) if len(sys.argv) > 3 else 3 # Add --no-cleanup flag to skip database cleanup cleanup = "--no-cleanup" not in sys.argv + # Filter out flags before parsing positional args + positional = [a for a in sys.argv[1:] if not a.startswith("--")] + num_ips = int(positional[0]) if len(positional) > 0 else 20 + logs_per_ip = int(positional[1]) if len(positional) > 1 else 15 + credentials_per_ip = int(positional[2]) if len(positional) > 2 else 3 generate_fake_data( num_ips, From 214c83a852208efcbdbb9f41a6272a66cfad7903 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 1 Mar 2026 17:45:46 +0100 Subject: [PATCH 20/25] fix: Adjust different user agents count for attacker and good crawler scores --- src/tasks/analyze_ips.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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": { From 2fbea80156e4ed0cb5a3bf74b41aa8afe0d32c10 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 1 Mar 2026 17:48:28 +0100 Subject: [PATCH 21/25] feat: Add backup and export configurations to the Krawl config map --- kubernetes/krawl-all-in-one-deploy.yaml | 8 ++++++++ kubernetes/manifests/configmap.yaml | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/kubernetes/krawl-all-in-one-deploy.yaml b/kubernetes/krawl-all-in-one-deploy.yaml index 99e30fc..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 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 From 6ec4e49d10e0510a1dfce25bb14a6ea5925a14ac Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 1 Mar 2026 17:49:04 +0100 Subject: [PATCH 22/25] feat: Add logging configuration to support dynamic log levels in config files --- config.yaml | 3 +++ helm/Chart.yaml | 4 ++-- helm/templates/configmap.yaml | 2 ++ helm/values.yaml | 4 +++- 4 files changed, 10 insertions(+), 3 deletions(-) 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/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 From ed4fe0dcfbf4e6ca0dcb349cf47cdb963c137047 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 1 Mar 2026 18:01:19 +0100 Subject: [PATCH 23/25] feat: Add IP filtering to attack types pagination and detail views --- src/database.py | 19 +++++++++++++++---- src/routes/htmx.py | 5 ++++- .../jinja2/dashboard/partials/_ip_detail.html | 13 +++++++++++++ .../partials/attack_types_table.html | 6 +++--- 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/database.py b/src/database.py index 2119893..7375630 100644 --- a/src/database.py +++ b/src/database.py @@ -1955,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. @@ -1964,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 @@ -1979,18 +1981,27 @@ 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 = ( + count_query = ( session.query(AccessLog) .join(AttackDetection) - .distinct(AccessLog.id) - .count() ) + 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) + 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( diff --git a/src/routes/htmx.py b/src/routes/htmx.py index 976fc35..e4cf1a7 100644 --- a/src/routes/htmx.py +++ b/src/routes/htmx.py @@ -241,10 +241,12 @@ 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) @@ -271,6 +273,7 @@ async def htmx_attacks( "pagination": result["pagination"], "sort_by": sort_by, "sort_order": sort_order, + "ip_filter": ip_filter or "", }, ) diff --git a/src/templates/jinja2/dashboard/partials/_ip_detail.html b/src/templates/jinja2/dashboard/partials/_ip_detail.html index 42bdcf1..1812b1d 100644 --- a/src/templates/jinja2/dashboard/partials/_ip_detail.html +++ b/src/templates/jinja2/dashboard/partials/_ip_detail.html @@ -185,6 +185,19 @@
{% 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

diff --git a/src/templates/jinja2/dashboard/partials/attack_types_table.html b/src/templates/jinja2/dashboard/partials/attack_types_table.html index e149bfc..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,7 +23,7 @@ Attack Types User-Agent Time From 991dca6a9d83679dc14f57b67d93419bb75c1946 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 1 Mar 2026 18:02:22 +0100 Subject: [PATCH 24/25] code linted --- src/database.py | 9 ++------- src/routes/htmx.py | 5 ++++- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/database.py b/src/database.py index 7375630..cbee4a0 100644 --- a/src/database.py +++ b/src/database.py @@ -1987,18 +1987,13 @@ class DatabaseManager: base_filters.append(AccessLog.ip == ip_filter) # Count total unique access logs with attack detections - count_query = ( - session.query(AccessLog) - .join(AttackDetection) - ) + 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) - ) + query = session.query(AccessLog).join(AttackDetection) if base_filters: query = query.filter(*base_filters) query = query.distinct(AccessLog.id) diff --git a/src/routes/htmx.py b/src/routes/htmx.py index e4cf1a7..303bce5 100644 --- a/src/routes/htmx.py +++ b/src/routes/htmx.py @@ -245,7 +245,10 @@ async def htmx_attacks( ): 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, ) From 311e79aba023a57bc988edd5cacfcb46e84d7034 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 1 Mar 2026 18:02:32 +0100 Subject: [PATCH 25/25] fix: Update Trivy action version to 0.31.0 in security scan workflow --- .github/workflows/security-scan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: '.'