Merge branch 'feat/dashboard-single-ip-page' into feat/add-search-bar
This commit is contained in:
@@ -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)
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -56,25 +56,20 @@
|
||||
<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 === '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>
|
||||
|
||||
{# ==================== 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" %}
|
||||
|
||||
{# 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 #}
|
||||
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
|
||||
<div class="table-container" style="flex: 1; min-width: 300px;">
|
||||
@@ -112,9 +107,6 @@
|
||||
{# ==================== ATTACKS TAB ==================== #}
|
||||
<div x-show="tab === 'attacks'" x-cloak>
|
||||
|
||||
{# Map section #}
|
||||
{% include "dashboard/partials/map_section.html" %}
|
||||
|
||||
{# Attackers table - HTMX loaded #}
|
||||
<div class="table-container alert-section">
|
||||
<h2>Attackers by Total Requests</h2>
|
||||
@@ -137,6 +129,17 @@
|
||||
</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 #}
|
||||
<div class="table-container alert-section">
|
||||
<h2>Detected Attack Types</h2>
|
||||
@@ -168,6 +171,19 @@
|
||||
</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 #}
|
||||
{% include "dashboard/partials/raw_request_modal.html" %}
|
||||
|
||||
|
||||
310
src/templates/jinja2/dashboard/ip.html
Normal file
310
src/templates/jinja2/dashboard/ip.html
Normal 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;">
|
||||
← 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()">×</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: '© CartoDB | © 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 %}
|
||||
@@ -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 }} — {{ 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>
|
||||
@@ -28,7 +28,7 @@
|
||||
hx-swap="innerHTML">
|
||||
Time
|
||||
</th>
|
||||
<th>Actions</th>
|
||||
<th style="width: 80px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -60,10 +60,13 @@
|
||||
</td>
|
||||
<td>{{ (attack.user_agent | default(''))[:50] | e }}</td>
|
||||
<td>{{ attack.timestamp | format_ts }}</td>
|
||||
<td>
|
||||
<td style="display: flex; gap: 6px; flex-wrap: wrap;">
|
||||
{% if attack.log_id %}
|
||||
<button class="view-btn" @click="viewRawRequest({{ attack.log_id }})">View Request</button>
|
||||
{% 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>
|
||||
</tr>
|
||||
<tr class="ip-stats-row" style="display: none;">
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
hx-swap="innerHTML">
|
||||
Last Seen</th>
|
||||
<th>Location</th>
|
||||
<th style="width: 40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -53,9 +54,14 @@
|
||||
<td>{{ ip.first_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>
|
||||
<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 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="loading">Loading stats...</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
hx-swap="innerHTML">
|
||||
Time
|
||||
</th>
|
||||
<th style="width: 40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -45,9 +46,14 @@
|
||||
<td>{{ cred.password | default('N/A') | e }}</td>
|
||||
<td>{{ cred.path | default('') | e }}</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 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="loading">Loading stats...</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
hx-swap="innerHTML">
|
||||
Honeypot Triggers
|
||||
</th>
|
||||
<th style="width: 40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -39,9 +40,14 @@
|
||||
{{ item.ip | e }}
|
||||
</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 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="loading">Loading stats...</div>
|
||||
</div>
|
||||
|
||||
279
src/templates/jinja2/dashboard/partials/ip_insight.html
Normal file
279
src/templates/jinja2/dashboard/partials/ip_insight.html
Normal 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: '© CartoDB | © 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>
|
||||
@@ -8,6 +8,7 @@
|
||||
<th>Path</th>
|
||||
<th>User-Agent</th>
|
||||
<th>Time</th>
|
||||
<th style="width: 40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -22,10 +23,15 @@
|
||||
</td>
|
||||
<td>{{ activity.path | 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 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="loading">Loading stats...</div>
|
||||
</div>
|
||||
|
||||
@@ -19,12 +19,14 @@
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>IP Address</th>
|
||||
<th>Category</th>
|
||||
<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-target="closest .htmx-container"
|
||||
hx-swap="innerHTML">
|
||||
Access Count
|
||||
</th>
|
||||
<th style="width: 40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -38,10 +40,20 @@
|
||||
@click="toggleIpDetail($event)">
|
||||
{{ item.ip | e }}
|
||||
</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>
|
||||
<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 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="loading">Loading stats...</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 += `<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({
|
||||
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>` +
|
||||
`<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>` +
|
||||
`<svg width="${size}" height="${size}" style="position:absolute;top:0;left:0;overflow:visible;">` +
|
||||
`<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>`,
|
||||
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 += `
|
||||
<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>';
|
||||
marker.setPopupContent(popupContent);
|
||||
} 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;">
|
||||
Failed to load chart: ${err.message}
|
||||
</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>
|
||||
`;
|
||||
marker.setPopupContent(errorPopup);
|
||||
|
||||
Reference in New Issue
Block a user