diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml
index f24261c..3676817 100644
--- a/helm/templates/deployment.yaml
+++ b/helm/templates/deployment.yaml
@@ -8,6 +8,8 @@ spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
+ strategy:
+ type: Recreate
selector:
matchLabels:
{{- include "krawl.selectorLabels" . | nindent 6 }}
@@ -29,7 +31,7 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- - name: {{ .Chart.Name }}
+ - name: krawl
{{- with .Values.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
diff --git a/kubernetes/krawl-all-in-one-deploy.yaml b/kubernetes/krawl-all-in-one-deploy.yaml
index 767c080..99e30fc 100644
--- a/kubernetes/krawl-all-in-one-deploy.yaml
+++ b/kubernetes/krawl-all-in-one-deploy.yaml
@@ -154,6 +154,8 @@ metadata:
app.kubernetes.io/version: "1.0.0"
spec:
replicas: 1
+ strategy:
+ type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: krawl
diff --git a/kubernetes/manifests/deployment.yaml b/kubernetes/manifests/deployment.yaml
index 4c87a73..aff7469 100644
--- a/kubernetes/manifests/deployment.yaml
+++ b/kubernetes/manifests/deployment.yaml
@@ -10,6 +10,8 @@ metadata:
app.kubernetes.io/version: "1.0.0"
spec:
replicas: 1
+ strategy:
+ type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: krawl
diff --git a/src/database.py b/src/database.py
index 5b8d685..6bd282b 100644
--- a/src/database.py
+++ b/src/database.py
@@ -850,6 +850,72 @@ class DatabaseManager:
except Exception as e:
session.rollback()
raise
+
+ def get_access_logs_paginated(
+ self,
+ page: int = 1,
+ page_size: int = 25,
+ ip_filter: Optional[str] = None,
+ suspicious_only: bool = False,
+ since_minutes: Optional[int] = None,
+ ) -> Dict[str, Any]:
+ """
+ Retrieve access logs with pagination and optional filtering.
+
+ Args:
+ page: Page to retrieve
+ page_size: Number of records for page
+ ip_filter: Filter by IP address
+ suspicious_only: Only return suspicious requests
+ since_minutes: Only return logs from the last N minutes
+
+ Returns:
+ List of access log dictionaries
+ """
+ session = self.session
+ try:
+ offset = (page - 1) * page_size
+ query = session.query(AccessLog).order_by(AccessLog.timestamp.desc())
+
+ if ip_filter:
+ query = query.filter(AccessLog.ip == sanitize_ip(ip_filter))
+ if suspicious_only:
+ query = query.filter(AccessLog.is_suspicious == True)
+ if since_minutes is not None:
+ cutoff_time = datetime.now() - timedelta(minutes=since_minutes)
+ query = query.filter(AccessLog.timestamp >= cutoff_time)
+
+ logs = query.offset(offset).limit(page_size).all()
+ # Get total count of attackers
+ total_access_logs = (
+ session.query(AccessLog)
+ .filter(AccessLog.ip == sanitize_ip(ip_filter))
+ .count()
+ )
+ total_pages = (total_access_logs + page_size - 1) // page_size
+
+ return {
+ "access_logs": [
+ {
+ "id": log.id,
+ "ip": log.ip,
+ "path": log.path,
+ "user_agent": log.user_agent,
+ "method": log.method,
+ "is_suspicious": log.is_suspicious,
+ "is_honeypot_trigger": log.is_honeypot_trigger,
+ "timestamp": log.timestamp.isoformat(),
+ "attack_types": [d.attack_type for d in log.attack_detections],
+ }
+ for log in logs
+ ],
+ "pagination": {
+ "page": page,
+ "page_size": page_size,
+ "total_logs": total_access_logs,
+ "total_pages": total_pages,
+ },
+ }
finally:
self.close_session()
@@ -1018,6 +1084,8 @@ class DatabaseManager:
"region": stat.region,
"region_name": stat.region_name,
"timezone": stat.timezone,
+ "latitude": stat.latitude,
+ "longitude": stat.longitude,
"isp": stat.isp,
"reverse": stat.reverse,
"asn": stat.asn,
@@ -1687,14 +1755,23 @@ class DatabaseManager:
offset = (page - 1) * page_size
results = (
- session.query(AccessLog.ip, func.count(AccessLog.id).label("count"))
- .group_by(AccessLog.ip)
+ session.query(
+ AccessLog.ip,
+ func.count(AccessLog.id).label("count"),
+ IpStats.category,
+ )
+ .outerjoin(IpStats, AccessLog.ip == IpStats.ip)
+ .group_by(AccessLog.ip, IpStats.category)
.all()
)
# Filter out local/private IPs and server IP, then sort
filtered = [
- {"ip": row.ip, "count": row.count}
+ {
+ "ip": row.ip,
+ "count": row.count,
+ "category": row.category or "unknown",
+ }
for row in results
if is_valid_public_ip(row.ip, server_ip)
]
diff --git a/src/routes/api.py b/src/routes/api.py
index 02b52dc..a4e6a7a 100644
--- a/src/routes/api.py
+++ b/src/routes/api.py
@@ -7,7 +7,6 @@ All endpoints are prefixed with the secret dashboard path.
"""
import os
-import json
from fastapi import APIRouter, Request, Response, Query
from fastapi.responses import JSONResponse, PlainTextResponse
diff --git a/src/routes/dashboard.py b/src/routes/dashboard.py
index 6f5773b..081336c 100644
--- a/src/routes/dashboard.py
+++ b/src/routes/dashboard.py
@@ -6,6 +6,8 @@ Renders the main dashboard page with server-side data for initial load.
"""
from fastapi import APIRouter, Request
+from fastapi.responses import JSONResponse
+from logger import get_app_logger
from dependencies import get_db, get_templates
@@ -21,7 +23,7 @@ async def dashboard_page(request: Request):
# Get initial data for server-rendered sections
stats = db.get_dashboard_counts()
- suspicious = db.get_recent_suspicious(limit=20)
+ suspicious = db.get_recent_suspicious(limit=10)
# Get credential count for the stats card
cred_result = db.get_credentials_paginated(page=1, page_size=1)
@@ -37,3 +39,36 @@ async def dashboard_page(request: Request):
"suspicious_activities": suspicious,
},
)
+
+
+@router.get("/ip/{ip_address:path}")
+async def ip_page(ip_address: str, request: Request):
+ db = get_db()
+ try:
+ stats = db.get_ip_stats_by_ip(ip_address)
+ config = request.app.state.config
+ dashboard_path = "/" + config.dashboard_secret_path.lstrip("/")
+
+ if stats:
+ # Transform fields for template compatibility
+ list_on = stats.get("list_on") or {}
+ stats["blocklist_memberships"] = list(list_on.keys()) if list_on else []
+ stats["reverse_dns"] = stats.get("reverse")
+
+ templates = get_templates()
+ return templates.TemplateResponse(
+ "dashboard/ip.html",
+ {
+ "request": request,
+ "dashboard_path": dashboard_path,
+ "stats": stats,
+ "ip_address": ip_address,
+ },
+ )
+ else:
+ return JSONResponse(
+ content={"error": "IP not found"},
+ )
+ except Exception as e:
+ get_app_logger().error(f"Error fetching IP stats: {e}")
+ return JSONResponse(content={"error": str(e)})
diff --git a/src/routes/htmx.py b/src/routes/htmx.py
index 0023598..976fc35 100644
--- a/src/routes/htmx.py
+++ b/src/routes/htmx.py
@@ -58,7 +58,7 @@ async def htmx_top_ips(
):
db = get_db()
result = db.get_top_ips_paginated(
- page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order
+ page=max(1, page), page_size=8, sort_by=sort_by, sort_order=sort_order
)
templates = get_templates()
@@ -167,6 +167,42 @@ async def htmx_attackers(
)
+# ── Access logs by ip ────────────────────────────────────────────────────────
+
+
+@router.get("/htmx/access-logs")
+async def htmx_access_logs_by_ip(
+ request: Request,
+ page: int = Query(1),
+ sort_by: str = Query("total_requests"),
+ sort_order: str = Query("desc"),
+ ip_filter: str = Query("ip_filter"),
+):
+ db = get_db()
+ result = db.get_access_logs_paginated(
+ page=max(1, page), page_size=25, ip_filter=ip_filter
+ )
+
+ # Normalize pagination key (DB returns total_attackers, template expects total)
+ pagination = result["pagination"]
+ if "total_access_logs" in pagination and "total" not in pagination:
+ pagination["total"] = pagination["total_access_logs"]
+
+ templates = get_templates()
+ return templates.TemplateResponse(
+ "dashboard/partials/access_by_ip_table.html",
+ {
+ "request": request,
+ "dashboard_path": _dashboard_path(request),
+ "items": result["access_logs"],
+ "pagination": pagination,
+ "sort_by": sort_by,
+ "sort_order": sort_order,
+ "ip_filter": ip_filter,
+ },
+ )
+
+
# ── Credentials ──────────────────────────────────────────────────────
@@ -280,6 +316,34 @@ async def htmx_patterns(
)
+# ── IP Insight (full IP page as partial) ─────────────────────────────
+
+
+@router.get("/htmx/ip-insight/{ip_address:path}")
+async def htmx_ip_insight(ip_address: str, request: Request):
+ db = get_db()
+ stats = db.get_ip_stats_by_ip(ip_address)
+
+ if not stats:
+ stats = {"ip": ip_address, "total_requests": "N/A"}
+
+ # Transform fields for template compatibility
+ list_on = stats.get("list_on") or {}
+ stats["blocklist_memberships"] = list(list_on.keys()) if list_on else []
+ stats["reverse_dns"] = stats.get("reverse")
+
+ templates = get_templates()
+ return templates.TemplateResponse(
+ "dashboard/partials/ip_insight.html",
+ {
+ "request": request,
+ "dashboard_path": _dashboard_path(request),
+ "stats": stats,
+ "ip_address": ip_address,
+ },
+ )
+
+
# ── IP Detail ────────────────────────────────────────────────────────
diff --git a/src/templates/jinja2/dashboard/index.html b/src/templates/jinja2/dashboard/index.html
index 83d708c..97e7ce1 100644
--- a/src/templates/jinja2/dashboard/index.html
+++ b/src/templates/jinja2/dashboard/index.html
@@ -56,25 +56,20 @@
{# ==================== OVERVIEW TAB ==================== #}
-
+
- {# Suspicious Activity - server-rendered #}
+ {# Map section #}
+ {% include "dashboard/partials/map_section.html" %}
+
+ {# Suspicious Activity - server-rendered (last 10 requests) #}
{% include "dashboard/partials/suspicious_table.html" %}
- {# Honeypot Triggers - HTMX loaded #}
-
-
Honeypot Triggers by IP
-
-
-
{# Top IPs + Top User-Agents side by side #}
@@ -112,9 +107,6 @@
{# ==================== ATTACKS TAB ==================== #}
- {# Map section #}
- {% include "dashboard/partials/map_section.html" %}
-
{# Attackers table - HTMX loaded #}
Attackers by Total Requests
@@ -137,6 +129,17 @@
+ {# Honeypot Triggers - HTMX loaded #}
+
+
Honeypot Triggers by IP
+
+
+
{# Attack Types table #}
Detected Attack Types
@@ -168,6 +171,19 @@
+ {# ==================== IP INSIGHT TAB ==================== #}
+
+ {# IP Insight content - loaded via HTMX when IP is selected #}
+
+
+
+
Select an IP address from any table to view detailed insights.
+
+
+
+
+
+
{# Raw request modal - Alpine.js #}
{% include "dashboard/partials/raw_request_modal.html" %}
diff --git a/src/templates/jinja2/dashboard/ip.html b/src/templates/jinja2/dashboard/ip.html
new file mode 100644
index 0000000..8a0e70f
--- /dev/null
+++ b/src/templates/jinja2/dashboard/ip.html
@@ -0,0 +1,310 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
+ {# GitHub logo #}
+
+
+ Krawl
+
+
+ {# Back to dashboard link #}
+
+
+ {# Page header #}
+
+
+ {# Main content grid #}
+
+ {# Left column: IP Info + Map #}
+
+ {# IP Information Card #}
+
+
IP Information
+
+
+
Activity
+
+ Total Requests:
+ {{ stats.total_requests | default('N/A') }}
+
+
+ First Seen:
+ {{ stats.first_seen | format_ts }}
+
+
+ Last Seen:
+ {{ stats.last_seen | format_ts }}
+
+ {% if stats.last_analysis %}
+
+ Last Analysis:
+ {{ stats.last_analysis | format_ts }}
+
+ {% endif %}
+
+
+
+
Location
+ {% if stats.city %}
+
+ City:
+ {{ stats.city | e }}
+
+ {% endif %}
+ {% if stats.region_name %}
+
+ Region:
+ {{ stats.region_name | e }}
+
+ {% endif %}
+ {% if stats.country %}
+
+ Country:
+ {{ stats.country | e }} ({{ stats.country_code | default('') | e }})
+
+ {% endif %}
+ {% if stats.timezone %}
+
+ Timezone:
+ {{ stats.timezone | e }}
+
+ {% endif %}
+
+
+
+
Network
+ {% if stats.isp %}
+
+ ISP:
+ {{ stats.isp | e }}
+
+ {% endif %}
+ {% if stats.asn_org %}
+
+ Organization:
+ {{ stats.asn_org | e }}
+
+ {% endif %}
+ {% if stats.asn %}
+
+ ASN:
+ AS{{ stats.asn }}
+
+ {% endif %}
+ {% if stats.reverse_dns %}
+
+ Reverse DNS:
+ {{ stats.reverse_dns | e }}
+
+ {% endif %}
+
+
+
+
Flags & Reputation
+ {% set flags = [] %}
+ {% if stats.is_proxy %}{% set _ = flags.append('Proxy') %}{% endif %}
+ {% if stats.is_hosting %}{% set _ = flags.append('Hosting') %}{% endif %}
+ {% if flags %}
+
+ Flags:
+
+ {% for flag in flags %}
+ {{ flag }}
+ {% endfor %}
+
+
+ {% endif %}
+ {% if stats.reputation_score is not none %}
+
+ Reputation:
+
+ {{ stats.reputation_score }}/100
+
+
+ {% endif %}
+ {% if stats.blocklist_memberships %}
+
+
Listed On:
+
+ {% for bl in stats.blocklist_memberships %}
+ {{ bl | e }}
+ {% endfor %}
+
+
+ {% else %}
+
+ Blocklists:
+ Clean
+
+ {% endif %}
+
+
+
+
+ {# Single IP Map #}
+
+
+
+ {# Right column: Radar Chart + Timeline #}
+
+ {# Category Analysis Card #}
+ {% if stats.category_scores %}
+
+ {% 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 #}
+
+
+ {# Raw Request Modal #}
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/src/templates/jinja2/dashboard/partials/access_by_ip_table.html b/src/templates/jinja2/dashboard/partials/access_by_ip_table.html
new file mode 100644
index 0000000..5e7bd6c
--- /dev/null
+++ b/src/templates/jinja2/dashboard/partials/access_by_ip_table.html
@@ -0,0 +1,63 @@
+{# HTMX fragment: Detected Access logs by ip table #}
+
+
+
+
+ | # |
+ Path |
+ User-Agent |
+
+ Time
+ |
+ |
+
+
+
+ {% for log in items %}
+
+ | {{ loop.index + (pagination.page - 1) * pagination.page_size }} |
+
+
+ {{ log.path | e }}
+ {% if log.path | length > 30 %}
+ {{ log.path | e }}
+ {% endif %}
+
+ |
+ {{ (log.user_agent | default(''))[:50] | e }} |
+ {{ log.timestamp | format_ts }} |
+
+ {% if log.id %}
+
+ {% endif %}
+ |
+
+
+ |
+
+ |
+
+ {% else %}
+ | 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 befd6e6..e149bfc 100644
--- a/src/templates/jinja2/dashboard/partials/attack_types_table.html
+++ b/src/templates/jinja2/dashboard/partials/attack_types_table.html
@@ -28,7 +28,7 @@
hx-swap="innerHTML">
Time
-
Actions |
+
|
@@ -60,10 +60,13 @@
{{ (attack.user_agent | default(''))[:50] | e }} |
{{ attack.timestamp | format_ts }} |
-
+ |
{% if attack.log_id %}
{% endif %}
+
|
diff --git a/src/templates/jinja2/dashboard/partials/attackers_table.html b/src/templates/jinja2/dashboard/partials/attackers_table.html
index 656a341..1bcbb40 100644
--- a/src/templates/jinja2/dashboard/partials/attackers_table.html
+++ b/src/templates/jinja2/dashboard/partials/attackers_table.html
@@ -36,6 +36,7 @@
hx-swap="innerHTML">
Last Seen
| Location |
+ |
@@ -53,9 +54,14 @@
{{ ip.first_seen | format_ts }} |
{{ ip.last_seen | format_ts }} |
{{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }} |
+
+
+ |
- |
+ |
diff --git a/src/templates/jinja2/dashboard/partials/credentials_table.html b/src/templates/jinja2/dashboard/partials/credentials_table.html
index 49c3abc..c7ee193 100644
--- a/src/templates/jinja2/dashboard/partials/credentials_table.html
+++ b/src/templates/jinja2/dashboard/partials/credentials_table.html
@@ -28,6 +28,7 @@
hx-swap="innerHTML">
Time
+ | |
@@ -45,9 +46,14 @@
{{ cred.password | default('N/A') | e }} |
{{ cred.path | default('') | e }} |
{{ cred.timestamp | format_ts }} |
+
+
+ |
- |
+ |
diff --git a/src/templates/jinja2/dashboard/partials/honeypot_table.html b/src/templates/jinja2/dashboard/partials/honeypot_table.html
index 53ac150..302df69 100644
--- a/src/templates/jinja2/dashboard/partials/honeypot_table.html
+++ b/src/templates/jinja2/dashboard/partials/honeypot_table.html
@@ -25,6 +25,7 @@
hx-swap="innerHTML">
Honeypot Triggers
+ | |
@@ -39,9 +40,14 @@
{{ item.ip | e }}
{{ item.count }} |
+
+
+ |
- |
+ |
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 #}
+
+
+ {# 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 %}
+
+ {% 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 #}
+
+
+ {# Access History table #}
+
+
+
+{# Inline script for initializing map and chart after HTMX swap #}
+
diff --git a/src/templates/jinja2/dashboard/partials/suspicious_table.html b/src/templates/jinja2/dashboard/partials/suspicious_table.html
index 97a11c8..333e8df 100644
--- a/src/templates/jinja2/dashboard/partials/suspicious_table.html
+++ b/src/templates/jinja2/dashboard/partials/suspicious_table.html
@@ -8,6 +8,7 @@
| Path |
User-Agent |
Time |
+ |
@@ -22,10 +23,15 @@
{{ activity.path | e }} |
{{ (activity.user_agent | default(''))[:80] | e }} |
- {{ activity.timestamp | format_ts }} |
+ {{ activity.timestamp | format_ts(time_only=True) }} |
+
+
+ |
- |
+ |
diff --git a/src/templates/jinja2/dashboard/partials/top_ips_table.html b/src/templates/jinja2/dashboard/partials/top_ips_table.html
index cbfc959..d4614c2 100644
--- a/src/templates/jinja2/dashboard/partials/top_ips_table.html
+++ b/src/templates/jinja2/dashboard/partials/top_ips_table.html
@@ -19,12 +19,14 @@
|
| # |
IP Address |
+ Category |
Access Count
|
+ |
@@ -38,10 +40,20 @@
@click="toggleIpDetail($event)">
{{ item.ip | e }}
+
+ {% set cat = item.category | default('unknown') %}
+ {% set cat_colors = {'attacker': '#f85149', 'good_crawler': '#3fb950', 'bad_crawler': '#f0883e', 'regular_user': '#58a6ff', 'unknown': '#8b949e'} %}
+
+ |
{{ item.count }} |
+
+
+ |
- |
+ |
diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css
index 4ca038f..a4dae5f 100644
--- a/src/templates/static/css/dashboard.css
+++ b/src/templates/static/css/dashboard.css
@@ -477,6 +477,15 @@ tbody {
color: #58a6ff;
border-bottom-color: #58a6ff;
}
+.tab-button.disabled {
+ color: #484f58;
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+.tab-button.disabled:hover {
+ color: #484f58;
+ background: transparent;
+}
.tab-content {
display: none;
}
diff --git a/src/templates/static/js/dashboard.js b/src/templates/static/js/dashboard.js
index b74a51d..e6e848b 100644
--- a/src/templates/static/js/dashboard.js
+++ b/src/templates/static/js/dashboard.js
@@ -17,19 +17,26 @@ document.addEventListener('alpine:init', () => {
// Chart state
chartLoaded: false,
+ // IP Insight state
+ insightIp: null,
+
init() {
// Handle hash-based tab routing
const hash = window.location.hash.slice(1);
if (hash === 'ip-stats' || hash === 'attacks') {
this.switchToAttacks();
}
+ // ip-insight tab is only accessible via lens buttons, not direct hash navigation
window.addEventListener('hashchange', () => {
const h = window.location.hash.slice(1);
if (h === 'ip-stats' || h === 'attacks') {
this.switchToAttacks();
- } else {
- this.switchToOverview();
+ } else if (h !== 'ip-insight') {
+ // Don't switch away from ip-insight via hash if already there
+ if (this.tab !== 'ip-insight') {
+ this.switchToOverview();
+ }
}
});
},
@@ -38,15 +45,9 @@ document.addEventListener('alpine:init', () => {
this.tab = 'attacks';
window.location.hash = '#ip-stats';
- // Delay initialization to ensure the container is visible and
- // the browser has reflowed after x-show removes display:none.
- // Leaflet and Chart.js need visible containers with real dimensions.
+ // Delay chart initialization to ensure the container is visible
this.$nextTick(() => {
setTimeout(() => {
- if (!this.mapInitialized && typeof initializeAttackerMap === 'function') {
- initializeAttackerMap();
- this.mapInitialized = true;
- }
if (!this.chartLoaded && typeof loadAttackTypesChart === 'function') {
loadAttackTypesChart();
this.chartLoaded = true;
@@ -60,6 +61,31 @@ document.addEventListener('alpine:init', () => {
window.location.hash = '#overview';
},
+ switchToIpInsight() {
+ // Only allow switching if an IP is selected
+ if (!this.insightIp) return;
+ this.tab = 'ip-insight';
+ window.location.hash = '#ip-insight';
+ },
+
+ openIpInsight(ip) {
+ // Set the IP and load the insight content
+ this.insightIp = ip;
+ this.tab = 'ip-insight';
+ window.location.hash = '#ip-insight';
+
+ // Load IP insight content via HTMX
+ this.$nextTick(() => {
+ const container = document.getElementById('ip-insight-htmx-container');
+ if (container && typeof htmx !== 'undefined') {
+ htmx.ajax('GET', `${this.dashboardPath}/htmx/ip-insight/${encodeURIComponent(ip)}`, {
+ target: '#ip-insight-htmx-container',
+ swap: 'innerHTML'
+ });
+ }
+ });
+ },
+
async viewRawRequest(logId) {
try {
const resp = await fetch(
@@ -110,6 +136,19 @@ document.addEventListener('alpine:init', () => {
}));
});
+// Global function for opening IP Insight (used by map popups)
+window.openIpInsight = function(ip) {
+ // Find the Alpine component and call openIpInsight
+ const container = document.querySelector('[x-data="dashboardApp()"]');
+ if (container) {
+ // Try Alpine 3.x API first, then fall back to older API
+ const data = Alpine.$data ? Alpine.$data(container) : (container._x_dataStack && container._x_dataStack[0]);
+ if (data && typeof data.openIpInsight === 'function') {
+ data.openIpInsight(ip);
+ }
+ }
+};
+
// Utility function for formatting timestamps (used by map popups)
function formatTimestamp(isoTimestamp) {
if (!isoTimestamp) return 'N/A';
diff --git a/src/templates/static/js/map.js b/src/templates/static/js/map.js
index 5181295..aa4e5ab 100644
--- a/src/templates/static/js/map.js
+++ b/src/templates/static/js/map.js
@@ -36,14 +36,45 @@ function createClusterIcon(cluster) {
gradientStops.push(`${color} ${start.toFixed(1)}deg ${end.toFixed(1)}deg`);
});
- const size = Math.max(32, Math.min(50, 32 + Math.log2(total) * 5));
- const inner = size - 10;
- const offset = 5; // (size - inner) / 2
+ const size = Math.max(20, Math.min(44, 20 + Math.log2(total) * 4));
+ const centerSize = size - 8;
+ const centerOffset = 4;
+ const ringWidth = 4;
+ const radius = (size / 2) - (ringWidth / 2);
+ const cx = size / 2;
+ const cy = size / 2;
+ const gapDeg = 8;
+
+ // Build SVG arc segments with gaps - glow layer first, then sharp layer
+ let glowSegments = '';
+ let segments = '';
+ let currentAngle = -90;
+ sorted.forEach(([cat, count], idx) => {
+ const sliceDeg = (count / total) * 360;
+ if (sliceDeg < gapDeg) return;
+ const startAngle = currentAngle + (gapDeg / 2);
+ const endAngle = currentAngle + sliceDeg - (gapDeg / 2);
+ const startRad = (startAngle * Math.PI) / 180;
+ const endRad = (endAngle * Math.PI) / 180;
+ const x1 = cx + radius * Math.cos(startRad);
+ const y1 = cy + radius * Math.sin(startRad);
+ const x2 = cx + radius * Math.cos(endRad);
+ const y2 = cy + radius * Math.sin(endRad);
+ const largeArc = (endAngle - startAngle) > 180 ? 1 : 0;
+ const color = categoryColors[cat] || '#8b949e';
+ // Glow layer - subtle
+ glowSegments += ``;
+ // Sharp layer
+ segments += ``;
+ currentAngle += sliceDeg;
+ });
return L.divIcon({
html: `` +
- ` ` +
- ` ${total} ` +
+ ` ` +
+ ` ${total} ` +
` `,
className: 'ip-cluster-icon',
iconSize: L.point(size, size)
@@ -180,11 +211,11 @@ function buildMapMarkers(ips) {
// Single cluster group with custom pie-chart icons
clusterGroup = L.markerClusterGroup({
- maxClusterRadius: 20,
+ maxClusterRadius: 35,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
- disableClusteringAtZoom: 10,
+ disableClusteringAtZoom: 8,
iconCreateFunction: createClusterIcon
});
@@ -309,6 +340,15 @@ function buildMapMarkers(ips) {
`;
}
+ // Add inspect button
+ popupContent += `
+
+ `;
+
popupContent += '';
marker.setPopupContent(popupContent);
} catch (err) {
@@ -332,6 +372,11 @@ function buildMapMarkers(ips) {
Failed to load chart: ${err.message}
+
`;
marker.setPopupContent(errorPopup);
|