Files
krawl.es/src/templates/dashboard_template.py

3601 lines
155 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Dashboard template for viewing honeypot statistics.
Customize this template to change the dashboard appearance.
"""
import html
from datetime import datetime
from zoneinfo import ZoneInfo
# imports for the __init_subclass__ method, do not remove pls
from firewall import fwtype
def _escape(value) -> str:
"""Escape HTML special characters to prevent XSS attacks."""
if value is None:
return ""
return html.escape(str(value))
def format_timestamp(iso_timestamp: str, time_only: bool = False) -> str:
"""Format ISO timestamp for display with timezone conversion
Args:
iso_timestamp: ISO format timestamp string (UTC)
time_only: If True, return only HH:MM:SS, otherwise full datetime
"""
try:
# Parse UTC timestamp
dt = datetime.fromisoformat(iso_timestamp)
if time_only:
return dt.strftime("%H:%M:%S")
return dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
# Fallback for old format
return (
iso_timestamp.split("T")[1][:8] if "T" in iso_timestamp else iso_timestamp
)
def generate_dashboard(stats: dict, dashboard_path: str = "") -> str:
"""Generate dashboard HTML with access statistics
Args:
stats: Statistics dictionary
dashboard_path: The secret dashboard path for generating API URLs
"""
# Generate comprehensive suspicious activity rows combining all suspicious events
suspicious_activities = []
# Add recent suspicious accesses (attacks)
for log in stats.get("recent_suspicious", [])[-20:]:
suspicious_activities.append({
"type": "Attack",
"ip": log["ip"],
"path": log["path"],
"user_agent": log["user_agent"][:60],
"timestamp": log["timestamp"],
"details": ", ".join(log.get("attack_types", [])) if log.get("attack_types") else "Suspicious behavior"
})
# Add credential attempts
for cred in stats.get("credential_attempts", [])[-20:]:
suspicious_activities.append({
"type": "Credentials",
"ip": cred["ip"],
"path": cred["path"],
"user_agent": "",
"timestamp": cred["timestamp"],
"details": f"User: {cred.get('username', 'N/A')}"
})
# Add honeypot triggers
for honeypot in stats.get("honeypot_triggered_ips", [])[-20:]:
# honeypot is a tuple (ip, paths)
ip = honeypot[0]
paths = honeypot[1] if isinstance(honeypot[1], list) else []
suspicious_activities.append({
"type": "Honeypot",
"ip": ip,
"path": paths[0] if paths else "Multiple",
"user_agent": "",
"timestamp": "", # Tuples don't have timestamp
"details": f"{len(paths)} trap(s) triggered"
})
# Sort by timestamp (most recent first) and take last 20
# Put entries with empty timestamps at the end
try:
suspicious_activities.sort(key=lambda x: (x["timestamp"] == "", x["timestamp"]), reverse=True)
except:
pass
suspicious_activities = suspicious_activities[:20]
# Generate table rows
suspicious_rows = (
"\n".join([f"""<tr class="ip-row" data-ip="{_escape(activity["ip"])}">
<td class="ip-clickable">{_escape(activity["ip"])}</td>
<td>{_escape(activity["type"])}</td>
<td>{_escape(activity["path"])}</td>
<td style="word-break: break-all;">{_escape(activity["details"])}</td>
<td>{format_timestamp(activity["timestamp"], time_only=True)}</td>
</tr>
<tr class="ip-stats-row" id="stats-row-suspicious-{_escape(activity["ip"]).replace(".", "-")}-{suspicious_activities.index(activity)}" style="display: none;">
<td colspan="5" class="ip-stats-cell">
<div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div>
</div>
</td>
</tr>""" for activity in suspicious_activities])
or '<tr><td colspan="5" style="text-align:center;">No suspicious activity detected</td></tr>'
)
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Krawl Dashboard</title>
<link rel="icon" type="image/svg+xml" href="https://raw.githubusercontent.com/BlessedRebuS/Krawl/refs/heads/main/img/krawl-svg.svg" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script>
<style>
body {{
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #0d1117;
color: #c9d1d9;
margin: 0;
padding: 20px;
}}
.container {{
max-width: 1400px;
margin: 0 auto;
position: relative;
}}
.github-logo {{
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
color: #58a6ff;
transition: color 0.2s;
}}
.github-logo:hover {{
color: #79c0ff;
}}
.github-logo svg {{
width: 32px;
height: 32px;
fill: currentColor;
}}
.github-logo-text {{
font-size: 14px;
font-weight: 600;
text-decoration: none;
}}
h1 {{
color: #58a6ff;
text-align: center;
margin-bottom: 40px;
}}
.download-section {{
position: absolute;
top: 0;
right: 0;
}}
.download-btn {{
display: inline-block;
padding: 8px 14px;
background: #238636;
color: #ffffff;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
font-size: 13px;
transition: background 0.2s;
border: 1px solid #2ea043;
}}
.download-btn:hover {{
background: #2ea043;
}}
.download-btn:active {{
background: #1f7a2f;
}}
.banlist-dropdown {{
position: relative;
display: inline-block;
width: 100%;
}}
.banlist-dropdown-btn {{
display: block;
width: 100%;
padding: 8px 14px;
background: #238636;
color: #ffffff;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
font-size: 13px;
transition: background 0.2s;
border: 1px solid #2ea043;
cursor: pointer;
text-align: left;
box-sizing: border-box;
}}
.banlist-dropdown-btn:hover {{
background: #2ea043;
}}
.banlist-dropdown-menu {{
display: none;
position: absolute;
right: 0;
left: 0;
background-color: #161b22;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.3);
z-index: 1;
border: 1px solid #30363d;
border-radius: 6px;
margin-top: 4px;
overflow: hidden;
}}
.banlist-dropdown-menu.show {{
display: block;
}}
.banlist-dropdown-menu a {{
color: #c9d1d9;
padding: 6px 12px;
text-decoration: none;
display: flex;
align-items: center;
gap: 6px;
transition: background 0.2s;
font-size: 12px;
}}
.banlist-dropdown-menu a:hover {{
background-color: #1c2128;
color: #58a6ff;
}}
.banlist-dropdown-menu a.disabled {{
color: #6e7681;
cursor: not-allowed;
pointer-events: none;
}}
.banlist-icon {{
font-size: 14px;
}}
.stats-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 20px;
margin-bottom: 40px;
}}
.stat-card {{
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 20px;
text-align: center;
}}
.stat-card.alert {{
border-color: #f85149;
}}
.stat-value {{
font-size: 36px;
font-weight: bold;
color: #58a6ff;
}}
.stat-value.alert {{
color: #f85149;
}}
.stat-label {{
font-size: 14px;
color: #8b949e;
margin-top: 5px;
}}
.table-container {{
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 12px;
margin-bottom: 20px;
}}
h2 {{
color: #58a6ff;
margin-top: 0;
}}
table {{
width: 100%;
border-collapse: collapse;
}}
th, td {{
padding: 12px;
text-align: left;
border-bottom: 1px solid #30363d;
}}
th {{
background: #0d1117;
color: #58a6ff;
font-weight: 600;
}}
tr:hover {{
background: #1c2128;
}}
.rank {{
color: #8b949e;
font-weight: bold;
}}
.alert-section {{
background: #1c1917;
border-left: 4px solid #f85149;
}}
th.sortable {{
cursor: pointer;
user-select: none;
position: relative;
padding-right: 24px;
}}
th.sortable:hover {{
background: #1c2128;
}}
th.sortable::after {{
content: '';
position: absolute;
right: 8px;
opacity: 0.5;
font-size: 12px;
}}
th.sortable.asc::after {{
content: '';
opacity: 1;
}}
th.sortable.desc::after {{
content: '';
opacity: 1;
}}
tbody {{
transition: opacity 0.1s ease;
}}
tbody {{
animation: fadeIn 0.3s ease-in;
}}
.ip-row {{
transition: background-color 0.2s;
}}
.ip-clickable {{
cursor: pointer;
color: #58a6ff !important;
font-weight: 500;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 3px;
}}
.ip-clickable:hover {{
color: #79c0ff !important;
text-decoration-style: solid;
background: #1c2128;
}}
.ip-stats-row {{
background: #0d1117;
}}
.ip-stats-cell {{
padding: 0 !important;
}}
.ip-stats-dropdown {{
margin-top: 10px;
padding: 15px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
font-size: 13px;
display: flex;
gap: 20px;
}}
.stats-left {{
flex: 1;
}}
.stats-right {{
flex: 0 0 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}}
.radar-chart {{
position: relative;
width: 220px;
height: 220px;
overflow: visible;
}}
.radar-legend {{
margin-top: 10px;
font-size: 11px;
}}
.radar-legend-item {{
display: flex;
align-items: center;
gap: 6px;
margin: 3px 0;
}}
.radar-legend-color {{
width: 12px;
height: 12px;
border-radius: 2px;
}}
.ip-stats-dropdown .loading {{
color: #8b949e;
font-style: italic;
}}
.stat-row {{
display: flex;
justify-content: space-between;
padding: 5px 0;
border-bottom: 1px solid #21262d;
}}
.stat-row:last-child {{
border-bottom: none;
}}
.stat-label-sm {{
color: #8b949e;
font-weight: 500;
}}
.stat-value-sm {{
color: #58a6ff;
font-weight: 600;
}}
.category-badge {{
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}}
.category-attacker {{
background: #f851491a;
color: #f85149;
border: 1px solid #f85149;
}}
.category-good-crawler {{
background: #3fb9501a;
color: #3fb950;
border: 1px solid #3fb950;
}}
.category-bad-crawler {{
background: #f0883e1a;
color: #f0883e;
border: 1px solid #f0883e;
}}
.category-regular-user {{
background: #58a6ff1a;
color: #58a6ff;
border: 1px solid #58a6ff;
}}
.category-unknown {{
background: #8b949e1a;
color: #8b949e;
border: 1px solid #8b949e;
}}
.timeline-section {{
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #30363d;
}}
.timeline-container {{
display: flex;
gap: 20px;
min-height: 200px;
}}
.timeline-column {{
flex: 1;
min-width: 0;
overflow: auto;
max-height: 350px;
}}
.timeline-column:first-child {{
flex: 1.5;
}}
.timeline-column:last-child {{
flex: 1;
}}
.timeline-header {{
color: #58a6ff;
font-size: 13px;
font-weight: 600;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #30363d;
}}
.reputation-title {{
color: #8b949e;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
margin-bottom: 8px;
}}
.reputation-badge {{
display: inline-flex;
align-items: center;
gap: 3px;
padding: 4px 8px;
background: #161b22;
border: 1px solid #f851494d;
border-radius: 4px;
font-size: 11px;
color: #f85149;
text-decoration: none;
transition: all 0.2s;
margin-bottom: 6px;
margin-right: 6px;
white-space: nowrap;
}}
.reputation-badge:hover {{
background: #1c2128;
border-color: #f85149;
}}
.reputation-clean {{
display: inline-flex;
align-items: center;
gap: 3px;
padding: 4px 8px;
background: #161b22;
border: 1px solid #3fb9504d;
border-radius: 4px;
font-size: 11px;
color: #3fb950;
margin-bottom: 6px;
}}
.timeline {{
position: relative;
padding-left: 28px;
}}
.timeline::before {{
content: '';
position: absolute;
left: 11px;
top: 0;
bottom: 0;
width: 2px;
background: #30363d;
}}
.timeline-item {{
position: relative;
padding-bottom: 12px;
font-size: 12px;
}}
.timeline-item:last-child {{
padding-bottom: 0;
}}
.timeline-marker {{
position: absolute;
left: -23px;
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid #0d1117;
}}
.timeline-marker.attacker {{ background: #f85149; }}
.timeline-marker.good-crawler {{ background: #3fb950; }}
.timeline-marker.bad-crawler {{ background: #f0883e; }}
.timeline-marker.regular-user {{ background: #58a6ff; }}
.timeline-marker.unknown {{ background: #8b949e; }}
.tabs-container {{
border-bottom: 1px solid #30363d;
margin-bottom: 30px;
display: flex;
gap: 2px;
background: #161b22;
border-radius: 6px 6px 0 0;
overflow-x: auto;
overflow-y: hidden;
}}
.tab-button {{
padding: 12px 20px;
background: transparent;
border: none;
color: #8b949e;
font-size: 14px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
border-bottom: 3px solid transparent;
position: relative;
bottom: -1px;
}}
.tab-button:hover {{
color: #c9d1d9;
background: #1c2128;
}}
.tab-button.active {{
color: #58a6ff;
border-bottom-color: #58a6ff;
}}
.tab-content {{
display: none;
}}
.tab-content.active {{
display: block;
}}
.ip-stats-table {{
width: 100%;
border-collapse: collapse;
}}
.ip-stats-table th, .ip-stats-table td {{
padding: 12px;
text-align: left;
border-bottom: 1px solid #30363d;
}}
.ip-stats-table th {{
background: #0d1117;
color: #58a6ff;
font-weight: 600;
}}
.ip-stats-table tr:hover {{
background: #1c2128;
}}
.ip-detail-modal {{
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
align-items: center;
justify-content: center;
}}
.ip-detail-modal.show {{
display: flex;
}}
.ip-detail-content {{
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 30px;
max-width: 900px;
max-height: 90vh;
overflow-y: auto;
position: relative;
}}
.ip-detail-close {{
position: absolute;
top: 15px;
right: 15px;
background: none;
border: none;
color: #8b949e;
font-size: 24px;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}}
.ip-detail-close:hover {{
color: #c9d1d9;
}}
#attacker-map {{
background: #0d1117 !important;
}}
.leaflet-container {{
background: #0d1117 !important;
}}
.leaflet-tile {{
filter: none;
}}
.leaflet-popup-content-wrapper {{
background-color: #0d1117;
color: #c9d1d9;
border: 1px solid #30363d;
border-radius: 6px;
padding: 0;
}}
.leaflet-popup-content {{
margin: 0;
min-width: 280px;
}}
.leaflet-popup-content-wrapper a {{
color: #58a6ff;
}}
.leaflet-popup-tip {{
background: #0d1117;
border: 1px solid #30363d;
}}
.ip-detail-popup .leaflet-popup-content-wrapper {{
max-width: 340px !important;
}}
/* Remove the default leaflet icon background */
.ip-custom-marker {{
background: none !important;
border: none !important;
}}
.ip-marker {{
border: 2px solid #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: white;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}}
.ip-marker:hover {{
transform: scale(1.15);
}}
.marker-attacker {{
background: #f85149;
box-shadow: 0 0 8px rgba(248, 81, 73, 0.8), inset 0 0 4px rgba(248, 81, 73, 0.5);
}}
.marker-attacker:hover {{
box-shadow: 0 0 15px rgba(248, 81, 73, 1), inset 0 0 6px rgba(248, 81, 73, 0.7);
}}
.marker-bad_crawler {{
background: #f0883e;
box-shadow: 0 0 8px rgba(240, 136, 62, 0.8), inset 0 0 4px rgba(240, 136, 62, 0.5);
}}
.marker-bad_crawler:hover {{
box-shadow: 0 0 15px rgba(240, 136, 62, 1), inset 0 0 6px rgba(240, 136, 62, 0.7);
}}
.marker-good_crawler {{
background: #3fb950;
box-shadow: 0 0 8px rgba(63, 185, 80, 0.8), inset 0 0 4px rgba(63, 185, 80, 0.5);
}}
.marker-good_crawler:hover {{
box-shadow: 0 0 15px rgba(63, 185, 80, 1), inset 0 0 6px rgba(63, 185, 80, 0.7);
}}
.marker-regular_user {{
background: #58a6ff;
box-shadow: 0 0 8px rgba(88, 166, 255, 0.8), inset 0 0 4px rgba(88, 166, 255, 0.5);
}}
.marker-regular_user:hover {{
box-shadow: 0 0 15px rgba(88, 166, 255, 1), inset 0 0 6px rgba(88, 166, 255, 0.7);
}}
.marker-unknown {{
background: #8b949e;
box-shadow: 0 0 8px rgba(139, 148, 158, 0.8), inset 0 0 4px rgba(139, 148, 158, 0.5);
}}
.marker-unknown:hover {{
box-shadow: 0 0 15px rgba(139, 148, 158, 1), inset 0 0 6px rgba(139, 148, 158, 0.7);
}}
.leaflet-bottom.leaflet-right {{
display: none !important;
}}
.charts-container {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 20px;
}}
.chart-section {{
display: flex;
flex-direction: column;
}}
.chart-wrapper {{
display: flex;
flex-direction: column;
}}
#attack-types-chart {{
max-height: 350px;
}}
#attack-patterns-chart {{
max-height: 350px;
}}
@media (max-width: 1200px) {{
.charts-container {{
grid-template-columns: 1fr;
}}
}}
/* Raw Request Modal */
.raw-request-modal {{
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
overflow: auto;
}}
.raw-request-modal-content {{
background-color: #161b22;
margin: 5% auto;
padding: 0;
border: 1px solid #30363d;
border-radius: 6px;
width: 80%;
max-width: 900px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}}
.raw-request-modal-header {{
padding: 16px 20px;
background-color: #21262d;
border-bottom: 1px solid #30363d;
border-radius: 6px 6px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
}}
.raw-request-modal-header h3 {{
margin: 0;
color: #58a6ff;
font-size: 16px;
}}
.raw-request-modal-close {{
color: #8b949e;
font-size: 28px;
font-weight: bold;
cursor: pointer;
line-height: 20px;
transition: color 0.2s;
}}
.raw-request-modal-close:hover {{
color: #c9d1d9;
}}
.raw-request-modal-body {{
padding: 20px;
}}
.raw-request-content {{
background-color: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
padding: 16px;
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
color: #c9d1d9;
white-space: pre-wrap;
word-wrap: break-word;
max-height: 400px;
overflow-y: auto;
}}
.raw-request-modal-footer {{
padding: 16px 20px;
background-color: #21262d;
border-top: 1px solid #30363d;
border-radius: 0 0 6px 6px;
text-align: right;
}}
.raw-request-download-btn {{
padding: 8px 16px;
background: #238636;
color: #ffffff;
border: none;
border-radius: 6px;
font-weight: 500;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
}}
.raw-request-download-btn:hover {{
background: #2ea043;
}}
/* Attack Types Cell Styling */
.attack-types-cell {{
max-width: 280px;
position: relative;
display: inline-block;
width: 100%;
overflow: visible;
}}
.attack-types-truncated {{
display: block;
width: 100%;
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #fb8500;
font-weight: 500;
transition: all 0.2s;
position: relative;
}}
.attack-types-tooltip {{
position: absolute;
bottom: 100%;
left: 0;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
padding: 12px;
margin-bottom: 8px;
max-width: 400px;
word-wrap: break-word;
white-space: normal;
z-index: 1000;
color: #c9d1d9;
font-size: 12px;
font-weight: normal;
display: none;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
pointer-events: auto;
}}
.attack-types-cell:hover .attack-types-tooltip {{
display: block;
}}
.attack-types-tooltip::after {{
content: '';
position: absolute;
top: 100%;
left: 12px;
border: 6px solid transparent;
border-top-color: #30363d;
}}
.attack-types-tooltip::before {{
content: '';
position: absolute;
top: 100%;
left: 13px;
border: 5px solid transparent;
border-top-color: #0d1117;
z-index: 1;
}}
/* Path Cell Styling for Attack Table */
.path-cell-container {{
position: relative;
display: inline-block;
max-width: 100%;
}}
.path-truncated {{
display: block;
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
color: #f85149 !important;
font-weight: 500;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 3px;
transition: all 0.2s;
}}
.path-truncated:hover {{
color: #ff7369 !important;
text-decoration-style: solid;
}}
.path-cell-container:hover .path-tooltip {{
display: block;
}}
.path-tooltip {{
position: absolute;
bottom: 100%;
left: 0;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 8px;
max-width: 500px;
word-wrap: break-word;
white-space: normal;
z-index: 1000;
color: #c9d1d9;
font-size: 12px;
font-weight: normal;
display: none;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
font-family: 'Courier New', monospace;
}}
.path-tooltip::after {{
content: '';
position: absolute;
top: 100%;
left: 12px;
border: 6px solid transparent;
border-top-color: #30363d;
}}
.path-tooltip::before {{
content: '';
position: absolute;
top: 100%;
left: 13px;
border: 5px solid transparent;
border-top-color: #0d1117;
z-index: 1;
}}
/* Mobile Optimization - Tablets (768px and down) */
@media (max-width: 768px) {{
body {{
padding: 12px;
}}
.container {{
max-width: 100%;
}}
h1 {{
font-size: 24px;
margin-bottom: 20px;
}}
.github-logo {{
position: relative;
top: auto;
left: auto;
margin-bottom: 15px;
}}
.download-section {{
position: relative;
top: auto;
right: auto;
margin-bottom: 20px;
}}
.stats-grid {{
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 20px;
}}
.stat-value {{
font-size: 28px;
}}
.stat-card {{
padding: 15px;
}}
.table-container {{
padding: 12px;
margin-bottom: 15px;
overflow-x: auto;
}}
table {{
font-size: 13px;
}}
th, td {{
padding: 10px 6px;
}}
h2 {{
font-size: 18px;
}}
.tabs-container {{
gap: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}}
.tab-button {{
padding: 10px 16px;
font-size: 12px;
}}
.ip-stats-dropdown {{
flex-direction: column;
gap: 15px;
}}
.stats-right {{
flex: 0 0 auto;
width: 100%;
}}
.radar-chart {{
width: 160px;
height: 160px;
}}
.timeline-container {{
flex-direction: column;
gap: 15px;
min-height: auto;
}}
.timeline-column {{
flex: 1 !important;
max-height: 300px;
}}
#attacker-map {{
height: 350px !important;
}}
.leaflet-popup-content {{
min-width: 200px !important;
}}
.ip-marker {{
font-size: 8px;
}}
.ip-detail-content {{
padding: 20px;
max-width: 95%;
max-height: 85vh;
}}
.download-btn {{
padding: 6px 12px;
font-size: 12px;
}}
}}
/* Mobile Optimization - Small phones (480px and down) */
@media (max-width: 480px) {{
body {{
padding: 8px;
}}
h1 {{
font-size: 20px;
margin-bottom: 15px;
}}
.stats-grid {{
grid-template-columns: 1fr;
gap: 10px;
margin-bottom: 15px;
}}
.stat-value {{
font-size: 24px;
}}
.stat-card {{
padding: 12px;
}}
.stat-label {{
font-size: 12px;
}}
.table-container {{
padding: 10px;
margin-bottom: 12px;
border-radius: 4px;
}}
table {{
font-size: 12px;
}}
th, td {{
padding: 8px 4px;
}}
th {{
position: relative;
}}
th.sortable::after {{
right: 4px;
font-size: 10px;
}}
h2 {{
font-size: 16px;
margin-bottom: 12px;
}}
.tabs-container {{
gap: 0;
}}
.tab-button {{
padding: 10px 12px;
font-size: 11px;
flex: 1;
}}
.ip-row {{
display: block;
margin-bottom: 10px;
background: #1c2128;
padding: 10px;
border-radius: 4px;
}}
.ip-row td {{
display: block;
padding: 4px 0;
border: none;
}}
.ip-row td::before {{
content: attr(data-label);
font-weight: bold;
color: #8b949e;
margin-right: 8px;
}}
.ip-clickable {{
display: inline-block;
}}
.ip-stats-dropdown {{
flex-direction: column;
gap: 12px;
font-size: 12px;
}}
.stats-left {{
flex: 1;
}}
.stats-right {{
flex: 0 0 auto;
width: 100%;
}}
.radar-chart {{
width: 140px;
height: 140px;
}}
.radar-legend {{
margin-top: 8px;
font-size: 10px;
}}
.stat-row {{
padding: 4px 0;
}}
.stat-label-sm {{
font-size: 12px;
}}
.stat-value-sm {{
font-size: 13px;
}}
.category-badge {{
padding: 3px 6px;
font-size: 10px;
}}
.timeline-container {{
flex-direction: column;
gap: 12px;
min-height: auto;
}}
.timeline-column {{
flex: 1 !important;
max-height: 250px;
font-size: 11px;
}}
.timeline-header {{
font-size: 12px;
margin-bottom: 8px;
}}
.timeline-item {{
padding-bottom: 10px;
font-size: 11px;
}}
.timeline-marker {{
left: -19px;
width: 12px;
height: 12px;
}}
.reputation-badge {{
display: block;
margin-bottom: 6px;
margin-right: 0;
font-size: 10px;
}}
#attacker-map {{
height: 300px !important;
}}
.leaflet-popup-content {{
min-width: 150px !important;
}}
.ip-marker {{
font-size: 7px;
}}
.ip-detail-modal {{
justify-content: flex-end;
align-items: flex-end;
}}
.ip-detail-content {{
padding: 15px;
max-width: 100%;
max-height: 90vh;
border-radius: 8px 8px 0 0;
width: 100%;
}}
.download-btn {{
padding: 6px 10px;
font-size: 11px;
}}
.github-logo {{
font-size: 12px;
}}
.github-logo svg {{
width: 24px;
height: 24px;
}}
}}
/* Landscape mode optimization */
@media (max-height: 600px) and (orientation: landscape) {{
body {{
padding: 8px;
}}
h1 {{
margin-bottom: 10px;
font-size: 18px;
}}
.stats-grid {{
margin-bottom: 10px;
gap: 8px;
}}
.stat-value {{
font-size: 20px;
}}
.stat-card {{
padding: 8px;
}}
#attacker-map {{
height: 250px !important;
}}
.ip-stats-dropdown {{
gap: 10px;
}}
.radar-chart {{
width: 120px;
height: 120px;
}}
}}
/* Touch-friendly optimizations */
@media (hover: none) and (pointer: coarse) {{
.ip-clickable {{
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: rgba(88, 166, 255, 0.2);
}}
.tab-button {{
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: rgba(88, 166, 255, 0.2);
padding: 14px 18px;
}}
.download-btn {{
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: rgba(36, 134, 54, 0.3);
}}
input[type="checkbox"] {{
width: 18px;
height: 18px;
cursor: pointer;
}}
}}
</style>
</head>
<body>
<div class="container">
<a href="https://github.com/BlessedRebuS/Krawl" class="github-logo" target="_blank" rel="noopener noreferrer">
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<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.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
</svg>
<span class="github-logo-text">BlessedRebuS/Krawl</span>
</a>
<div class="download-section">
<div class="banlist-dropdown">
<button class="banlist-dropdown-btn" onclick="toggleBanlistDropdown()">Export IPs Banlist</button>
<div id="banlistDropdown" class="banlist-dropdown-menu">
<a href="javascript:void(0)" onclick="downloadBanlist('raw')">
<span>Raw IPs</span>
</a>
<a href="javascript:void(0)" onclick="downloadBanlist('iptables')">
<span>IPTables Rules</span>
</a>
</div>
</div>
</div>
<h1>Krawl Dashboard</h1>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{stats['total_accesses']}</div>
<div class="stat-label">Total Accesses</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats['unique_ips']}</div>
<div class="stat-label">Unique IPs</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats['unique_paths']}</div>
<div class="stat-label">Unique Paths</div>
</div>
<div class="stat-card alert">
<div class="stat-value alert">{stats['suspicious_accesses']}</div>
<div class="stat-label">Suspicious Accesses</div>
</div>
<div class="stat-card alert">
<div class="stat-value alert">{stats.get('honeypot_ips', 0)}</div>
<div class="stat-label">Honeypot Caught</div>
</div>
<div class="stat-card alert">
<div class="stat-value alert">{len(stats.get('credential_attempts', []))}</div>
<div class="stat-label">Credentials Captured</div>
</div>
<div class="stat-card alert">
<div class="stat-value alert">{stats.get('unique_attackers', 0)}</div>
<div class="stat-label">Unique Attackers</div>
</div>
</div>
<div class="tabs-container">
<a class="tab-button active" href="#overview">Overview</a>
<a class="tab-button" href="#ip-stats">Attacks</a>
</div>
<div id="overview" class="tab-content active">
<div class="table-container alert-section">
<h2>Recent Suspicious Activity</h2>
<table>
<thead>
<tr>
<th>IP Address</th>
<th>Type</th>
<th>Path</th>
<th>Details</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{suspicious_rows}
</tbody>
</table>
</div>
<div class="table-container alert-section">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px;">
<h2 style="margin: 0;">Honeypot Triggers by IP</h2>
<div class="pagination-controls" id="honeypot-pagination" style="display: flex; align-items: center; gap: 12px; padding: 0; background: transparent;">
<div style="display: flex; align-items: center; gap: 6px; color: #6e7681; font-weight: 400; font-size: 12px;">
<span>Page <span class="current-page">1</span>/<span class="total-pages">1</span></span>
<span style="color: #6e7681;">•</span>
<span><span class="total-records">0</span> total</span>
</div>
<button class="pagination-btn" onclick="previousPage('honeypot')" style="padding: 6px 12px; background: #0969da; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; font-size: 12px; transition: background 0.2s;">← Prev</button>
<button class="pagination-btn" onclick="nextPage('honeypot')" style="padding: 6px 12px; background: #0969da; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; font-size: 12px; transition: background 0.2s;">Next →</button>
</div>
</div>
<table id="honeypot-table" class="overview-table">
<thead>
<tr>
<th>#</th>
<th>IP Address</th>
<th>Accessed Paths</th>
<th class="sortable" data-sort="count" data-table="honeypot">Count</th>
</tr>
</thead>
<tbody id="honeypot-tbody">
<tr><td colspan="4" style="text-align: center;">Loading...</td></tr>
</tbody>
</table>
</div>
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
<div class="table-container" style="flex: 1;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px;">
<h2 style="margin: 0;">Top IP Addresses</h2>
<div class="pagination-controls" id="top-ips-pagination" style="display: flex; align-items: center; gap: 12px; padding: 0; background: transparent;">
<div style="display: flex; align-items: center; gap: 6px; color: #6e7681; font-weight: 400; font-size: 12px;">
<span>Page <span class="current-page">1</span>/<span class="total-pages">1</span></span>
<span style="color: #6e7681;">•</span>
<span><span class="total-records">0</span> total</span>
</div>
<button class="pagination-btn" onclick="previousPage('top-ips')" style="padding: 6px 12px; background: #0969da; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; font-size: 12px; transition: background 0.2s;">← Prev</button>
<button class="pagination-btn" onclick="nextPage('top-ips')" style="padding: 6px 12px; background: #0969da; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; font-size: 12px; transition: background 0.2s;">Next →</button>
</div>
</div>
<table id="top-ips-table" class="overview-table">
<thead>
<tr>
<th>#</th>
<th>IP Address</th>
<th class="sortable" data-sort="count" data-table="top-ips">Access Count</th>
</tr>
</thead>
<tbody id="top-ips-tbody">
<tr><td colspan="3" style="text-align: center;">Loading...</td></tr>
</tbody>
</table>
</div>
<div class="table-container" style="flex: 1;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px;">
<h2 style="margin: 0;">Top User-Agents</h2>
<div class="pagination-controls" id="top-ua-pagination" style="display: flex; align-items: center; gap: 12px; padding: 0; background: transparent;">
<div style="display: flex; align-items: center; gap: 6px; color: #6e7681; font-weight: 400; font-size: 12px;">
<span>Page <span class="current-page">1</span>/<span class="total-pages">1</span></span>
<span style="color: #6e7681;">•</span>
<span><span class="total-records">0</span> total</span>
</div>
<button class="pagination-btn" onclick="previousPage('top-ua')" style="padding: 6px 12px; background: #0969da; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; font-size: 12px; transition: background 0.2s;">← Prev</button>
<button class="pagination-btn" onclick="nextPage('top-ua')" style="padding: 6px 12px; background: #0969da; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; font-size: 12px; transition: background 0.2s;">Next →</button>
</div>
</div>
<table id="top-ua-table" class="overview-table">
<thead>
<tr>
<th>#</th>
<th>User-Agent</th>
<th class="sortable" data-sort="count" data-table="top-ua">Count</th>
</tr>
</thead>
<tbody id="top-ua-tbody">
<tr><td colspan="3" style="text-align: center;">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="ip-stats" class="tab-content">
<div class="table-container" style="margin-bottom: 30px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px;">
<h2 style="margin: 0;">IP Origins Map</h2>
<div style="display: flex; gap: 16px; align-items: center; flex-wrap: wrap;">
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; color: #c9d1d9; font-size: 13px;">
<input type="checkbox" id="filter-attacker" checked onchange="updateMapFilters()" style="cursor: pointer;">
<span style="color: #f85149;">Attackers</span>
</label>
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; color: #c9d1d9; font-size: 13px;">
<input type="checkbox" id="filter-bad-crawler" checked onchange="updateMapFilters()" style="cursor: pointer;">
<span style="color: #f0883e;">Bad Crawlers</span>
</label>
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; color: #c9d1d9; font-size: 13px;">
<input type="checkbox" id="filter-good-crawler" checked onchange="updateMapFilters()" style="cursor: pointer;">
<span style="color: #3fb950;">Good Crawlers</span>
</label>
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; color: #c9d1d9; font-size: 13px;">
<input type="checkbox" id="filter-regular-user" checked onchange="updateMapFilters()" style="cursor: pointer;">
<span style="color: #58a6ff;">Regular Users</span>
</label>
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; color: #c9d1d9; font-size: 13px;">
<input type="checkbox" id="filter-unknown" checked onchange="updateMapFilters()" style="cursor: pointer;">
<span style="color: #8b949e;">Unknown</span>
</label>
</div>
</div>
<div id="attacker-map" style="height: 500px; border-radius: 6px; overflow: hidden; border: 1px solid #30363d; background: #161b22;">
<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #8b949e;">Loading map...</div>
</div>
</div>
<div class="table-container alert-section" style="position: relative;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px;">
<h2 style="margin: 0;">Attackers by Total Requests</h2>
<div class="pagination-controls" style="display: flex; align-items: center; gap: 12px; padding: 0; background: transparent;">
<div style="display: flex; align-items: center; gap: 6px; color: #6e7681; font-weight: 400; font-size: 12px;">
<span>Page <span id="current-page">1</span>/<span id="total-pages">1</span></span>
<span style="color: #6e7681;">•</span>
<span><span id="total-attackers">0</span> total</span>
</div>
<button id="prev-page-btn" class="pagination-btn" onclick="previousPageIpStats()" style="padding: 6px 12px; background: #0969da; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; font-size: 12px; transition: background 0.2s;">← Prev</button>
<button id="next-page-btn" class="pagination-btn" onclick="nextPageIpStats()" style="padding: 6px 12px; background: #0969da; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; font-size: 12px; transition: background 0.2s;">Next →</button>
</div>
</div>
<table class="ip-stats-table">
<thead>
<tr>
<th>#</th>
<th>IP Address</th>
<th class="sortable" data-sort="total_requests">Total Requests</th>
<th>First Seen</th>
<th>Last Seen</th>
<th>Location</th>
</tr>
</thead>
<tbody id="ip-stats-tbody">
<!-- Dynamically populated -->
</tbody>
</table>
</div>
<div class="table-container alert-section">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px;">
<h2 style="margin: 0;">Captured Credentials</h2>
<div class="pagination-controls" id="credentials-pagination" style="display: flex; align-items: center; gap: 12px; padding: 0; background: transparent;">
<div style="display: flex; align-items: center; gap: 6px; color: #6e7681; font-weight: 400; font-size: 12px;">
<span>Page <span class="current-page">1</span>/<span class="total-pages">1</span></span>
<span style="color: #6e7681;">•</span>
<span><span class="total-records">0</span> total</span>
</div>
<button class="pagination-btn" onclick="previousPage('credentials')" style="padding: 6px 12px; background: #0969da; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; font-size: 12px; transition: background 0.2s;">← Prev</button>
<button class="pagination-btn" onclick="nextPage('credentials')" style="padding: 6px 12px; background: #0969da; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; font-size: 12px; transition: background 0.2s;">Next →</button>
</div>
</div>
<table id="credentials-table" class="overview-table">
<thead>
<tr>
<th>#</th>
<th>IP Address</th>
<th>Username</th>
<th>Password</th>
<th>Path</th>
<th class="sortable" data-sort="timestamp" data-table="credentials">Time</th>
</tr>
</thead>
<tbody id="credentials-tbody">
<tr><td colspan="6" style="text-align: center;">Loading...</td></tr>
</tbody>
</table>
</div>
<div class="table-container alert-section">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px;">
<h2 style="margin: 0;">Detected Attack Types</h2>
<div class="pagination-controls" id="attacks-pagination" style="display: flex; align-items: center; gap: 12px; padding: 0; background: transparent;">
<div style="display: flex; align-items: center; gap: 6px; color: #6e7681; font-weight: 400; font-size: 12px;">
<span>Page <span class="current-page">1</span>/<span class="total-pages">1</span></span>
<span style="color: #6e7681;">•</span>
<span><span class="total-records">0</span> total</span>
</div>
<button class="pagination-btn" onclick="previousPage('attacks')" style="padding: 6px 12px; background: #0969da; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; font-size: 12px; transition: background 0.2s;">← Prev</button>
<button class="pagination-btn" onclick="nextPage('attacks')" style="padding: 6px 12px; background: #0969da; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; font-size: 12px; transition: background 0.2s;">Next →</button>
</div>
</div>
<table id="attacks-table" class="overview-table">
<thead>
<tr>
<th>#</th>
<th>IP Address</th>
<th>Path</th>
<th>Attack Types</th>
<th>User-Agent</th>
<th class="sortable" data-sort="timestamp" data-table="attacks">Time</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="attacks-tbody">
<tr><td colspan="7" style="text-align: center;">Loading...</td></tr>
</tbody>
</table>
</div>
<div class="charts-container">
<div class="chart-section">
<div class="table-container alert-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h2 style="margin: 0; font-size: 18px;">Most Recurring Attack Types</h2>
<div style="font-size: 11px; color: #8b949e;">Top 10</div>
</div>
<div style="position: relative; height: 295px;">
<canvas id="attack-types-chart"></canvas>
</div>
</div>
</div>
<div class="chart-section">
<div class="table-container alert-section">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 15px;">
<h2 style="margin: 0; font-size: 16px;">Most Recurring Attack Patterns</h2>
<div class="pagination-controls" id="patterns-pagination" style="display: flex; align-items: center; gap: 8px; padding: 0; background: transparent;">
<div style="display: flex; align-items: center; gap: 4px; color: #6e7681; font-weight: 400; font-size: 11px;">
<span>Page <span class="current-page">1</span>/<span class="total-pages">1</span></span>
<span style="color: #6e7681;">•</span>
<span><span class="total-records">0</span> total</span>
</div>
<button class="pagination-btn" onclick="previousPage('patterns')" style="padding: 4px 8px; background: #0969da; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; font-size: 11px; transition: background 0.2s;">← Prev</button>
<button class="pagination-btn" onclick="nextPage('patterns')" style="padding: 4px 8px; background: #0969da; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; font-size: 11px; transition: background 0.2s;">Next →</button>
</div>
</div>
<div style="position: relative; height: 295px; overflow-y: auto;">
<table id="patterns-table" class="overview-table" style="font-size: 15px;">
<thead>
<tr>
<th>#</th>
<th>Attack Pattern</th>
<th>Attack Type</th>
<th class="sortable" data-sort="count" data-table="patterns">Frequency</th>
<th>IPs</th>
</tr>
</thead>
<tbody id="patterns-tbody">
<tr><td colspan="5" style="text-align: center;">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div id="ip-detail-modal" class="ip-detail-modal">
<div class="ip-detail-content">
<button class="ip-detail-close" onclick="closeIpDetailModal()">×</button>
<div id="ip-detail-body">
<!-- Dynamically populated -->
</div>
</div>
</div>
<div id="raw-request-modal" class="raw-request-modal">
<div class="raw-request-modal-content">
<div class="raw-request-modal-header">
<h3>Raw HTTP Request</h3>
<span class="raw-request-modal-close" onclick="closeRawRequestModal()">&times;</span>
</div>
<div class="raw-request-modal-body">
<div id="raw-request-content" class="raw-request-content">
<!-- Dynamically populated -->
</div>
</div>
<div class="raw-request-modal-footer">
<button class="raw-request-download-btn" onclick="downloadRawRequest()">Download as .txt</button>
</div>
</div>
</div>
</div>
<script>
const DASHBOARD_PATH = '{dashboard_path}';
// Dropdown menu functions
function toggleBanlistDropdown() {{
const dropdown = document.getElementById('banlistDropdown');
dropdown.classList.toggle('show');
}}
// Close dropdown when clicking outside
document.addEventListener('click', function(event) {{
const dropdown = document.querySelector('.banlist-dropdown');
if (!dropdown.contains(event.target)) {{
const menu = document.getElementById('banlistDropdown');
menu.classList.remove('show');
}}
}});
// Download banlist function
function downloadBanlist(fwtype) {{
const url = DASHBOARD_PATH + '/api/get_banlist?fwtype=' + encodeURIComponent(fwtype);
// Create a temporary link and trigger download
const link = document.createElement('a');
link.href = url;
// Set filename based on type
const filename = fwtype === 'raw' ? 'banlist_raw.txt' : 'banlist_iptables.sh';
link.setAttribute('download', filename);
// Append to body, click, and remove
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Close dropdown after download
const menu = document.getElementById('banlistDropdown');
menu.classList.remove('show');
}}
function formatTimestamp(isoTimestamp) {{
if (!isoTimestamp) return 'N/A';
try {{
const date = new Date(isoTimestamp);
return date.toLocaleString('en-US', {{
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}});
}} catch (err) {{
console.error('Error formatting timestamp:', err);
return new Date(isoTimestamp).toLocaleString();
}}
}}
document.querySelectorAll('th.sortable').forEach(header => {{
header.addEventListener('click', function() {{
const table = this.closest('table');
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
const sortType = this.getAttribute('data-sort');
const columnIndex = Array.from(this.parentElement.children).indexOf(this);
const isAscending = this.classList.contains('asc');
table.querySelectorAll('th.sortable').forEach(th => {{
th.classList.remove('asc', 'desc');
}});
this.classList.add(isAscending ? 'desc' : 'asc');
rows.sort((a, b) => {{
let aValue = a.cells[columnIndex].textContent.trim();
let bValue = b.cells[columnIndex].textContent.trim();
if (sortType === 'count') {{
aValue = parseInt(aValue) || 0;
bValue = parseInt(bValue) || 0;
return isAscending ? bValue - aValue : aValue - bValue;
}}
if (sortType === 'ip') {{
const ipToNum = ip => {{
const parts = ip.split('.');
if (parts.length !== 4) return 0;
return parts.reduce((acc, part, i) => acc + (parseInt(part) || 0) * Math.pow(256, 3 - i), 0);
}};
const aNum = ipToNum(aValue);
const bNum = ipToNum(bValue);
return isAscending ? bNum - aNum : aNum - bNum;
}}
if (isAscending) {{
return bValue.localeCompare(aValue);
}} else {{
return aValue.localeCompare(bValue);
}}
}});
rows.forEach(row => tbody.appendChild(row));
}});
}});
document.querySelectorAll('.ip-clickable').forEach(cell => {{
cell.addEventListener('click', async function(e) {{
const row = e.currentTarget.closest('.ip-row');
if (!row) return;
const ip = row.getAttribute('data-ip');
const statsRow = row.nextElementSibling;
if (!statsRow || !statsRow.classList.contains('ip-stats-row')) return;
const isVisible = getComputedStyle(statsRow).display !== 'none';
document.querySelectorAll('.ip-stats-row').forEach(r => {{
r.style.display = 'none';
}});
if (isVisible) return;
statsRow.style.display = 'table-row';
const dropdown = statsRow.querySelector('.ip-stats-dropdown');
if (dropdown) {{
dropdown.innerHTML = '<div class="loading">Loading stats...</div>';
try {{
const response = await fetch(`${{DASHBOARD_PATH}}/api/ip-stats/${{ip}}`, {{
cache: 'no-store',
headers: {{
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}}
}});
if (!response.ok) throw new Error(`HTTP ${{response.status}}`);
const data = await response.json();
dropdown.innerHTML = data.error
? `<div style="color:#f85149;">Error: ${{data.error}}</div>`
: formatIpStats(data);
}} catch (err) {{
dropdown.innerHTML = `<div style="color:#f85149;">Failed to load stats: ${{err.message}}</div>`;
}}
}}
}});
}});
function formatIpStats(stats) {{
let html = '<div class="stats-left">';
html += '<div class="stat-row">';
html += '<span class="stat-label-sm">Total Requests:</span>';
html += `<span class="stat-value-sm">${{stats.total_requests || 0}}</span>`;
html += '</div>';
html += '<div class="stat-row">';
html += '<span class="stat-label-sm">First Seen:</span>';
html += `<span class="stat-value-sm">${{formatTimestamp(stats.first_seen)}}</span>`;
html += '</div>';
html += '<div class="stat-row">';
html += '<span class="stat-label-sm">Last Seen:</span>';
html += `<span class="stat-value-sm">${{formatTimestamp(stats.last_seen)}}</span>`;
html += '</div>';
if (stats.country_code || stats.city) {{
html += '<div class="stat-row">';
html += '<span class="stat-label-sm">Location:</span>';
html += `<span class="stat-value-sm">${{stats.city ? (stats.country_code ? `${{stats.city}}, ${{stats.country_code}}` : stats.city) : (stats.country_code || 'Unknown')}}</span>`;
html += '</div>';
}}
if (stats.country) {{
html += '<div class="stat-row">';
html += '<span class="stat-label-sm">Country:</span>';
html += `<span class="stat-value-sm">${{stats.country}}</span>`;
html += '</div>';
}}
if (stats.reverse) {{
html += '<div class="stat-row">';
html += '<span class="stat-label-sm">Reverse DNS:</span>';
html += `<span class="stat-value-sm">${{stats.reverse}}</span>`;
html += '</div>';
}}
if (stats.asn_org) {{
html += '<div class="stat-row">';
html += '<span class="stat-label-sm">ASN Org:</span>';
html += `<span class="stat-value-sm">${{stats.asn_org}}</span>`;
html += '</div>';
}}
if (stats.is_proxy !== undefined || stats.is_hosting !== undefined) {{
const flags = [];
if (stats.is_proxy) flags.push('Proxy');
if (stats.is_hosting) flags.push('Hosting');
if (flags.length > 0) {{
html += '<div class="stat-row">';
html += '<span class="stat-label-sm">Flags: <span title="Proxy: IP is using a proxy service. Hosting: IP is from a hosting/cloud provider" style="cursor: help; color: #58a6ff; font-weight: bold;">ⓘ</span></span>';
html += `<span class="stat-value-sm">${{flags.join(', ')}}</span>`;
html += '</div>';
}}
}}
if (stats.reputation_score !== null && stats.reputation_score !== undefined) {{
html += '<div class="stat-row">';
html += '<span class="stat-label-sm">Reputation Score:</span>';
html += `<span class="stat-value-sm">${{stats.reputation_score}} ${{stats.reputation_source ? '(' + stats.reputation_source + ')' : ''}}</span>`;
html += '</div>';
}}
if (stats.category) {{
html += '<div class="stat-row">';
html += '<span class="stat-label-sm">Category:</span>';
const categoryClass = 'category-' + stats.category.toLowerCase().replace('_', '-');
html += `<span class="category-badge ${{categoryClass}}">${{stats.category}}</span>`;
html += '</div>';
}}
if (stats.category_history && stats.category_history.length > 0) {{
html += '<div class="timeline-section">';
html += '<div class="timeline-container">';
// Timeline column
html += '<div class="timeline-column">';
html += '<div class="timeline-header">Behavior Timeline</div>';
html += '<div class="timeline">';
stats.category_history.forEach(change => {{
const categoryClass = change.new_category.toLowerCase().replace('_', '-');
const timestamp = formatTimestamp(change.timestamp);
const oldClass = change.old_category ? 'category-' + change.old_category.toLowerCase().replace('_', '-') : '';
const newClass = 'category-' + categoryClass;
html += '<div class="timeline-item">';
html += `<div class="timeline-marker ${{categoryClass}}"></div>`;
html += '<div class="timeline-content">';
if (change.old_category) {{
html += `<span class="category-badge ${{oldClass}}">${{change.old_category}}</span>`;
html += '<span style="color: #8b949e; margin: 0 4px;">→</span>';
}}
html += `<span class="category-badge ${{newClass}}">${{change.new_category}}</span>`;
html += `<div class="timeline-time">${{timestamp}}</div>`;
html += '</div>';
html += '</div>';
}});
html += '</div>';
html += '</div>';
// Reputation column
html += '<div class="timeline-column">';
if (stats.list_on && Object.keys(stats.list_on).length > 0) {{
// Filter out is_hosting and is_proxy from the displayed list
const filteredList = Object.entries(stats.list_on).filter(([source, data]) =>
source !== 'is_hosting' && source !== 'is_proxy'
);
if (filteredList.length > 0) {{
html += '<div class="timeline-header">Listed On</div>';
const sortedSources = filteredList.sort((a, b) => a[0].localeCompare(b[0]));
sortedSources.forEach(([source, data]) => {{
// Handle both string URLs and nested object data
if (typeof data === 'string' && data !== 'N/A') {{
html += `<a href="${{data}}" target="_blank" rel="noopener noreferrer" class="reputation-badge" title="${{source}}">${{source}}</a>`;
}} else if (typeof data === 'object' && data !== null) {{
// For nested blocklist data, extract source_link if available
const sourceLink = data['__source_link'] || data.source_link;
if (sourceLink) {{
html += `<a href="${{sourceLink}}" target="_blank" rel="noopener noreferrer" class="reputation-badge" title="${{source}}">${{source}}</a>`;
}} else {{
html += `<span class="reputation-badge">${{source}}</span>`;
}}
}} else {{
html += `<span class="reputation-badge">${{source}}</span>`;
}}
}});
}} else if (stats.country_code || stats.asn) {{
html += '<div class="timeline-header">Reputation</div>';
html += '<span class="reputation-clean" title="Not found on public blacklists">✓ Clean</span>';
}}
}} else if (stats.country_code || stats.asn) {{
html += '<div class="timeline-header">Reputation</div>';
html += '<span class="reputation-clean" title="Not found on public blacklists">✓ Clean</span>';
}}
html += '</div>';
html += '</div>';
html += '</div>';
}}
html += '</div>';
if (stats.category_scores && Object.keys(stats.category_scores).length > 0) {{
html += '<div class="stats-right">';
html += '<div style="font-size: 13px; font-weight: 600; color: #58a6ff; margin-bottom: 10px;">Category Score</div>';
html += '<svg class="radar-chart" viewBox="-30 -30 260 260" preserveAspectRatio="xMidYMid meet">';
const scores = {{
attacker: stats.category_scores.attacker || 0,
good_crawler: stats.category_scores.good_crawler || 0,
bad_crawler: stats.category_scores.bad_crawler || 0,
regular_user: stats.category_scores.regular_user || 0,
unknown: stats.category_scores.unknown || 0
}};
const maxScore = Math.max(...Object.values(scores), 1);
const minVisibleRadius = 0.15;
const normalizedScores = {{}};
Object.keys(scores).forEach(key => {{
normalizedScores[key] = minVisibleRadius + (scores[key] / maxScore) * (1 - minVisibleRadius);
}});
const colors = {{
attacker: '#f85149',
good_crawler: '#3fb950',
bad_crawler: '#f0883e',
regular_user: '#58a6ff',
unknown: '#8b949e'
}};
const labels = {{
attacker: 'Attacker',
good_crawler: 'Good Bot',
bad_crawler: 'Bad Bot',
regular_user: 'User',
unknown: 'Unknown'
}};
const cx = 100, cy = 100, maxRadius = 75;
for (let i = 1; i <= 5; i++) {{
const r = (maxRadius / 5) * i;
html += `<circle cx="${{cx}}" cy="${{cy}}" r="${{r}}" fill="none" stroke="#30363d" stroke-width="0.5"/>`;
}}
const angles = [0, 72, 144, 216, 288];
const keys = ['good_crawler', 'regular_user', 'unknown', 'bad_crawler', 'attacker'];
angles.forEach((angle, i) => {{
const rad = (angle - 90) * Math.PI / 180;
const x2 = cx + maxRadius * Math.cos(rad);
const y2 = cy + maxRadius * Math.sin(rad);
html += `<line x1="${{cx}}" y1="${{cy}}" x2="${{x2}}" y2="${{y2}}" stroke="#30363d" stroke-width="0.5"/>`;
const labelDist = maxRadius + 35;
const lx = cx + labelDist * Math.cos(rad);
const ly = cy + labelDist * Math.sin(rad);
html += `<text x="${{lx}}" y="${{ly}}" fill="#8b949e" font-size="12" text-anchor="middle" dominant-baseline="middle">${{labels[keys[i]]}}</text>`;
}});
let points = [];
angles.forEach((angle, i) => {{
const normalizedScore = normalizedScores[keys[i]];
const rad = (angle - 90) * Math.PI / 180;
const r = normalizedScore * maxRadius;
const x = cx + r * Math.cos(rad);
const y = cy + r * Math.sin(rad);
points.push(`${{x}},${{y}}`);
}});
const dominantKey = Object.keys(scores).reduce((a, b) => scores[a] > scores[b] ? a : b);
const dominantColor = colors[dominantKey];
html += `<polygon points="${{points.join(' ')}}" fill="${{dominantColor}}" fill-opacity="0.4" stroke="${{dominantColor}}" stroke-width="2.5"/>`;
angles.forEach((angle, i) => {{
const normalizedScore = normalizedScores[keys[i]];
const rad = (angle - 90) * Math.PI / 180;
const r = normalizedScore * maxRadius;
const x = cx + r * Math.cos(rad);
const y = cy + r * Math.sin(rad);
html += `<circle cx="${{x}}" cy="${{y}}" r="4.5" fill="${{colors[keys[i]]}}" stroke="#0d1117" stroke-width="2"/>`;
}});
html += '</svg>';
html += '<div class="radar-legend">';
keys.forEach(key => {{
html += '<div class="radar-legend-item">';
html += `<div class="radar-legend-color" style="background: ${{colors[key]}};"></div>`;
html += `<span style="color: #8b949e;">${{labels[key]}}: ${{scores[key]}} pt</span>`;
html += '</div>';
}});
html += '</div>';
html += '</div>';
}}
return html;
}}
// Generate radar chart for map panel
function generateMapPanelRadarChart(categoryScores) {{
if (!categoryScores || Object.keys(categoryScores).length === 0) {{
return '<div style="color: #8b949e; text-align: center; padding: 20px;">No category data available</div>';
}}
let html = '<div style="display: flex; flex-direction: column; align-items: center;">';
html += '<svg class="radar-chart" viewBox="-30 -30 260 260" preserveAspectRatio="xMidYMid meet" style="width: 160px; height: 160px;">';
const scores = {{
attacker: categoryScores.attacker || 0,
good_crawler: categoryScores.good_crawler || 0,
bad_crawler: categoryScores.bad_crawler || 0,
regular_user: categoryScores.regular_user || 0,
unknown: categoryScores.unknown || 0
}};
const maxScore = Math.max(...Object.values(scores), 1);
const minVisibleRadius = 0.15;
const normalizedScores = {{}};
Object.keys(scores).forEach(key => {{
normalizedScores[key] = minVisibleRadius + (scores[key] / maxScore) * (1 - minVisibleRadius);
}});
const colors = {{
attacker: '#f85149',
good_crawler: '#3fb950',
bad_crawler: '#f0883e',
regular_user: '#58a6ff',
unknown: '#8b949e'
}};
const labels = {{
attacker: 'Attacker',
good_crawler: 'Good Bot',
bad_crawler: 'Bad Bot',
regular_user: 'User',
unknown: 'Unknown'
}};
const cx = 100, cy = 100, maxRadius = 75;
for (let i = 1; i <= 5; i++) {{
const r = (maxRadius / 5) * i;
html += `<circle cx="${{cx}}" cy="${{cy}}" r="${{r}}" fill="none" stroke="#30363d" stroke-width="0.5"/>`;
}}
const angles = [0, 72, 144, 216, 288];
const keys = ['good_crawler', 'regular_user', 'unknown', 'bad_crawler', 'attacker'];
angles.forEach((angle, i) => {{
const rad = (angle - 90) * Math.PI / 180;
const x2 = cx + maxRadius * Math.cos(rad);
const y2 = cy + maxRadius * Math.sin(rad);
html += `<line x1="${{cx}}" y1="${{cy}}" x2="${{x2}}" y2="${{y2}}" stroke="#30363d" stroke-width="0.5"/>`;
const labelDist = maxRadius + 35;
const lx = cx + labelDist * Math.cos(rad);
const ly = cy + labelDist * Math.sin(rad);
html += `<text x="${{lx}}" y="${{ly}}" fill="#8b949e" font-size="12" text-anchor="middle" dominant-baseline="middle">${{labels[keys[i]]}}</text>`;
}});
let points = [];
angles.forEach((angle, i) => {{
const normalizedScore = normalizedScores[keys[i]];
const rad = (angle - 90) * Math.PI / 180;
const r = normalizedScore * maxRadius;
const x = cx + r * Math.cos(rad);
const y = cy + r * Math.sin(rad);
points.push(`${{x}},${{y}}`);
}});
const dominantKey = Object.keys(scores).reduce((a, b) => scores[a] > scores[b] ? a : b);
const dominantColor = colors[dominantKey];
html += `<polygon points="${{points.join(' ')}}" fill="${{dominantColor}}" fill-opacity="0.4" stroke="${{dominantColor}}" stroke-width="2.5"/>`;
angles.forEach((angle, i) => {{
const normalizedScore = normalizedScores[keys[i]];
const rad = (angle - 90) * Math.PI / 180;
const r = normalizedScore * maxRadius;
const x = cx + r * Math.cos(rad);
const y = cy + r * Math.sin(rad);
html += `<circle cx="${{x}}" cy="${{y}}" r="4.5" fill="${{colors[keys[i]]}}" stroke="#0d1117" stroke-width="2"/>`;
}});
html += '</svg>';
html += '</div>';
return html;
}}
// Tab functionality with hash-based routing
function switchTab(tabName) {{
// Hide all tabs
document.querySelectorAll('.tab-content').forEach(tab => {{
tab.classList.remove('active');
}});
// Remove active class from all buttons
document.querySelectorAll('.tab-button').forEach(btn => {{
btn.classList.remove('active');
}});
// Show selected tab
const selectedTab = document.getElementById(tabName);
const selectedButton = document.querySelector(`.tab-button[href="#${{tabName}}"]`);
if (selectedTab) {{
selectedTab.classList.add('active');
}}
if (selectedButton) {{
selectedButton.classList.add('active');
}}
// Load data for this tab
if (tabName === 'ip-stats') {{
loadIpStatistics(1);
// Load attack and credentials tables if not already loaded
if (!overviewState.attacks.loaded) {{
loadOverviewTable('attacks');
overviewState.attacks.loaded = true;
}}
if (!overviewState.credentials.loaded) {{
loadOverviewTable('credentials');
overviewState.credentials.loaded = true;
}}
if (!overviewState.patterns.loaded) {{
loadOverviewTable('patterns');
overviewState.patterns.loaded = true;
}}
}}
}}
// Handle hash changes
window.addEventListener('hashchange', function() {{
const hash = window.location.hash.slice(1) || 'overview';
switchTab(hash);
}});
// Initialize tabs on page load
document.addEventListener('DOMContentLoaded', function() {{
const hash = window.location.hash.slice(1) || 'overview';
switchTab(hash);
}});
// Prevent default anchor behavior and use hash navigation
document.querySelectorAll('.tab-button').forEach(button => {{
button.addEventListener('click', function(e) {{
e.preventDefault();
const href = this.getAttribute('href');
window.location.hash = href;
}});
}});
// Handle sorting for IP stats table
document.addEventListener('click', function(e) {{
if (e.target.classList.contains('sortable') && e.target.closest('#ip-stats-tbody')) {{
return; // Don't sort when inside tbody
}}
const sortHeader = e.target.closest('th.sortable');
if (!sortHeader) return;
const table = sortHeader.closest('table');
if (!table || !table.classList.contains('ip-stats-table')) return;
const sortField = sortHeader.getAttribute('data-sort');
// Toggle sort order if clicking the same field
if (currentSortBy === sortField) {{
currentSortOrder = currentSortOrder === 'desc' ? 'asc' : 'desc';
}} else {{
currentSortBy = sortField;
currentSortOrder = 'desc';
}}
// Update UI indicators
table.querySelectorAll('th.sortable').forEach(th => {{
th.classList.remove('asc', 'desc');
}});
sortHeader.classList.add(currentSortOrder);
// Reload with new sort
loadIpStatistics(1);
}});
let currentPage = 1;
let totalPages = 1;
let currentSortBy = "total_requests";
let currentSortOrder = "desc";
const PAGE_SIZE = 5;
async function loadIpStatistics(page = 1) {{
const tbody = document.getElementById('ip-stats-tbody');
if (!tbody) {{
console.error('IP stats tbody not found');
return;
}}
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center;">Loading...</td></tr>';
try {{
console.log('Fetching attackers from page:', page, 'sort:', currentSortBy, currentSortOrder);
const response = await fetch(DASHBOARD_PATH + '/api/attackers?page=' + page + '&page_size=' + PAGE_SIZE + '&sort_by=' + currentSortBy + '&sort_order=' + currentSortOrder, {{
cache: 'no-store',
headers: {{
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}}
}});
console.log('Response status:', response.status);
if (!response.ok) throw new Error(`HTTP ${{response.status}}`);
const data = await response.json();
console.log('Received data:', data);
if (!data.attackers || data.attackers.length === 0) {{
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center;">No attackers on this page.</td></tr>';
currentPage = page;
totalPages = data.pagination?.total_pages || 1;
updatePaginationControls();
return;
}}
// Update pagination info
currentPage = data.pagination.page;
totalPages = data.pagination.total_pages;
document.getElementById('current-page').textContent = currentPage;
document.getElementById('total-pages').textContent = totalPages;
document.getElementById('total-attackers').textContent = data.pagination.total_attackers;
updatePaginationControls();
let html = '';
data.attackers.forEach((attacker, index) => {{
const rank = (currentPage - 1) * PAGE_SIZE + index + 1;
html += `<tr class="ip-row" data-ip="${{attacker.ip}}">
<td class="rank">${{rank}}</td>
<td class="ip-clickable">${{attacker.ip}}</td>
<td>${{attacker.total_requests}}</td>
<td>${{formatTimestamp(attacker.first_seen)}}</td>
<td>${{formatTimestamp(attacker.last_seen)}}</td>
<td>${{attacker.city ? (attacker.country_code ? `${{attacker.city}}, ${{attacker.country_code}}` : attacker.city) : (attacker.country_code || 'Unknown')}}</td>
</tr>
<tr class="ip-stats-row" id="stats-row-${{attacker.ip.replace('.', '-')}}" style="display: none;">
<td colspan="6" class="ip-stats-cell">
<div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div>
</div>
</td>
</tr>`;
}});
tbody.innerHTML = html;
console.log('Populated', data.attackers.length, 'attacker records');
// Re-attach click listeners for expandable rows
attachAttackerClickListeners();
}} catch (err) {{
console.error('Error loading attackers:', err);
tbody.innerHTML = `<tr><td colspan="6" style="text-align: center; color: #f85149;">Failed to load: ${{err.message}}</td></tr>`;
}}
}}
function updatePaginationControls() {{
const prevBtn = document.getElementById('prev-page-btn');
const nextBtn = document.getElementById('next-page-btn');
if (prevBtn) prevBtn.disabled = currentPage <= 1;
if (nextBtn) nextBtn.disabled = currentPage >= totalPages;
}}
function previousPageIpStats() {{
if (currentPage > 1) {{
loadIpStatistics(currentPage - 1);
}}
}}
function nextPageIpStats() {{
if (currentPage < totalPages) {{
loadIpStatistics(currentPage + 1);
}}
}}
function attachAttackerClickListeners() {{
document.querySelectorAll('#ip-stats-tbody .ip-clickable').forEach(cell => {{
cell.addEventListener('click', async function(e) {{
const row = e.currentTarget.closest('.ip-row');
if (!row) return;
const ip = row.getAttribute('data-ip');
const statsRow = row.nextElementSibling;
if (!statsRow || !statsRow.classList.contains('ip-stats-row')) return;
const isVisible = getComputedStyle(statsRow).display !== 'none';
// Close other open rows
document.querySelectorAll('#ip-stats-tbody .ip-stats-row').forEach(r => {{
r.style.display = 'none';
}});
if (isVisible) return;
statsRow.style.display = 'table-row';
const dropdown = statsRow.querySelector('.ip-stats-dropdown');
if (dropdown) {{
dropdown.innerHTML = '<div class="loading">Loading stats...</div>';
try {{
const response = await fetch(`${{DASHBOARD_PATH}}/api/ip-stats/${{ip}}`, {{
cache: 'no-store',
headers: {{
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}}
}});
if (!response.ok) throw new Error(`HTTP ${{response.status}}`);
const data = await response.json();
dropdown.innerHTML = data.error
? `<div style="color:#f85149;">Error: ${{data.error}}</div>`
: formatIpStats(data);
}} catch (err) {{
dropdown.innerHTML = `<div style="color:#f85149;">Failed to load stats: ${{err.message}}</div>`;
}}
}}
}});
}});
}}
function attachTopIpsClickListeners() {{
document.querySelectorAll('#top-ips-tbody .ip-clickable').forEach(cell => {{
cell.addEventListener('click', async function(e) {{
const row = e.currentTarget.closest('.ip-row');
if (!row) return;
const ip = row.getAttribute('data-ip');
const statsRow = row.nextElementSibling;
if (!statsRow || !statsRow.classList.contains('ip-stats-row')) return;
const isVisible = getComputedStyle(statsRow).display !== 'none';
// Close other open rows in this table
document.querySelectorAll('#top-ips-tbody .ip-stats-row').forEach(r => {{
r.style.display = 'none';
}});
if (isVisible) return;
statsRow.style.display = 'table-row';
const dropdown = statsRow.querySelector('.ip-stats-dropdown');
if (dropdown) {{
dropdown.innerHTML = '<div class="loading">Loading stats...</div>';
try {{
const response = await fetch(`${{DASHBOARD_PATH}}/api/ip-stats/${{ip}}`, {{
cache: 'no-store',
headers: {{
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}}
}});
if (!response.ok) throw new Error(`HTTP ${{response.status}}`);
const data = await response.json();
dropdown.innerHTML = data.error
? `<div style="color:#f85149;">Error: ${{data.error}}</div>`
: formatIpStats(data);
}} catch (err) {{
dropdown.innerHTML = `<div style="color:#f85149;">Failed to load stats: ${{err.message}}</div>`;
}}
}}
}});
}});
}}
function attachHoneypotClickListeners() {{
document.querySelectorAll('#honeypot-tbody .ip-clickable').forEach(cell => {{
cell.addEventListener('click', async function(e) {{
const row = e.currentTarget.closest('.ip-row');
if (!row) return;
const ip = row.getAttribute('data-ip');
const statsRow = row.nextElementSibling;
if (!statsRow || !statsRow.classList.contains('ip-stats-row')) return;
const isVisible = getComputedStyle(statsRow).display !== 'none';
document.querySelectorAll('#honeypot-tbody .ip-stats-row').forEach(r => {{
r.style.display = 'none';
}});
if (isVisible) return;
statsRow.style.display = 'table-row';
const dropdown = statsRow.querySelector('.ip-stats-dropdown');
if (dropdown) {{
dropdown.innerHTML = '<div class="loading">Loading stats...</div>';
try {{
const response = await fetch(`${{DASHBOARD_PATH}}/api/ip-stats/${{ip}}`, {{
cache: 'no-store',
headers: {{
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}}
}});
if (!response.ok) throw new Error(`HTTP ${{response.status}}`);
const data = await response.json();
dropdown.innerHTML = data.error
? `<div style="color:#f85149;">Error: ${{data.error}}</div>`
: formatIpStats(data);
}} catch (err) {{
dropdown.innerHTML = `<div style="color:#f85149;">Failed to load stats: ${{err.message}}</div>`;
}}
}}
}});
}});
}}
function attachCredentialsClickListeners() {{
document.querySelectorAll('#credentials-tbody .ip-clickable').forEach(cell => {{
cell.addEventListener('click', async function(e) {{
const row = e.currentTarget.closest('.ip-row');
if (!row) return;
const ip = row.getAttribute('data-ip');
const statsRow = row.nextElementSibling;
if (!statsRow || !statsRow.classList.contains('ip-stats-row')) return;
const isVisible = getComputedStyle(statsRow).display !== 'none';
document.querySelectorAll('#credentials-tbody .ip-stats-row').forEach(r => {{
r.style.display = 'none';
}});
if (isVisible) return;
statsRow.style.display = 'table-row';
const dropdown = statsRow.querySelector('.ip-stats-dropdown');
if (dropdown) {{
dropdown.innerHTML = '<div class="loading">Loading stats...</div>';
try {{
const response = await fetch(`${{DASHBOARD_PATH}}/api/ip-stats/${{ip}}`, {{
cache: 'no-store',
headers: {{
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}}
}});
if (!response.ok) throw new Error(`HTTP ${{response.status}}`);
const data = await response.json();
dropdown.innerHTML = data.error
? `<div style="color:#f85149;">Error: ${{data.error}}</div>`
: formatIpStats(data);
}} catch (err) {{
dropdown.innerHTML = `<div style="color:#f85149;">Failed to load stats: ${{err.message}}</div>`;
}}
}}
}});
}});
}}
function attachAttacksClickListeners() {{
document.querySelectorAll('#attacks-tbody .ip-clickable').forEach(cell => {{
cell.addEventListener('click', async function(e) {{
const row = e.currentTarget.closest('.ip-row');
if (!row) return;
const ip = row.getAttribute('data-ip');
const statsRow = row.nextElementSibling;
if (!statsRow || !statsRow.classList.contains('ip-stats-row')) return;
const isVisible = getComputedStyle(statsRow).display !== 'none';
document.querySelectorAll('#attacks-tbody .ip-stats-row').forEach(r => {{
r.style.display = 'none';
}});
if (isVisible) return;
statsRow.style.display = 'table-row';
const dropdown = statsRow.querySelector('.ip-stats-dropdown');
if (dropdown) {{
dropdown.innerHTML = '<div class="loading">Loading stats...</div>';
try {{
const response = await fetch(`${{DASHBOARD_PATH}}/api/ip-stats/${{ip}}`, {{
cache: 'no-store',
headers: {{
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}}
}});
if (!response.ok) throw new Error(`HTTP ${{response.status}}`);
const data = await response.json();
dropdown.innerHTML = data.error
? `<div style="color:#f85149;">Error: ${{data.error}}</div>`
: formatIpStats(data);
}} catch (err) {{
dropdown.innerHTML = `<div style="color:#f85149;">Failed to load stats: ${{err.message}}</div>`;
}}
}}
}});
}});
}}
// Overview tables state management
const overviewState = {{
honeypot: {{ currentPage: 1, totalPages: 1, total: 0, sortBy: 'count', sortOrder: 'desc' }},
credentials: {{ currentPage: 1, totalPages: 1, total: 0, sortBy: 'timestamp', sortOrder: 'desc', loaded: false }},
'top-ips': {{ currentPage: 1, totalPages: 1, total: 0, sortBy: 'count', sortOrder: 'desc' }},
'top-paths': {{ currentPage: 1, totalPages: 1, total: 0, sortBy: 'count', sortOrder: 'desc' }},
'top-ua': {{ currentPage: 1, totalPages: 1, total: 0, sortBy: 'count', sortOrder: 'desc' }},
attacks: {{ currentPage: 1, totalPages: 1, total: 0, sortBy: 'timestamp', sortOrder: 'desc', loaded: false }},
patterns: {{ currentPage: 1, totalPages: 1, total: 0, sortBy: 'count', sortOrder: 'desc', loaded: false }}
}};
const tableConfig = {{
honeypot: {{ endpoint: 'honeypot', dataKey: 'honeypots', cellCount: 4, columns: ['ip', 'paths', 'count'] }},
credentials: {{ endpoint: 'credentials', dataKey: 'credentials', cellCount: 6, columns: ['ip', 'username', 'password', 'path', 'timestamp'] }},
'top-ips': {{ endpoint: 'top-ips', dataKey: 'ips', cellCount: 3, columns: ['ip', 'count'] }},
'top-paths': {{ endpoint: 'top-paths', dataKey: 'paths', cellCount: 3, columns: ['path', 'count'] }},
'top-ua': {{ endpoint: 'top-user-agents', dataKey: 'user_agents', cellCount: 3, columns: ['user_agent', 'count'] }},
attacks: {{ endpoint: 'attack-types', dataKey: 'attacks', cellCount: 7, columns: ['ip', 'path', 'attack_types', 'user_agent', 'timestamp', 'raw_request'] }},
patterns: {{ endpoint: 'attack-patterns', dataKey: 'patterns', cellCount: 5, columns: ['pattern', 'attack_type', 'count', 'ips'] }}
}};
// Load overview table on page load
async function loadOverviewTable(tableId) {{
const config = tableConfig[tableId];
if (!config) return;
const state = overviewState[tableId];
const tbody = document.getElementById(tableId + '-tbody');
if (!tbody) return;
// Just fade out without showing loading text
tbody.style.opacity = '0';
try {{
let items;
let pagination;
// Special handling for attack patterns
if (tableId === 'patterns') {{
// Fetch all attacks to extract and aggregate patterns
try {{
const patternsUrl = DASHBOARD_PATH + '/api/attack-types?page=1&page_size=1000&sort_by=timestamp&sort_order=desc';
const patternsResponse = await fetch(patternsUrl, {{ cache: 'no-store', headers: {{ 'Cache-Control': 'no-cache', 'Pragma': 'no-cache' }} }});
if (!patternsResponse.ok) throw new Error('Failed to fetch patterns');
const patternsData = await patternsResponse.json();
const allAttacks = patternsData.attacks || [];
// Aggregate patterns
const patternMap = {{}};
allAttacks.forEach(attack => {{
// Extract patterns from the path or query
const patterns = [];
if (attack.path && attack.path.length < 200) patterns.push(attack.path);
patterns.forEach(pattern => {{
if (!patternMap[pattern]) {{
patternMap[pattern] = {{
pattern: pattern,
attack_type: (attack.attack_types && attack.attack_types[0]) || 'Unknown',
count: 0,
example_ips: []
}};
}}
patternMap[pattern].count++;
if (patternMap[pattern].example_ips.indexOf(attack.ip) === -1) {{
patternMap[pattern].example_ips.push(attack.ip);
}}
}});
}});
// Convert to sorted array based on current sort preferences
const patternArray = Object.values(patternMap);
// Apply sorting based on state
patternArray.sort((a, b) => {{
let aValue, bValue;
if (state.sortBy === 'count') {{
aValue = a.count;
bValue = b.count;
return state.sortOrder === 'desc' ? bValue - aValue : aValue - bValue;
}} else if (state.sortBy === 'pattern') {{
aValue = a.pattern || '';
bValue = b.pattern || '';
return state.sortOrder === 'desc' ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue);
}} else if (state.sortBy === 'attack_type') {{
aValue = a.attack_type || '';
bValue = b.attack_type || '';
return state.sortOrder === 'desc' ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue);
}}
// Default to count desc
return b.count - a.count;
}});
items = patternArray.slice((state.currentPage - 1) * 5, state.currentPage * 5);
const totalPatterns = Object.values(patternMap).length;
pagination = {{
page: state.currentPage,
total_pages: Math.ceil(totalPatterns / 5),
total: totalPatterns
}};
}} catch (err) {{
console.error('Error processing patterns:', err);
items = [];
pagination = {{ page: 1, total_pages: 1, total: 0 }};
}}
}} else {{
const url = DASHBOARD_PATH + '/api/' + config.endpoint + '?page=' + state.currentPage + '&page_size=5&sort_by=' + state.sortBy + '&sort_order=' + state.sortOrder;
const response = await fetch(url, {{ cache: 'no-store', headers: {{ 'Cache-Control': 'no-cache', 'Pragma': 'no-cache' }} }});
if (!response.ok) throw new Error(`HTTP ${{response.status}}`);
const data = await response.json();
items = data[config.dataKey] || [];
pagination = data.pagination || {{}};
}}
state.currentPage = pagination.page || 1;
state.totalPages = pagination.total_pages || 1;
state.total = pagination.total || 0;
updateOverviewPaginationControls(tableId);
if (items.length === 0) {{
tbody.style.opacity = '0';
setTimeout(() => {{
tbody.innerHTML = '<tr><td colspan="' + config.cellCount + '" style="text-align: center; color: #6e7681; padding: 20px; font-size: 13px;">No data</td></tr>';
tbody.style.opacity = '1';
}}, 50);
return;
}}
let html = '';
items.forEach((item, index) => {{
const rank = (state.currentPage - 1) * 5 + index + 1;
if (tableId === 'honeypot') {{
html += `<tr class="ip-row" data-ip="${{item.ip}}"><td class="rank">${{rank}}</td><td class="ip-clickable">${{item.ip}}</td><td>${{item.paths.join(', ')}}</td><td>${{item.count}}</td></tr>`;
html += `<tr class="ip-stats-row" id="stats-row-honeypot-${{item.ip.replace(/\\./g, '-')}}" style="display: none;">
<td colspan="4" class="ip-stats-cell">
<div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div>
</div>
</td>
</tr>`;
}} else if (tableId === 'credentials') {{
html += `<tr class="ip-row" data-ip="${{item.ip}}"><td class="rank">${{rank}}</td><td class="ip-clickable">${{item.ip}}</td><td>${{item.username}}</td><td>${{item.password}}</td><td>${{item.path}}</td><td>${{formatTimestamp(item.timestamp, true)}}</td></tr>`;
html += `<tr class="ip-stats-row" id="stats-row-credentials-${{item.ip.replace(/\\./g, '-')}}" style="display: none;">
<td colspan="6" class="ip-stats-cell">
<div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div>
</div>
</td>
</tr>`;
}} else if (tableId === 'top-ips') {{
html += `<tr class="ip-row" data-ip="${{item.ip}}"><td class="rank">${{rank}}</td><td class="ip-clickable">${{item.ip}}</td><td>${{item.count}}</td></tr>`;
html += `<tr class="ip-stats-row" id="stats-row-top-ips-${{item.ip.replace(/\\./g, '-')}}" style="display: none;">
<td colspan="3" class="ip-stats-cell">
<div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div>
</div>
</td>
</tr>`;
}} else if (tableId === 'top-paths') {{
html += `<tr><td class="rank">${{rank}}</td><td>${{item.path}}</td><td>${{item.count}}</td></tr>`;
}} else if (tableId === 'top-ua') {{
html += `<tr><td class="rank">${{rank}}</td><td style="word-break: break-all;">${{item.user_agent.substring(0, 80)}}</td><td>${{item.count}}</td></tr>`;
}} else if (tableId === 'attacks') {{
const actionBtn = item.raw_request
? `<button class="action-btn" onclick="viewRawRequest(${{item.id}})" style="padding: 4px 8px; background: #0969da; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 11px;">View Request</button>`
: `<span style="color: #6e7681; font-size: 11px;">N/A</span>`;
const attackTypesStr = item.attack_types.join(', ');
const itemId = item.id || '';
const attackTypesHtml = `<div class="attack-types-cell" title="${{attackTypesStr}}">
<div class="attack-types-truncated">${{attackTypesStr}}</div>
${{attackTypesStr.length > 50 ? `<div class="attack-types-tooltip">${{attackTypesStr}}</div>` : ''}}
</div>`;
const pathStr = item.path || '';
const truncatedPath = pathStr.length > 50 ? pathStr.substring(0, 47) + '...' : pathStr;
const pathHtml = `<div class="path-cell-container" onclick="event.stopPropagation(); viewRawRequest(${{itemId}})">
<div class="path-truncated" title="Click to view raw request">${{truncatedPath}}</div>
${{pathStr.length > 50 ? `<div class="path-tooltip">${{pathStr}}</div>` : ''}}
</div>`;
html += `<tr class="ip-row" data-ip="${{item.ip}}"><td class="rank">${{rank}}</td><td class="ip-clickable">${{item.ip}}</td><td>${{pathHtml}}</td><td>${{attackTypesHtml}}</td><td style="word-break: break-all;">${{item.user_agent.substring(0, 60)}}</td><td>${{formatTimestamp(item.timestamp, true)}}</td><td>${{actionBtn}}</td></tr>`;
html += `<tr class="ip-stats-row" id="stats-row-attacks-${{item.ip.replace(/\\./g, '-')}}" style="display: none;">
<td colspan="7" class="ip-stats-cell">
<div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div>
</div>
</td>
</tr>`;
}} else if (tableId === 'patterns') {{
const patternStr = item.pattern || '(empty)';
const exampleIps = item.example_ips ? item.example_ips.slice(0, 3).join(', ') : 'N/A';
const ipText = item.example_ips && item.example_ips.length > 3 ? exampleIps + '...' : exampleIps;
html += `<tr><td class="rank">${{rank}}</td><td title="${{patternStr}}" style="word-break: break-all;">${{patternStr.substring(0, 60)}}</td><td>${{item.attack_type || 'Unknown'}}</td><td style="color: #f85149; font-weight: bold;">${{item.count}}</td><td style="font-size: 11px; color: #8b949e;">${{ipText}}</td></tr>`;
}}
}});
// Fade in new content
tbody.style.opacity = '0';
setTimeout(() => {{
tbody.innerHTML = html;
tbody.style.opacity = '1';
// Attach click listeners for IP cells in tables
if (tableId === 'top-ips') {{
attachTopIpsClickListeners();
}} else if (tableId === 'honeypot') {{
attachHoneypotClickListeners();
}} else if (tableId === 'credentials') {{
attachCredentialsClickListeners();
}} else if (tableId === 'attacks') {{
attachAttacksClickListeners();
}}
}}, 50);
}} catch (err) {{
console.error('Error loading overview table ' + tableId + ':', err);
tbody.style.opacity = '0';
setTimeout(() => {{
tbody.innerHTML = '<tr><td colspan="' + config.cellCount + '" style="text-align: center; color: #f85149; padding: 20px; font-size: 13px;">Failed to load</td></tr>';
tbody.style.opacity = '1';
}}, 50);
}}
}}
function updateOverviewPaginationControls(tableId) {{
const state = overviewState[tableId];
const pagination = document.getElementById(tableId + '-pagination');
if (!pagination) return;
const prevBtn = pagination.querySelector('.pagination-btn:nth-child(2)');
const nextBtn = pagination.querySelector('.pagination-btn:nth-child(3)');
const currentPageEl = pagination.querySelector('.current-page');
const totalPagesEl = pagination.querySelector('.total-pages');
const totalRecordsEl = pagination.querySelector('.total-records');
if (prevBtn) prevBtn.disabled = state.currentPage <= 1;
if (nextBtn) nextBtn.disabled = state.currentPage >= state.totalPages;
if (currentPageEl) currentPageEl.textContent = state.currentPage;
if (totalPagesEl) totalPagesEl.textContent = state.totalPages;
if (totalRecordsEl) totalRecordsEl.textContent = state.total;
}}
function previousPage(tableId) {{
if (overviewState[tableId].currentPage > 1) {{
overviewState[tableId].currentPage--;
loadOverviewTable(tableId);
}}
}}
function nextPage(tableId) {{
if (overviewState[tableId].currentPage < overviewState[tableId].totalPages) {{
overviewState[tableId].currentPage++;
loadOverviewTable(tableId);
}}
}}
// Handle sorting for overview tables
document.addEventListener('click', function(e) {{
const header = e.target.closest('th.sortable[data-table]');
if (!header) return;
const tableId = header.getAttribute('data-table');
const sortField = header.getAttribute('data-sort');
const state = overviewState[tableId];
if (!state) return;
// Toggle sort order if same field
if (state.sortBy === sortField) {{
state.sortOrder = state.sortOrder === 'desc' ? 'asc' : 'desc';
}} else {{
state.sortBy = sortField;
state.sortOrder = 'desc';
}}
// Update UI and reload
const table = header.closest('table');
if (table) {{
table.querySelectorAll('th.sortable').forEach(th => {{
th.classList.remove('asc', 'desc');
}});
header.classList.add(state.sortOrder);
}}
state.currentPage = 1;
loadOverviewTable(tableId);
}});
// Load all overview tables when page loads
window.addEventListener('load', function() {{
// Only load tables that are in the Overview tab
const overviewTableIds = ['honeypot', 'top-ips', 'top-paths', 'top-ua'];
overviewTableIds.forEach(tableId => {{
loadOverviewTable(tableId);
}});
}})
async function showIpDetail(ip) {{
const modal = document.getElementById('ip-detail-modal');
const bodyDiv = document.getElementById('ip-detail-body');
if (!modal || !bodyDiv) return;
bodyDiv.innerHTML = '<div class="loading" style="text-align: center;">Loading IP details...</div>';
modal.classList.add('show');
try {{
const response = await fetch(`${{DASHBOARD_PATH}}/api/ip-stats/${{ip}}`, {{
cache: 'no-store',
headers: {{
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}}
}});
if (!response.ok) throw new Error(`HTTP ${{response.status}}`);
const stats = await response.json();
bodyDiv.innerHTML = '<h2>' + stats.ip + ' - Detailed Statistics</h2>' + formatIpStats(stats);
}} catch (err) {{
bodyDiv.innerHTML = `<div style="color: #f85149;">Failed to load details: ${{err.message}}</div>`;
}}
}}
function closeIpDetailModal() {{
const modal = document.getElementById('ip-detail-modal');
if (modal) {{
modal.classList.remove('show');
}}
}}
// Close modal when clicking outside
document.getElementById('ip-detail-modal')?.addEventListener('click', function(e) {{
if (e.target === this) {{
closeIpDetailModal();
}}
}});
// Add CSS for view button
const style = document.createElement('style');
style.textContent = `
.view-btn {{
padding: 6px 12px;
background: #238636;
color: #ffffff;
border: 1px solid #2ea043;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: background 0.2s;
}}
.view-btn:hover {{
background: #2ea043;
}}
.view-btn:active {{
background: #1f7a2f;
}}
.pagination-btn:hover:not(:disabled) {{
background: #1f6feb !important;
}}
.pagination-btn:disabled {{
opacity: 0.5;
cursor: not-allowed;
}}
`;
document.head.appendChild(style);
// IP Map Visualization
let attackerMap = null;
let allIps = [];
let mapMarkers = [];
let markerLayers = {{}};
const categoryColors = {{
attacker: '#f85149',
bad_crawler: '#f0883e',
good_crawler: '#3fb950',
regular_user: '#58a6ff',
unknown: '#8b949e'
}};
async function initializeAttackerMap() {{
const mapContainer = document.getElementById('attacker-map');
if (!mapContainer || attackerMap) return;
try {{
// Initialize map
attackerMap = L.map('attacker-map', {{
center: [20, 0],
zoom: 2,
layers: [
L.tileLayer('https://{{s}}.basemaps.cartocdn.com/dark_all/{{z}}/{{x}}/{{y}}{{r}}.png', {{
attribution: '© CartoDB | © OpenStreetMap contributors',
maxZoom: 19,
subdomains: 'abcd'
}})
]
}});
// Fetch all IPs (not just attackers)
const response = await fetch(DASHBOARD_PATH + '/api/all-ips?page=1&page_size=100&sort_by=total_requests&sort_order=desc', {{
cache: 'no-store',
headers: {{
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}}
}});
if (!response.ok) throw new Error('Failed to fetch IPs');
const data = await response.json();
allIps = data.ips || [];
if (allIps.length === 0) {{
mapContainer.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #8b949e;\">No IP location data available</div>';
return;
}}
// Get max request count for scaling
const maxRequests = Math.max(...allIps.map(ip => ip.total_requests || 0));
// City coordinates database (major cities worldwide)
const cityCoordinates = {{
// United States
'New York': [40.7128, -74.0060], 'Los Angeles': [34.0522, -118.2437],
'San Francisco': [37.7749, -122.4194], 'Chicago': [41.8781, -87.6298],
'Seattle': [47.6062, -122.3321], 'Miami': [25.7617, -80.1918],
'Boston': [42.3601, -71.0589], 'Atlanta': [33.7490, -84.3880],
'Dallas': [32.7767, -96.7970], 'Houston': [29.7604, -95.3698],
'Denver': [39.7392, -104.9903], 'Phoenix': [33.4484, -112.0740],
// Europe
'London': [51.5074, -0.1278], 'Paris': [48.8566, 2.3522],
'Berlin': [52.5200, 13.4050], 'Amsterdam': [52.3676, 4.9041],
'Moscow': [55.7558, 37.6173], 'Rome': [41.9028, 12.4964],
'Madrid': [40.4168, -3.7038], 'Barcelona': [41.3874, 2.1686],
'Milan': [45.4642, 9.1900], 'Vienna': [48.2082, 16.3738],
'Stockholm': [59.3293, 18.0686], 'Oslo': [59.9139, 10.7522],
'Copenhagen': [55.6761, 12.5683], 'Warsaw': [52.2297, 21.0122],
'Prague': [50.0755, 14.4378], 'Budapest': [47.4979, 19.0402],
'Athens': [37.9838, 23.7275], 'Lisbon': [38.7223, -9.1393],
'Brussels': [50.8503, 4.3517], 'Dublin': [53.3498, -6.2603],
'Zurich': [47.3769, 8.5417], 'Geneva': [46.2044, 6.1432],
'Helsinki': [60.1699, 24.9384], 'Bucharest': [44.4268, 26.1025],
'Saint Petersburg': [59.9343, 30.3351], 'Manchester': [53.4808, -2.2426],
'Roubaix': [50.6942, 3.1746], 'Frankfurt': [50.1109, 8.6821],
'Munich': [48.1351, 11.5820], 'Hamburg': [53.5511, 9.9937],
// Asia
'Tokyo': [35.6762, 139.6503], 'Beijing': [39.9042, 116.4074],
'Shanghai': [31.2304, 121.4737], 'Singapore': [1.3521, 103.8198],
'Mumbai': [19.0760, 72.8777], 'Delhi': [28.7041, 77.1025],
'Bangalore': [12.9716, 77.5946], 'Seoul': [37.5665, 126.9780],
'Hong Kong': [22.3193, 114.1694], 'Bangkok': [13.7563, 100.5018],
'Jakarta': [6.2088, 106.8456], 'Manila': [14.5995, 120.9842],
'Hanoi': [21.0285, 105.8542], 'Ho Chi Minh City': [10.8231, 106.6297],
'Taipei': [25.0330, 121.5654], 'Kuala Lumpur': [3.1390, 101.6869],
'Karachi': [24.8607, 67.0011], 'Islamabad': [33.6844, 73.0479],
'Dhaka': [23.8103, 90.4125], 'Colombo': [6.9271, 79.8612],
// South America
'São Paulo': [-23.5505, -46.6333], 'Rio de Janeiro': [-22.9068, -43.1729],
'Buenos Aires': [-34.6037, -58.3816], 'Bogotá': [4.7110, -74.0721],
'Lima': [-12.0464, -77.0428], 'Santiago': [-33.4489, -70.6693],
// Middle East & Africa
'Cairo': [30.0444, 31.2357], 'Dubai': [25.2048, 55.2708],
'Istanbul': [41.0082, 28.9784], 'Tel Aviv': [32.0853, 34.7818],
'Johannesburg': [26.2041, 28.0473], 'Lagos': [6.5244, 3.3792],
'Nairobi': [-1.2921, 36.8219], 'Cape Town': [-33.9249, 18.4241],
// Australia & Oceania
'Sydney': [-33.8688, 151.2093], 'Melbourne': [-37.8136, 144.9631],
'Brisbane': [-27.4698, 153.0251], 'Perth': [-31.9505, 115.8605],
'Auckland': [-36.8485, 174.7633],
// Additional cities
'Unknown': null
}};
// Country center coordinates (fallback when city not found)
const countryCoordinates = {{
'US': [37.1, -95.7], 'GB': [55.4, -3.4], 'CN': [35.9, 104.1], 'RU': [61.5, 105.3],
'JP': [36.2, 138.3], 'DE': [51.2, 10.5], 'FR': [46.6, 2.2], 'IN': [20.6, 78.96],
'BR': [-14.2, -51.9], 'CA': [56.1, -106.3], 'AU': [-25.3, 133.8], 'MX': [23.6, -102.6],
'ZA': [-30.6, 22.9], 'KR': [35.9, 127.8], 'IT': [41.9, 12.6], 'ES': [40.5, -3.7],
'NL': [52.1, 5.3], 'SE': [60.1, 18.6], 'CH': [46.8, 8.2], 'PL': [51.9, 19.1],
'SG': [1.4, 103.8], 'HK': [22.4, 114.1], 'TW': [23.7, 120.96], 'TH': [15.9, 100.9],
'VN': [14.1, 108.8], 'ID': [-0.8, 113.2], 'PH': [12.9, 121.8], 'MY': [4.2, 101.7],
'PK': [30.4, 69.2], 'BD': [23.7, 90.4], 'NG': [9.1, 8.7], 'EG': [26.8, 30.8],
'TR': [38.9, 35.2], 'IR': [32.4, 53.7], 'AE': [23.4, 53.8], 'KZ': [48.0, 66.9],
'UA': [48.4, 31.2], 'BG': [42.7, 25.5], 'RO': [45.9, 24.97], 'CZ': [49.8, 15.5],
'HU': [47.2, 19.5], 'AT': [47.5, 14.6], 'BE': [50.5, 4.5], 'DK': [56.3, 9.5],
'FI': [61.9, 25.8], 'NO': [60.5, 8.5], 'GR': [39.1, 21.8], 'PT': [39.4, -8.2],
'AR': [-38.4161, -63.6167], 'CO': [4.5709, -74.2973], 'CL': [-35.6751, -71.5430],
'PE': [-9.1900, -75.0152], 'VE': [6.4238, -66.5897], 'LS': [40.0, -100.0]
}};
// Helper function to get coordinates for an IP
function getIPCoordinates(ip) {{
// Use actual latitude and longitude if available
if (ip.latitude != null && ip.longitude != null) {{
return [ip.latitude, ip.longitude];
}}
// Fall back to city lookup
if (ip.city && cityCoordinates[ip.city]) {{
return cityCoordinates[ip.city];
}}
// Fall back to country
if (ip.country_code && countryCoordinates[ip.country_code]) {{
return countryCoordinates[ip.country_code];
}}
return null;
}}
// Track used coordinates to add small offsets for overlapping markers
const usedCoordinates = {{}};
function getUniqueCoordinates(baseCoords) {{
const key = `${{baseCoords[0].toFixed(4)}},${{baseCoords[1].toFixed(4)}}`;
if (!usedCoordinates[key]) {{
usedCoordinates[key] = 0;
}}
usedCoordinates[key]++;
// If this is the first marker at this location, use exact coordinates
if (usedCoordinates[key] === 1) {{
return baseCoords;
}}
// Add small random offset for subsequent markers
// Offset increases with each marker to create a spread pattern
const angle = (usedCoordinates[key] * 137.5) % 360; // Golden angle for even distribution
const distance = 0.05 * Math.sqrt(usedCoordinates[key]); // Increase distance with more markers
const latOffset = distance * Math.cos(angle * Math.PI / 180);
const lngOffset = distance * Math.sin(angle * Math.PI / 180);
return [
baseCoords[0] + latOffset,
baseCoords[1] + lngOffset
];
}}
// Create layer groups for each category
markerLayers = {{
attacker: L.featureGroup(),
bad_crawler: L.featureGroup(),
good_crawler: L.featureGroup(),
regular_user: L.featureGroup(),
unknown: L.featureGroup()
}};
// Add markers for each IP
allIps.slice(0, 100).forEach(ip => {{
if (!ip.country_code || !ip.category) return;
// Get coordinates (city first, then country)
const baseCoords = getIPCoordinates(ip);
if (!baseCoords) return;
// Get unique coordinates with offset to prevent overlap
const coords = getUniqueCoordinates(baseCoords);
const category = ip.category.toLowerCase();
if (!markerLayers[category]) return;
// Calculate marker size based on request count with more dramatic scaling
// Scale up to 10,000 requests, then cap it
const requestsForScale = Math.min(ip.total_requests, 10000);
const sizeRatio = Math.pow(requestsForScale / 10000, 0.5); // Square root for better visual scaling
const markerSize = Math.max(10, Math.min(30, 10 + (sizeRatio * 20)));
// Create custom marker element with category-specific class
const markerElement = document.createElement('div');
markerElement.className = `ip-marker marker-${{category}}`;
markerElement.style.width = markerSize + 'px';
markerElement.style.height = markerSize + 'px';
markerElement.style.fontSize = (markerSize * 0.5) + 'px';
markerElement.textContent = '';
const marker = L.marker(coords, {{
icon: L.divIcon({{
html: markerElement.outerHTML,
iconSize: [markerSize, markerSize],
className: `ip-custom-marker category-${{category}}`
}})
}});
// Create popup with category badge and chart
const categoryColor = categoryColors[category] || '#8b949e';
const categoryLabels = {{
attacker: 'Attacker',
bad_crawler: 'Bad Crawler',
good_crawler: 'Good Crawler',
regular_user: 'Regular User',
unknown: 'Unknown'
}};
// Bind popup once when marker is created
marker.bindPopup('', {{
maxWidth: 550,
className: 'ip-detail-popup'
}});
// Add click handler to fetch data and show popup
marker.on('click', async function(e) {{
// Show loading popup first
const loadingPopup = `
<div style="padding: 12px; min-width: 280px; max-width: 320px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
<strong style="color: #58a6ff; font-size: 14px;">${{ip.ip}}</strong>
<span style="background: ${{categoryColor}}1a; color: ${{categoryColor}}; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600;">
${{categoryLabels[category]}}
</span>
</div>
<div style="text-align: center; padding: 20px; color: #8b949e;">
<div style="font-size: 12px;">Loading details...</div>
</div>
</div>
`;
marker.setPopupContent(loadingPopup);
marker.openPopup();
try {{
console.log('Fetching IP stats for:', ip.ip);
const response = await fetch(`${{DASHBOARD_PATH}}/api/ip-stats/${{ip.ip}}`);
if (!response.ok) throw new Error('Failed to fetch IP stats');
const stats = await response.json();
console.log('Received stats:', stats);
// Build complete popup content with chart
let popupContent = `
<div style="padding: 12px; min-width: 200px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
<strong style="color: #58a6ff; font-size: 14px;">${{ip.ip}}</strong>
<span style="background: ${{categoryColor}}1a; color: ${{categoryColor}}; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600;">
${{categoryLabels[category]}}
</span>
</div>
<span style="color: #8b949e; font-size: 12px;">
${{ip.city ? (ip.country_code ? `${{ip.city}}, ${{ip.country_code}}` : ip.city) : (ip.country_code || 'Unknown')}}
</span><br/>
<div style="margin-top: 8px; border-top: 1px solid #30363d; padding-top: 8px;">
<div style="margin-bottom: 4px;"><span style="color: #8b949e;">Requests:</span> <span style="color: ${{categoryColor}}; font-weight: bold;">${{ip.total_requests}}</span></div>
<div style="margin-bottom: 4px;"><span style="color: #8b949e;">First Seen:</span> <span style="color: #58a6ff; font-size: 11px;">${{formatTimestamp(ip.first_seen)}}</span></div>
<div style="margin-bottom: 4px;"><span style="color: #8b949e;">Last Seen:</span> <span style="color: #58a6ff; font-size: 11px;">${{formatTimestamp(ip.last_seen)}}</span></div>
</div>
`;
// Add chart if category scores exist
if (stats.category_scores && Object.keys(stats.category_scores).length > 0) {{
console.log('Category scores found:', stats.category_scores);
const chartHtml = generateMapPanelRadarChart(stats.category_scores);
console.log('Generated chart HTML length:', chartHtml.length);
popupContent += `
<div style="margin-top: 12px; border-top: 1px solid #30363d; padding-top: 12px;">
${{chartHtml}}
</div>
`;
}}
popupContent += '</div>';
// Update popup content
console.log('Updating popup content');
marker.setPopupContent(popupContent);
}} catch (err) {{
console.error('Error fetching IP stats:', err);
const errorPopup = `
<div style="padding: 12px; min-width: 280px; max-width: 320px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
<strong style="color: #58a6ff; font-size: 14px;">${{ip.ip}}</strong>
<span style="background: ${{categoryColor}}1a; color: ${{categoryColor}}; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600;">
${{categoryLabels[category]}}
</span>
</div>
<span style="color: #8b949e; font-size: 12px;">
${{ip.city ? (ip.country_code ? `${{ip.city}}, ${{ip.country_code}}` : ip.city) : (ip.country_code || 'Unknown')}}
</span><br/>
<div style="margin-top: 8px; border-top: 1px solid #30363d; padding-top: 8px;">
<div style="margin-bottom: 4px;"><span style="color: #8b949e;">Requests:</span> <span style="color: ${{categoryColor}}; font-weight: bold;">${{ip.total_requests}}</span></div>
<div style="margin-bottom: 4px;"><span style="color: #8b949e;">First Seen:</span> <span style="color: #58a6ff; font-size: 11px;">${{formatTimestamp(ip.first_seen)}}</span></div>
<div style="margin-bottom: 4px;"><span style="color: #8b949e;">Last Seen:</span> <span style="color: #58a6ff; font-size: 11px;">${{formatTimestamp(ip.last_seen)}}</span></div>
</div>
<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>
`;
marker.setPopupContent(errorPopup);
}}
}});
markerLayers[category].addLayer(marker);
}});
// Add all marker layers to map initially
Object.values(markerLayers).forEach(layer => attackerMap.addLayer(layer));
// Fit map to all markers
const allMarkers = Object.values(markerLayers).reduce((acc, layer) => {{
acc.push(...layer.getLayers());
return acc;
}}, []);
if (allMarkers.length > 0) {{
const bounds = L.featureGroup(allMarkers).getBounds();
attackerMap.fitBounds(bounds, {{ padding: [50, 50] }});
}}
}} catch (err) {{
console.error('Error initializing attacker map:', err);
mapContainer.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #f85149;">Failed to load map: ' + err.message + '</div>';
}}
}}
// Update map filters based on checkbox selection
function updateMapFilters() {{
if (!attackerMap) return;
const filters = {{
attacker: document.getElementById('filter-attacker').checked,
bad_crawler: document.getElementById('filter-bad-crawler').checked,
good_crawler: document.getElementById('filter-good-crawler').checked,
regular_user: document.getElementById('filter-regular-user').checked,
unknown: document.getElementById('filter-unknown').checked
}};
// Update marker and circle layers visibility
Object.entries(filters).forEach(([category, show]) => {{
if (markerLayers[category]) {{
if (show) {{
if (!attackerMap.hasLayer(markerLayers[category])) {{
attackerMap.addLayer(markerLayers[category]);
}}
}} else {{
if (attackerMap.hasLayer(markerLayers[category])) {{
attackerMap.removeLayer(markerLayers[category]);
}}
}}
}}
}});
}}
// Initialize map when Attacks tab is opened
const originalSwitchTab = window.switchTab;
let attackTypesChartLoaded = false;
window.switchTab = function(tabName) {{
originalSwitchTab(tabName);
if (tabName === 'ip-stats') {{
if (!attackerMap) {{
setTimeout(() => {{
initializeAttackerMap();
}}, 100);
}}
if (!attackTypesChartLoaded) {{
setTimeout(() => {{
loadAttackTypesChart();
}}, 100);
}}
}}
}};
// Load and render attack types bar chart
let attackTypesChart = null;
async function loadAttackTypesChart() {{
try {{
const canvas = document.getElementById('attack-types-chart');
if (!canvas) return;
const response = await fetch(DASHBOARD_PATH + '/api/attack-types?page=1&page_size=100', {{
cache: 'no-store',
headers: {{
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}}
}});
if (!response.ok) throw new Error('Failed to fetch attack types');
const data = await response.json();
const attacks = data.attacks || [];
if (attacks.length === 0) {{
canvas.style.display = 'none';
return;
}}
// Aggregate attack types
const attackCounts = {{}};
attacks.forEach(attack => {{
if (attack.attack_types && Array.isArray(attack.attack_types)) {{
attack.attack_types.forEach(type => {{
attackCounts[type] = (attackCounts[type] || 0) + 1;
}});
}}
}});
// Sort and get top 10
const sortedAttacks = Object.entries(attackCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
if (sortedAttacks.length === 0) {{
canvas.style.display = 'none';
return;
}}
const labels = sortedAttacks.map(([type]) => type);
const counts = sortedAttacks.map(([, count]) => count);
const maxCount = Math.max(...counts);
// Hash function to generate consistent color from string
function hashCode(str) {{
let hash = 0;
for (let i = 0; i < str.length; i++) {{
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}}
return Math.abs(hash);
}}
// Dynamic color generator based on hash
function generateColorFromHash(label) {{
const hash = hashCode(label);
const hue = (hash % 360); // 0-360 for hue
const saturation = 70 + (hash % 20); // 70-90 for vibrant colors
const lightness = 50 + (hash % 10); // 50-60 for brightness
const bgColor = `hsl(${{hue}}, ${{saturation}}%, ${{lightness}}%)`;
const borderColor = `hsl(${{hue}}, ${{saturation + 5}}%, ${{lightness - 10}}%)`; // Darker border
const hoverColor = `hsl(${{hue}}, ${{saturation - 10}}%, ${{lightness + 8}}%)`; // Lighter hover
return {{ bg: bgColor, border: borderColor, hover: hoverColor }};
}}
// Generate colors dynamically for each attack type
const backgroundColors = labels.map(label => generateColorFromHash(label).bg);
const borderColors = labels.map(label => generateColorFromHash(label).border);
const hoverColors = labels.map(label => generateColorFromHash(label).hover);
// Create or update chart
if (attackTypesChart) {{
attackTypesChart.destroy();
}}
const ctx = canvas.getContext('2d');
attackTypesChart = new Chart(ctx, {{
type: 'doughnut',
data: {{
labels: labels,
datasets: [{{
data: counts,
backgroundColor: backgroundColors,
borderColor: '#0d1117',
borderWidth: 3,
hoverBorderColor: '#58a6ff',
hoverBorderWidth: 4,
hoverOffset: 10
}}]
}},
options: {{
responsive: true,
maintainAspectRatio: false,
plugins: {{
legend: {{
position: 'right',
labels: {{
color: '#c9d1d9',
font: {{
size: 12,
weight: '500',
family: "'Segoe UI', Tahoma, Geneva, Verdana"
}},
padding: 16,
usePointStyle: true,
pointStyle: 'circle',
generateLabels: (chart) => {{
const data = chart.data;
return data.labels.map((label, i) => ({{
text: `${{label}} (${{data.datasets[0].data[i]}})`,
fillStyle: data.datasets[0].backgroundColor[i],
hidden: false,
index: i,
pointStyle: 'circle'
}}));
}}
}}
}},
tooltip: {{
enabled: true,
backgroundColor: 'rgba(22, 27, 34, 0.95)',
titleColor: '#58a6ff',
bodyColor: '#c9d1d9',
borderColor: '#58a6ff',
borderWidth: 2,
padding: 14,
titleFont: {{
size: 14,
weight: 'bold',
family: "'Segoe UI', Tahoma, Geneva, Verdana"
}},
bodyFont: {{
size: 13,
family: "'Segoe UI', Tahoma, Geneva, Verdana"
}},
caretSize: 8,
caretPadding: 12,
callbacks: {{
label: function(context) {{
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((context.parsed / total) * 100).toFixed(1);
return `${{context.label}}: ${{percentage}}%`;
}}
}}
}}
}},
animation: {{
enabled: false
}},
onHover: (event, activeElements) => {{
canvas.style.cursor = activeElements.length > 0 ? 'pointer' : 'default';
}}
}},
plugins: [{{
id: 'customCanvasBackgroundColor',
beforeDraw: (chart) => {{
if (chart.ctx) {{
chart.ctx.save();
chart.ctx.globalCompositeOperation = 'destination-over';
chart.ctx.fillStyle = 'rgba(0,0,0,0)';
chart.ctx.fillRect(0, 0, chart.width, chart.height);
chart.ctx.restore();
}}
}}
}}]
}});
attackTypesChartLoaded = true;
}} catch (err) {{
console.error('Error loading attack types chart:', err);
}}
}}
// Raw Request Modal functions
let currentRawRequest = '';
async function viewRawRequest(logId) {{
try {{
const response = await fetch(`${{DASHBOARD_PATH}}/api/raw-request/${{logId}}`, {{
cache: 'no-store'
}});
if (response.status === 404) {{
alert('Raw request not available');
return;
}}
if (!response.ok) throw new Error('Failed to fetch data');
const data = await response.json();
if (!data.raw_request) {{
alert('Raw request not available');
return;
}}
currentRawRequest = data.raw_request;
document.getElementById('raw-request-content').textContent = currentRawRequest;
document.getElementById('raw-request-modal').style.display = 'block';
}} catch (err) {{
console.error('Error loading raw request:', err);
alert('Failed to load raw request');
}}
}}
function closeRawRequestModal() {{
document.getElementById('raw-request-modal').style.display = 'none';
}}
function downloadRawRequest() {{
if (!currentRawRequest) return;
const blob = new Blob([currentRawRequest], {{ type: 'text/plain' }});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `raw-request-${{Date.now()}}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}}
// Close modal when clicking outside
window.onclick = function(event) {{
const modal = document.getElementById('raw-request-modal');
if (event.target === modal) {{
closeRawRequestModal();
}}
}}
</script>
</body>
</html>
"""