Merge branch 'feat/dashboard-single-ip-page' into feat/add-search-bar

This commit is contained in:
Patrick Di Fazio
2026-02-28 18:47:36 +01:00
committed by GitHub
20 changed files with 1028 additions and 47 deletions

View File

@@ -8,6 +8,8 @@ spec:
{{- if not .Values.autoscaling.enabled }} {{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }} replicas: {{ .Values.replicaCount }}
{{- end }} {{- end }}
strategy:
type: Recreate
selector: selector:
matchLabels: matchLabels:
{{- include "krawl.selectorLabels" . | nindent 6 }} {{- include "krawl.selectorLabels" . | nindent 6 }}
@@ -29,7 +31,7 @@ spec:
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}
{{- end }} {{- end }}
containers: containers:
- name: {{ .Chart.Name }} - name: krawl
{{- with .Values.securityContext }} {{- with .Values.securityContext }}
securityContext: securityContext:
{{- toYaml . | nindent 12 }} {{- toYaml . | nindent 12 }}

View File

@@ -154,6 +154,8 @@ metadata:
app.kubernetes.io/version: "1.0.0" app.kubernetes.io/version: "1.0.0"
spec: spec:
replicas: 1 replicas: 1
strategy:
type: Recreate
selector: selector:
matchLabels: matchLabels:
app.kubernetes.io/name: krawl app.kubernetes.io/name: krawl

View File

@@ -10,6 +10,8 @@ metadata:
app.kubernetes.io/version: "1.0.0" app.kubernetes.io/version: "1.0.0"
spec: spec:
replicas: 1 replicas: 1
strategy:
type: Recreate
selector: selector:
matchLabels: matchLabels:
app.kubernetes.io/name: krawl app.kubernetes.io/name: krawl

View File

@@ -850,6 +850,72 @@ class DatabaseManager:
except Exception as e: except Exception as e:
session.rollback() session.rollback()
raise 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: finally:
self.close_session() self.close_session()
@@ -1018,6 +1084,8 @@ class DatabaseManager:
"region": stat.region, "region": stat.region,
"region_name": stat.region_name, "region_name": stat.region_name,
"timezone": stat.timezone, "timezone": stat.timezone,
"latitude": stat.latitude,
"longitude": stat.longitude,
"isp": stat.isp, "isp": stat.isp,
"reverse": stat.reverse, "reverse": stat.reverse,
"asn": stat.asn, "asn": stat.asn,
@@ -1687,14 +1755,23 @@ class DatabaseManager:
offset = (page - 1) * page_size offset = (page - 1) * page_size
results = ( results = (
session.query(AccessLog.ip, func.count(AccessLog.id).label("count")) session.query(
.group_by(AccessLog.ip) AccessLog.ip,
func.count(AccessLog.id).label("count"),
IpStats.category,
)
.outerjoin(IpStats, AccessLog.ip == IpStats.ip)
.group_by(AccessLog.ip, IpStats.category)
.all() .all()
) )
# Filter out local/private IPs and server IP, then sort # Filter out local/private IPs and server IP, then sort
filtered = [ filtered = [
{"ip": row.ip, "count": row.count} {
"ip": row.ip,
"count": row.count,
"category": row.category or "unknown",
}
for row in results for row in results
if is_valid_public_ip(row.ip, server_ip) if is_valid_public_ip(row.ip, server_ip)
] ]

View File

@@ -7,7 +7,6 @@ All endpoints are prefixed with the secret dashboard path.
""" """
import os import os
import json
from fastapi import APIRouter, Request, Response, Query from fastapi import APIRouter, Request, Response, Query
from fastapi.responses import JSONResponse, PlainTextResponse from fastapi.responses import JSONResponse, PlainTextResponse

View File

@@ -6,6 +6,8 @@ Renders the main dashboard page with server-side data for initial load.
""" """
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from logger import get_app_logger
from dependencies import get_db, get_templates from dependencies import get_db, get_templates
@@ -21,7 +23,7 @@ async def dashboard_page(request: Request):
# Get initial data for server-rendered sections # Get initial data for server-rendered sections
stats = db.get_dashboard_counts() 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 # Get credential count for the stats card
cred_result = db.get_credentials_paginated(page=1, page_size=1) cred_result = db.get_credentials_paginated(page=1, page_size=1)
@@ -37,3 +39,36 @@ async def dashboard_page(request: Request):
"suspicious_activities": suspicious, "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)})

View File

@@ -58,7 +58,7 @@ async def htmx_top_ips(
): ):
db = get_db() db = get_db()
result = db.get_top_ips_paginated( 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() 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 ────────────────────────────────────────────────────── # ── 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 ──────────────────────────────────────────────────────── # ── IP Detail ────────────────────────────────────────────────────────

View File

@@ -56,25 +56,20 @@
<div class="tabs-container"> <div class="tabs-container">
<a class="tab-button" :class="{ active: tab === 'overview' }" @click.prevent="switchToOverview()" href="#overview">Overview</a> <a class="tab-button" :class="{ active: tab === 'overview' }" @click.prevent="switchToOverview()" href="#overview">Overview</a>
<a class="tab-button" :class="{ active: tab === 'attacks' }" @click.prevent="switchToAttacks()" href="#ip-stats">Attacks</a> <a class="tab-button" :class="{ active: tab === 'attacks' }" @click.prevent="switchToAttacks()" href="#ip-stats">Attacks</a>
<a class="tab-button" :class="{ active: tab === 'ip-insight', disabled: !insightIp }" @click.prevent="insightIp && switchToIpInsight()" href="#ip-insight">
IP Insight<span x-show="insightIp" x-text="' (' + insightIp + ')'"></span>
</a>
</div> </div>
{# ==================== OVERVIEW TAB ==================== #} {# ==================== OVERVIEW TAB ==================== #}
<div x-show="tab === 'overview'"> <div x-show="tab === 'overview'" x-init="$nextTick(() => { if (!mapInitialized && typeof initializeAttackerMap === 'function') { initializeAttackerMap(); mapInitialized = true; } })">
{# 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" %} {% include "dashboard/partials/suspicious_table.html" %}
{# Honeypot Triggers - HTMX loaded #}
<div class="table-container alert-section">
<h2>Honeypot Triggers by IP</h2>
<div class="htmx-container"
hx-get="{{ dashboard_path }}/htmx/honeypot?page=1"
hx-trigger="load"
hx-swap="innerHTML">
<div class="htmx-indicator">Loading...</div>
</div>
</div>
{# Top IPs + Top User-Agents side by side #} {# Top IPs + Top User-Agents side by side #}
<div style="display: flex; gap: 20px; flex-wrap: wrap;"> <div style="display: flex; gap: 20px; flex-wrap: wrap;">
<div class="table-container" style="flex: 1; min-width: 300px;"> <div class="table-container" style="flex: 1; min-width: 300px;">
@@ -112,9 +107,6 @@
{# ==================== ATTACKS TAB ==================== #} {# ==================== ATTACKS TAB ==================== #}
<div x-show="tab === 'attacks'" x-cloak> <div x-show="tab === 'attacks'" x-cloak>
{# Map section #}
{% include "dashboard/partials/map_section.html" %}
{# Attackers table - HTMX loaded #} {# Attackers table - HTMX loaded #}
<div class="table-container alert-section"> <div class="table-container alert-section">
<h2>Attackers by Total Requests</h2> <h2>Attackers by Total Requests</h2>
@@ -137,6 +129,17 @@
</div> </div>
</div> </div>
{# Honeypot Triggers - HTMX loaded #}
<div class="table-container alert-section">
<h2>Honeypot Triggers by IP</h2>
<div class="htmx-container"
hx-get="{{ dashboard_path }}/htmx/honeypot?page=1"
hx-trigger="revealed"
hx-swap="innerHTML">
<div class="htmx-indicator">Loading...</div>
</div>
</div>
{# Attack Types table #} {# Attack Types table #}
<div class="table-container alert-section"> <div class="table-container alert-section">
<h2>Detected Attack Types</h2> <h2>Detected Attack Types</h2>
@@ -168,6 +171,19 @@
</div> </div>
</div> </div>
{# ==================== IP INSIGHT TAB ==================== #}
<div x-show="tab === 'ip-insight'" x-cloak>
{# IP Insight content - loaded via HTMX when IP is selected #}
<div id="ip-insight-container">
<template x-if="!insightIp">
<div class="table-container" style="text-align: center; padding: 60px 20px;">
<p style="color: #8b949e; font-size: 16px;">Select an IP address from any table to view detailed insights.</p>
</div>
</template>
<div x-show="insightIp" id="ip-insight-htmx-container"></div>
</div>
</div>
{# Raw request modal - Alpine.js #} {# Raw request modal - Alpine.js #}
{% include "dashboard/partials/raw_request_modal.html" %} {% include "dashboard/partials/raw_request_modal.html" %}

View File

@@ -0,0 +1,310 @@
{% extends "base.html" %}
{% block content %}
<div class="container" x-data="dashboardApp()" x-init="init()">
{# GitHub logo #}
<a href="https://github.com/BlessedRebuS/Krawl" target="_blank" class="github-logo">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
<span class="github-logo-text">Krawl</span>
</a>
{# Back to dashboard link #}
<div style="position: absolute; top: 0; right: 0;">
<a href="{{ dashboard_path }}/" class="download-btn" style="text-decoration: none;">
&larr; Back to Dashboard
</a>
</div>
{# Page header #}
<div class="ip-page-header">
<h1>
<span class="ip-address-title">{{ ip_address }}</span>
{% if stats.category %}
<span class="category-badge category-{{ stats.category | lower | replace('_', '-') }}">
{{ stats.category | replace('_', ' ') | title }}
</span>
{% endif %}
</h1>
{% if stats.city or stats.country %}
<p class="ip-location-subtitle">
{{ stats.city | default('') }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) }}
</p>
{% endif %}
</div>
{# Main content grid #}
<div class="ip-page-grid">
{# Left column: IP Info + Map #}
<div class="ip-page-left">
{# IP Information Card #}
<div class="table-container ip-info-card">
<h2>IP Information</h2>
<div class="ip-info-grid">
<div class="ip-info-section">
<h3>Activity</h3>
<div class="stat-row">
<span class="stat-label-sm">Total Requests:</span>
<span class="stat-value-sm">{{ stats.total_requests | default('N/A') }}</span>
</div>
<div class="stat-row">
<span class="stat-label-sm">First Seen:</span>
<span class="stat-value-sm">{{ stats.first_seen | format_ts }}</span>
</div>
<div class="stat-row">
<span class="stat-label-sm">Last Seen:</span>
<span class="stat-value-sm">{{ stats.last_seen | format_ts }}</span>
</div>
{% if stats.last_analysis %}
<div class="stat-row">
<span class="stat-label-sm">Last Analysis:</span>
<span class="stat-value-sm">{{ stats.last_analysis | format_ts }}</span>
</div>
{% endif %}
</div>
<div class="ip-info-section">
<h3>Location</h3>
{% if stats.city %}
<div class="stat-row">
<span class="stat-label-sm">City:</span>
<span class="stat-value-sm">{{ stats.city | e }}</span>
</div>
{% endif %}
{% if stats.region_name %}
<div class="stat-row">
<span class="stat-label-sm">Region:</span>
<span class="stat-value-sm">{{ stats.region_name | e }}</span>
</div>
{% endif %}
{% if stats.country %}
<div class="stat-row">
<span class="stat-label-sm">Country:</span>
<span class="stat-value-sm">{{ stats.country | e }} ({{ stats.country_code | default('') | e }})</span>
</div>
{% endif %}
{% if stats.timezone %}
<div class="stat-row">
<span class="stat-label-sm">Timezone:</span>
<span class="stat-value-sm">{{ stats.timezone | e }}</span>
</div>
{% endif %}
</div>
<div class="ip-info-section">
<h3>Network</h3>
{% if stats.isp %}
<div class="stat-row">
<span class="stat-label-sm">ISP:</span>
<span class="stat-value-sm">{{ stats.isp | e }}</span>
</div>
{% endif %}
{% if stats.asn_org %}
<div class="stat-row">
<span class="stat-label-sm">Organization:</span>
<span class="stat-value-sm">{{ stats.asn_org | e }}</span>
</div>
{% endif %}
{% if stats.asn %}
<div class="stat-row">
<span class="stat-label-sm">ASN:</span>
<span class="stat-value-sm">AS{{ stats.asn }}</span>
</div>
{% endif %}
{% if stats.reverse_dns %}
<div class="stat-row">
<span class="stat-label-sm">Reverse DNS:</span>
<span class="stat-value-sm" style="word-break: break-all;">{{ stats.reverse_dns | e }}</span>
</div>
{% endif %}
</div>
<div class="ip-info-section">
<h3>Flags & Reputation</h3>
{% set flags = [] %}
{% if stats.is_proxy %}{% set _ = flags.append('Proxy') %}{% endif %}
{% if stats.is_hosting %}{% set _ = flags.append('Hosting') %}{% endif %}
{% if flags %}
<div class="stat-row">
<span class="stat-label-sm">Flags:</span>
<span class="stat-value-sm">
{% for flag in flags %}
<span class="ip-flag">{{ flag }}</span>
{% endfor %}
</span>
</div>
{% endif %}
{% if stats.reputation_score is not none %}
<div class="stat-row">
<span class="stat-label-sm">Reputation:</span>
<span class="stat-value-sm reputation-score {% if stats.reputation_score <= 30 %}bad{% elif stats.reputation_score <= 60 %}medium{% else %}good{% endif %}">
{{ stats.reputation_score }}/100
</span>
</div>
{% endif %}
{% if stats.blocklist_memberships %}
<div class="stat-row" style="flex-direction: column; align-items: flex-start; gap: 8px;">
<span class="stat-label-sm">Listed On:</span>
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
{% for bl in stats.blocklist_memberships %}
<span class="reputation-badge">{{ bl | e }}</span>
{% endfor %}
</div>
</div>
{% else %}
<div class="stat-row">
<span class="stat-label-sm">Blocklists:</span>
<span class="reputation-clean">Clean</span>
</div>
{% endif %}
</div>
</div>
</div>
{# Single IP Map #}
<div class="table-container">
<h2>Location</h2>
<div id="single-ip-map" style="height: 300px; border-radius: 6px; border: 1px solid #30363d;"></div>
</div>
</div>
{# Right column: Radar Chart + Timeline #}
<div class="ip-page-right">
{# Category Analysis Card #}
{% if stats.category_scores %}
<div class="table-container">
<h2>Category Analysis</h2>
<div class="radar-chart-container">
<div class="radar-chart" id="ip-radar-chart"></div>
</div>
</div>
{% endif %}
{# Behavior Timeline #}
{% if stats.category_history %}
<div class="table-container">
<h2>Behavior Timeline</h2>
<div class="timeline">
{% for entry in stats.category_history %}
<div class="timeline-item">
<div class="timeline-marker {{ entry.new_category | default('unknown') | replace('_', '-') }}"></div>
<div>
<strong style="color: #e6edf3;">{{ entry.new_category | default('unknown') | replace('_', ' ') | title }}</strong>
{% if entry.old_category %}
<span style="color: #8b949e;"> from {{ entry.old_category | replace('_', ' ') | title }}</span>
{% endif %}
<br><span style="color: #8b949e; font-size: 11px;">{{ entry.timestamp | format_ts }}</span>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{# Access History table #}
<div class="table-container alert-section" style="margin-top: 20px;">
<h2>Access History</h2>
<div class="htmx-container"
hx-get="{{ dashboard_path }}/htmx/access-logs?page=1&ip_filter={{ ip_address }}"
hx-trigger="revealed"
hx-swap="innerHTML">
<div class="htmx-indicator">Loading...</div>
</div>
</div>
{# Raw Request Modal #}
<div class="raw-request-modal" x-show="rawModal.show" @click.self="closeRawModal()" x-cloak>
<div class="raw-request-modal-content">
<div class="raw-request-modal-header">
<h3>Raw Request</h3>
<span class="raw-request-modal-close" @click="closeRawModal()">&times;</span>
</div>
<div class="raw-request-modal-body">
<pre class="raw-request-content" x-text="rawModal.content"></pre>
</div>
<div class="raw-request-modal-footer">
<button class="raw-request-download-btn" @click="downloadRawRequest()">Download</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(function() {
// Initialize radar chart
{% if stats.category_scores %}
const scores = {{ stats.category_scores | tojson }};
const container = document.getElementById('ip-radar-chart');
if (container && typeof generateRadarChart === 'function') {
container.innerHTML = generateRadarChart(scores, 220, true);
}
{% endif %}
// Initialize single IP map
{% if stats.latitude and stats.longitude %}
document.addEventListener('DOMContentLoaded', function() {
setTimeout(function() {
const mapContainer = document.getElementById('single-ip-map');
if (!mapContainer || typeof L === 'undefined') return;
const lat = {{ stats.latitude }};
const lng = {{ stats.longitude }};
const category = '{{ stats.category | default("unknown") | lower }}';
const categoryColors = {
attacker: '#f85149',
bad_crawler: '#f0883e',
good_crawler: '#3fb950',
regular_user: '#58a6ff',
unknown: '#8b949e'
};
const map = L.map('single-ip-map', {
center: [lat, lng],
zoom: 6,
zoomControl: true,
scrollWheelZoom: true
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; CartoDB | &copy; OpenStreetMap contributors',
maxZoom: 19,
subdomains: 'abcd'
}).addTo(map);
const color = categoryColors[category] || '#8b949e';
const markerHtml = `
<div style="
width: 24px;
height: 24px;
background: ${color};
border: 3px solid #fff;
border-radius: 50%;
box-shadow: 0 0 12px ${color}, 0 0 24px ${color}80;
"></div>
`;
const icon = L.divIcon({
html: markerHtml,
iconSize: [24, 24],
className: 'single-ip-marker'
});
L.marker([lat, lng], { icon: icon }).addTo(map);
}, 100);
});
{% else %}
document.addEventListener('DOMContentLoaded', function() {
const mapContainer = document.getElementById('single-ip-map');
if (mapContainer) {
mapContainer.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #8b949e;">Location data not available</div>';
}
});
{% endif %}
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,63 @@
{# HTMX fragment: Detected Access logs by ip table #}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span class="pagination-info">Page {{ pagination.page }}/{{ pagination.total_pages }} &mdash; {{ pagination.total }} total</span>
<div style="display: flex; gap: 8px;">
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/access-logs?page={{ pagination.page - 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}&ip_filter={{ ip_filter }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page <= 1 %}disabled{% endif %}>Prev</button>
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/access-logs?page={{ pagination.page + 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}&ip_filter={{ ip_filter }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page >= pagination.total_pages %}disabled{% endif %}>Next</button>
</div>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>Path</th>
<th>User-Agent</th>
<th class="sortable {% if sort_by == 'timestamp' %}{{ sort_order }}{% endif %}"
hx-get="{{ dashboard_path }}/htmx/access-logs?page=1&sort_by=timestamp&sort_order={% if sort_by == 'timestamp' and sort_order == 'desc' %}asc{% else %}desc{% endif %}&ip_filter={{ ip_filter }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML">
Time
</th>
<th style="width: 100px;"></th>
</tr>
</thead>
<tbody>
{% for log in items %}
<tr class="ip-row" data-ip="{{ log.ip | e }}">
<td class="rank">{{ loop.index + (pagination.page - 1) * pagination.page_size }}</td>
<td>
<div class="path-cell-container">
<span class="path-truncated">{{ log.path | e }}</span>
{% if log.path | length > 30 %}
<div class="path-tooltip">{{ log.path | e }}</div>
{% endif %}
</div>
</td>
<td>{{ (log.user_agent | default(''))[:50] | e }}</td>
<td>{{ log.timestamp | format_ts }}</td>
<td>
{% if log.id %}
<button class="view-btn" @click="viewRawRequest({{ log.id }})">View Request</button>
{% endif %}
</td>
</tr>
<tr class="ip-stats-row" style="display: none;">
<td colspan="5" class="ip-stats-cell">
<div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="5" style="text-align: center;">No logs detected</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -28,7 +28,7 @@
hx-swap="innerHTML"> hx-swap="innerHTML">
Time Time
</th> </th>
<th>Actions</th> <th style="width: 80px;"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -60,10 +60,13 @@
</td> </td>
<td>{{ (attack.user_agent | default(''))[:50] | e }}</td> <td>{{ (attack.user_agent | default(''))[:50] | e }}</td>
<td>{{ attack.timestamp | format_ts }}</td> <td>{{ attack.timestamp | format_ts }}</td>
<td> <td style="display: flex; gap: 6px; flex-wrap: wrap;">
{% if attack.log_id %} {% if attack.log_id %}
<button class="view-btn" @click="viewRawRequest({{ attack.log_id }})">View Request</button> <button class="view-btn" @click="viewRawRequest({{ attack.log_id }})">View Request</button>
{% endif %} {% endif %}
<button class="inspect-btn" @click="openIpInsight('{{ attack.ip | e }}')" title="Inspect IP">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
</button>
</td> </td>
</tr> </tr>
<tr class="ip-stats-row" style="display: none;"> <tr class="ip-stats-row" style="display: none;">

View File

@@ -36,6 +36,7 @@
hx-swap="innerHTML"> hx-swap="innerHTML">
Last Seen</th> Last Seen</th>
<th>Location</th> <th>Location</th>
<th style="width: 40px;"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -53,9 +54,14 @@
<td>{{ ip.first_seen | format_ts }}</td> <td>{{ ip.first_seen | format_ts }}</td>
<td>{{ ip.last_seen | format_ts }}</td> <td>{{ ip.last_seen | format_ts }}</td>
<td>{{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }}</td> <td>{{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }}</td>
<td>
<button class="inspect-btn" @click="openIpInsight('{{ ip.ip | e }}')" title="Inspect IP">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
</button>
</td>
</tr> </tr>
<tr class="ip-stats-row" style="display: none;"> <tr class="ip-stats-row" style="display: none;">
<td colspan="6" class="ip-stats-cell"> <td colspan="7" class="ip-stats-cell">
<div class="ip-stats-dropdown"> <div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div> <div class="loading">Loading stats...</div>
</div> </div>

View File

@@ -28,6 +28,7 @@
hx-swap="innerHTML"> hx-swap="innerHTML">
Time Time
</th> </th>
<th style="width: 40px;"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -45,9 +46,14 @@
<td>{{ cred.password | default('N/A') | e }}</td> <td>{{ cred.password | default('N/A') | e }}</td>
<td>{{ cred.path | default('') | e }}</td> <td>{{ cred.path | default('') | e }}</td>
<td>{{ cred.timestamp | format_ts }}</td> <td>{{ cred.timestamp | format_ts }}</td>
<td>
<button class="inspect-btn" @click="openIpInsight('{{ cred.ip | e }}')" title="Inspect IP">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
</button>
</td>
</tr> </tr>
<tr class="ip-stats-row" style="display: none;"> <tr class="ip-stats-row" style="display: none;">
<td colspan="6" class="ip-stats-cell"> <td colspan="7" class="ip-stats-cell">
<div class="ip-stats-dropdown"> <div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div> <div class="loading">Loading stats...</div>
</div> </div>

View File

@@ -25,6 +25,7 @@
hx-swap="innerHTML"> hx-swap="innerHTML">
Honeypot Triggers Honeypot Triggers
</th> </th>
<th style="width: 40px;"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -39,9 +40,14 @@
{{ item.ip | e }} {{ item.ip | e }}
</td> </td>
<td>{{ item.count }}</td> <td>{{ item.count }}</td>
<td>
<button class="inspect-btn" @click="openIpInsight('{{ item.ip | e }}')" title="Inspect IP">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
</button>
</td>
</tr> </tr>
<tr class="ip-stats-row" style="display: none;"> <tr class="ip-stats-row" style="display: none;">
<td colspan="3" class="ip-stats-cell"> <td colspan="4" class="ip-stats-cell">
<div class="ip-stats-dropdown"> <div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div> <div class="loading">Loading stats...</div>
</div> </div>

View File

@@ -0,0 +1,279 @@
{# HTMX fragment: IP Insight - full IP detail view for inline display #}
<div class="ip-insight-content" id="ip-insight-content">
{# Page header #}
<div class="ip-page-header">
<h1>
<span class="ip-address-title">{{ ip_address }}</span>
{% if stats.category %}
<span class="category-badge category-{{ stats.category | lower | replace('_', '-') }}">
{{ stats.category | replace('_', ' ') | title }}
</span>
{% endif %}
</h1>
{% if stats.city or stats.country %}
<p class="ip-location-subtitle">
{{ stats.city | default('') }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) }}
</p>
{% endif %}
</div>
{# Main content grid #}
<div class="ip-page-grid">
{# Left column: IP Info + Map #}
<div class="ip-page-left">
{# IP Information Card #}
<div class="table-container ip-info-card">
<h2>IP Information</h2>
<div class="ip-info-grid">
<div class="ip-info-section">
<h3>Activity</h3>
<div class="stat-row">
<span class="stat-label-sm">Total Requests:</span>
<span class="stat-value-sm">{{ stats.total_requests | default('N/A') }}</span>
</div>
<div class="stat-row">
<span class="stat-label-sm">First Seen:</span>
<span class="stat-value-sm">{{ stats.first_seen | format_ts }}</span>
</div>
<div class="stat-row">
<span class="stat-label-sm">Last Seen:</span>
<span class="stat-value-sm">{{ stats.last_seen | format_ts }}</span>
</div>
{% if stats.last_analysis %}
<div class="stat-row">
<span class="stat-label-sm">Last Analysis:</span>
<span class="stat-value-sm">{{ stats.last_analysis | format_ts }}</span>
</div>
{% endif %}
</div>
<div class="ip-info-section">
<h3>Location</h3>
{% if stats.city %}
<div class="stat-row">
<span class="stat-label-sm">City:</span>
<span class="stat-value-sm">{{ stats.city | e }}</span>
</div>
{% endif %}
{% if stats.region_name %}
<div class="stat-row">
<span class="stat-label-sm">Region:</span>
<span class="stat-value-sm">{{ stats.region_name | e }}</span>
</div>
{% endif %}
{% if stats.country %}
<div class="stat-row">
<span class="stat-label-sm">Country:</span>
<span class="stat-value-sm">{{ stats.country | e }} ({{ stats.country_code | default('') | e }})</span>
</div>
{% endif %}
{% if stats.timezone %}
<div class="stat-row">
<span class="stat-label-sm">Timezone:</span>
<span class="stat-value-sm">{{ stats.timezone | e }}</span>
</div>
{% endif %}
</div>
<div class="ip-info-section">
<h3>Network</h3>
{% if stats.isp %}
<div class="stat-row">
<span class="stat-label-sm">ISP:</span>
<span class="stat-value-sm">{{ stats.isp | e }}</span>
</div>
{% endif %}
{% if stats.asn_org %}
<div class="stat-row">
<span class="stat-label-sm">Organization:</span>
<span class="stat-value-sm">{{ stats.asn_org | e }}</span>
</div>
{% endif %}
{% if stats.asn %}
<div class="stat-row">
<span class="stat-label-sm">ASN:</span>
<span class="stat-value-sm">AS{{ stats.asn }}</span>
</div>
{% endif %}
{% if stats.reverse_dns %}
<div class="stat-row">
<span class="stat-label-sm">Reverse DNS:</span>
<span class="stat-value-sm" style="word-break: break-all;">{{ stats.reverse_dns | e }}</span>
</div>
{% endif %}
</div>
<div class="ip-info-section">
<h3>Flags & Reputation</h3>
{% set flags = [] %}
{% if stats.is_proxy %}{% set _ = flags.append('Proxy') %}{% endif %}
{% if stats.is_hosting %}{% set _ = flags.append('Hosting') %}{% endif %}
{% if flags %}
<div class="stat-row">
<span class="stat-label-sm">Flags:</span>
<span class="stat-value-sm">
{% for flag in flags %}
<span class="ip-flag">{{ flag }}</span>
{% endfor %}
</span>
</div>
{% endif %}
{% if stats.reputation_score is not none %}
<div class="stat-row">
<span class="stat-label-sm">Reputation:</span>
<span class="stat-value-sm reputation-score {% if stats.reputation_score <= 30 %}bad{% elif stats.reputation_score <= 60 %}medium{% else %}good{% endif %}">
{{ stats.reputation_score }}/100
</span>
</div>
{% endif %}
{% if stats.blocklist_memberships %}
<div class="stat-row" style="flex-direction: column; align-items: flex-start; gap: 8px;">
<span class="stat-label-sm">Listed On:</span>
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
{% for bl in stats.blocklist_memberships %}
<span class="reputation-badge">{{ bl | e }}</span>
{% endfor %}
</div>
</div>
{% else %}
<div class="stat-row">
<span class="stat-label-sm">Blocklists:</span>
<span class="reputation-clean">Clean</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{# Right column: Radar Chart + Timeline #}
<div class="ip-page-right">
{# Category Analysis Card #}
{% if stats.category_scores %}
<div class="table-container">
<h2>Category Analysis</h2>
<div class="radar-chart-container">
<div class="radar-chart" id="insight-radar-chart"></div>
</div>
</div>
{% endif %}
{# Behavior Timeline #}
{% if stats.category_history %}
<div class="table-container">
<h2>Behavior Timeline</h2>
<div class="timeline">
{% for entry in stats.category_history %}
<div class="timeline-item">
<div class="timeline-marker {{ entry.new_category | default('unknown') | replace('_', '-') }}"></div>
<div>
<strong style="color: #e6edf3;">{{ entry.new_category | default('unknown') | replace('_', ' ') | title }}</strong>
{% if entry.old_category %}
<span style="color: #8b949e;"> from {{ entry.old_category | replace('_', ' ') | title }}</span>
{% endif %}
<br><span style="color: #8b949e; font-size: 11px;">{{ entry.timestamp | format_ts }}</span>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{# Single IP Map - full width #}
<div class="table-container" style="margin-top: 20px;">
<h2>Location</h2>
<div id="insight-ip-map" style="height: 300px; border-radius: 6px; border: 1px solid #30363d;"></div>
</div>
{# Access History table #}
<div class="table-container alert-section" style="margin-top: 20px;">
<h2>Access History</h2>
<div class="htmx-container"
hx-get="{{ dashboard_path }}/htmx/access-logs?page=1&ip_filter={{ ip_address }}"
hx-trigger="revealed"
hx-swap="innerHTML">
<div class="htmx-indicator">Loading...</div>
</div>
</div>
</div>
{# Inline script for initializing map and chart after HTMX swap #}
<script>
(function() {
// Initialize radar chart
{% if stats.category_scores %}
const scores = {{ stats.category_scores | tojson }};
const container = document.getElementById('insight-radar-chart');
if (container && typeof generateRadarChart === 'function') {
container.innerHTML = generateRadarChart(scores, 220, true);
}
{% endif %}
// Initialize single IP map
{% if stats.latitude and stats.longitude %}
setTimeout(function() {
const mapContainer = document.getElementById('insight-ip-map');
if (!mapContainer || typeof L === 'undefined') return;
// Clean up any existing map instance
if (mapContainer._leaflet_id) {
mapContainer._leaflet_id = null;
}
mapContainer.innerHTML = '';
const lat = {{ stats.latitude }};
const lng = {{ stats.longitude }};
const category = '{{ stats.category | default("unknown") | lower }}';
const categoryColors = {
attacker: '#f85149',
bad_crawler: '#f0883e',
good_crawler: '#3fb950',
regular_user: '#58a6ff',
unknown: '#8b949e'
};
const map = L.map('insight-ip-map', {
center: [lat, lng],
zoom: 6,
zoomControl: true,
scrollWheelZoom: true
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; CartoDB | &copy; OpenStreetMap contributors',
maxZoom: 19,
subdomains: 'abcd'
}).addTo(map);
const color = categoryColors[category] || '#8b949e';
const markerHtml = `
<div style="
width: 24px;
height: 24px;
background: ${color};
border: 3px solid #fff;
border-radius: 50%;
box-shadow: 0 0 12px ${color}, 0 0 24px ${color}80;
"></div>
`;
const icon = L.divIcon({
html: markerHtml,
iconSize: [24, 24],
className: 'single-ip-marker'
});
L.marker([lat, lng], { icon: icon }).addTo(map);
}, 100);
{% else %}
const mapContainer = document.getElementById('insight-ip-map');
if (mapContainer) {
mapContainer.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #8b949e;">Location data not available</div>';
}
{% endif %}
})();
</script>

View File

@@ -8,6 +8,7 @@
<th>Path</th> <th>Path</th>
<th>User-Agent</th> <th>User-Agent</th>
<th>Time</th> <th>Time</th>
<th style="width: 40px;"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -22,10 +23,15 @@
</td> </td>
<td>{{ activity.path | e }}</td> <td>{{ activity.path | e }}</td>
<td style="word-break: break-all;">{{ (activity.user_agent | default(''))[:80] | e }}</td> <td style="word-break: break-all;">{{ (activity.user_agent | default(''))[:80] | e }}</td>
<td>{{ activity.timestamp | format_ts }}</td> <td>{{ activity.timestamp | format_ts(time_only=True) }}</td>
<td>
<button class="inspect-btn" @click="openIpInsight('{{ activity.ip | e }}')" title="Inspect IP">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
</button>
</td>
</tr> </tr>
<tr class="ip-stats-row" style="display: none;"> <tr class="ip-stats-row" style="display: none;">
<td colspan="4" class="ip-stats-cell"> <td colspan="5" class="ip-stats-cell">
<div class="ip-stats-dropdown"> <div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div> <div class="loading">Loading stats...</div>
</div> </div>

View File

@@ -19,12 +19,14 @@
<tr> <tr>
<th>#</th> <th>#</th>
<th>IP Address</th> <th>IP Address</th>
<th>Category</th>
<th class="sortable {% if sort_by == 'count' %}{{ sort_order }}{% endif %}" <th class="sortable {% if sort_by == 'count' %}{{ sort_order }}{% endif %}"
hx-get="{{ dashboard_path }}/htmx/top-ips?page=1&sort_by=count&sort_order={% if sort_by == 'count' and sort_order == 'desc' %}asc{% else %}desc{% endif %}" hx-get="{{ dashboard_path }}/htmx/top-ips?page=1&sort_by=count&sort_order={% if sort_by == 'count' and sort_order == 'desc' %}asc{% else %}desc{% endif %}"
hx-target="closest .htmx-container" hx-target="closest .htmx-container"
hx-swap="innerHTML"> hx-swap="innerHTML">
Access Count Access Count
</th> </th>
<th style="width: 40px;"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -38,10 +40,20 @@
@click="toggleIpDetail($event)"> @click="toggleIpDetail($event)">
{{ item.ip | e }} {{ item.ip | e }}
</td> </td>
<td>
{% set cat = item.category | default('unknown') %}
{% set cat_colors = {'attacker': '#f85149', 'good_crawler': '#3fb950', 'bad_crawler': '#f0883e', 'regular_user': '#58a6ff', 'unknown': '#8b949e'} %}
<span class="category-dot" style="display: inline-block; width: 12px; height: 12px; border-radius: 50%; background: {{ cat_colors.get(cat, '#8b949e') }};" title="{{ cat | replace('_', ' ') | title }}"></span>
</td>
<td>{{ item.count }}</td> <td>{{ item.count }}</td>
<td>
<button class="inspect-btn" @click="openIpInsight('{{ item.ip | e }}')" title="Inspect IP">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
</button>
</td>
</tr> </tr>
<tr class="ip-stats-row" style="display: none;"> <tr class="ip-stats-row" style="display: none;">
<td colspan="3" class="ip-stats-cell"> <td colspan="5" class="ip-stats-cell">
<div class="ip-stats-dropdown"> <div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div> <div class="loading">Loading stats...</div>
</div> </div>

View File

@@ -477,6 +477,15 @@ tbody {
color: #58a6ff; color: #58a6ff;
border-bottom-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 { .tab-content {
display: none; display: none;
} }

View File

@@ -17,19 +17,26 @@ document.addEventListener('alpine:init', () => {
// Chart state // Chart state
chartLoaded: false, chartLoaded: false,
// IP Insight state
insightIp: null,
init() { init() {
// Handle hash-based tab routing // Handle hash-based tab routing
const hash = window.location.hash.slice(1); const hash = window.location.hash.slice(1);
if (hash === 'ip-stats' || hash === 'attacks') { if (hash === 'ip-stats' || hash === 'attacks') {
this.switchToAttacks(); this.switchToAttacks();
} }
// ip-insight tab is only accessible via lens buttons, not direct hash navigation
window.addEventListener('hashchange', () => { window.addEventListener('hashchange', () => {
const h = window.location.hash.slice(1); const h = window.location.hash.slice(1);
if (h === 'ip-stats' || h === 'attacks') { if (h === 'ip-stats' || h === 'attacks') {
this.switchToAttacks(); this.switchToAttacks();
} else { } else if (h !== 'ip-insight') {
this.switchToOverview(); // 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'; this.tab = 'attacks';
window.location.hash = '#ip-stats'; window.location.hash = '#ip-stats';
// Delay initialization to ensure the container is visible and // Delay chart initialization to ensure the container is visible
// the browser has reflowed after x-show removes display:none.
// Leaflet and Chart.js need visible containers with real dimensions.
this.$nextTick(() => { this.$nextTick(() => {
setTimeout(() => { setTimeout(() => {
if (!this.mapInitialized && typeof initializeAttackerMap === 'function') {
initializeAttackerMap();
this.mapInitialized = true;
}
if (!this.chartLoaded && typeof loadAttackTypesChart === 'function') { if (!this.chartLoaded && typeof loadAttackTypesChart === 'function') {
loadAttackTypesChart(); loadAttackTypesChart();
this.chartLoaded = true; this.chartLoaded = true;
@@ -60,6 +61,31 @@ document.addEventListener('alpine:init', () => {
window.location.hash = '#overview'; 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) { async viewRawRequest(logId) {
try { try {
const resp = await fetch( 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) // Utility function for formatting timestamps (used by map popups)
function formatTimestamp(isoTimestamp) { function formatTimestamp(isoTimestamp) {
if (!isoTimestamp) return 'N/A'; if (!isoTimestamp) return 'N/A';

View File

@@ -36,14 +36,45 @@ function createClusterIcon(cluster) {
gradientStops.push(`${color} ${start.toFixed(1)}deg ${end.toFixed(1)}deg`); 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 size = Math.max(20, Math.min(44, 20 + Math.log2(total) * 4));
const inner = size - 10; const centerSize = size - 8;
const offset = 5; // (size - inner) / 2 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 += `<path d="M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2}" fill="none" stroke="${color}" stroke-width="${ringWidth + 4}" stroke-linecap="round" opacity="0.35" filter="url(#glow)"/>`;
// Sharp layer
segments += `<path d="M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2}" fill="none" stroke="${color}" stroke-width="${ringWidth}" stroke-linecap="round"/>`;
currentAngle += sliceDeg;
});
return L.divIcon({ return L.divIcon({
html: `<div style="position:relative;width:${size}px;height:${size}px;">` + html: `<div style="position:relative;width:${size}px;height:${size}px;">` +
`<div style="position:absolute;top:0;left:0;width:${size}px;height:${size}px;border-radius:50%;background:conic-gradient(${gradientStops.join(', ')});box-shadow:0 0 6px rgba(0,0,0,0.5);"></div>` + `<svg width="${size}" height="${size}" style="position:absolute;top:0;left:0;overflow:visible;">` +
`<div style="position:absolute;top:${offset}px;left:${offset}px;width:${inner}px;height:${inner}px;border-radius:50%;background:rgba(13,17,23,0.85);color:#e6edf3;font-size:11px;font-weight:700;line-height:${inner}px;text-align:center;">${total}</div>` + `<defs><filter id="glow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="2" result="blur"/></filter></defs>` +
`${glowSegments}${segments}</svg>` +
`<div style="position:absolute;top:${centerOffset}px;left:${centerOffset}px;width:${centerSize}px;height:${centerSize}px;border-radius:50%;background:#0d1117;display:flex;align-items:center;justify-content:center;color:#e6edf3;font-family:'SF Mono',Monaco,Consolas,monospace;font-size:${Math.max(9, centerSize * 0.38)}px;font-weight:600;">${total}</div>` +
`</div>`, `</div>`,
className: 'ip-cluster-icon', className: 'ip-cluster-icon',
iconSize: L.point(size, size) iconSize: L.point(size, size)
@@ -180,11 +211,11 @@ function buildMapMarkers(ips) {
// Single cluster group with custom pie-chart icons // Single cluster group with custom pie-chart icons
clusterGroup = L.markerClusterGroup({ clusterGroup = L.markerClusterGroup({
maxClusterRadius: 20, maxClusterRadius: 35,
spiderfyOnMaxZoom: true, spiderfyOnMaxZoom: true,
showCoverageOnHover: false, showCoverageOnHover: false,
zoomToBoundsOnClick: true, zoomToBoundsOnClick: true,
disableClusteringAtZoom: 10, disableClusteringAtZoom: 8,
iconCreateFunction: createClusterIcon iconCreateFunction: createClusterIcon
}); });
@@ -309,6 +340,15 @@ function buildMapMarkers(ips) {
`; `;
} }
// Add inspect button
popupContent += `
<div style="margin-top: 12px; border-top: 1px solid #30363d; padding-top: 12px; text-align: center;">
<button onclick="window.openIpInsight('${ip.ip}')" class="inspect-btn" style="display: inline-flex; align-items: center; gap: 4px; padding: 6px 12px; background: #21262d; color: #58a6ff; border: 1px solid #30363d; border-radius: 4px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.2s;">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
</button>
</div>
`;
popupContent += '</div>'; popupContent += '</div>';
marker.setPopupContent(popupContent); marker.setPopupContent(popupContent);
} catch (err) { } catch (err) {
@@ -332,6 +372,11 @@ function buildMapMarkers(ips) {
<div style="margin-top: 12px; border-top: 1px solid #30363d; padding-top: 12px; text-align: center; color: #f85149; font-size: 11px;"> <div style="margin-top: 12px; border-top: 1px solid #30363d; padding-top: 12px; text-align: center; color: #f85149; font-size: 11px;">
Failed to load chart: ${err.message} Failed to load chart: ${err.message}
</div> </div>
<div style="margin-top: 12px; text-align: center;">
<button onclick="window.openIpInsight('${ip.ip}')" class="inspect-btn" style="display: inline-flex; align-items: center; gap: 4px; padding: 6px 12px; background: #21262d; color: #58a6ff; border: 1px solid #30363d; border-radius: 4px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.2s;">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
</button>
</div>
</div> </div>
`; `;
marker.setPopupContent(errorPopup); marker.setPopupContent(errorPopup);