-
-
- BlessedRebuS/Krawl
-
-
-
-
-
-
-
-
Krawl Dashboard
-
-
-
-
{stats['total_accesses']}
-
Total Accesses
-
-
-
{stats['unique_ips']}
-
Unique IPs
-
-
-
{stats['unique_paths']}
-
Unique Paths
-
-
-
{stats['suspicious_accesses']}
-
Suspicious Accesses
-
-
-
{stats.get('honeypot_ips', 0)}
-
Honeypot Caught
-
-
-
{len(stats.get('credential_attempts', []))}
-
Credentials Captured
-
-
-
{stats.get('unique_attackers', 0)}
-
Unique Attackers
-
-
-
-
-
-
-
-
Recent Suspicious Activity
-
-
-
- | IP Address |
- Type |
- Path |
- Details |
- Time |
-
-
-
- {suspicious_rows}
-
-
-
-
-
-
-
Honeypot Triggers by IP
-
-
-
-
-
- | # |
- IP Address |
- Accessed Paths |
- Count |
-
-
-
- | Loading... |
-
-
-
-
-
-
-
-
Top IP Addresses
-
-
-
-
-
- | # |
- IP Address |
- Access Count |
-
-
-
- | Loading... |
-
-
-
-
-
-
-
Top User-Agents
-
-
-
-
-
- | # |
- User-Agent |
- Count |
-
-
-
- | Loading... |
-
-
-
-
-
-
-
-
-
-
-
-
Attackers by Total Requests
-
-
-
-
-
-
- | # |
- IP Address |
- Total Requests |
- First Seen |
- Last Seen |
- Location |
-
-
-
-
-
-
-
-
-
-
-
Captured Credentials
-
-
-
-
-
- | # |
- IP Address |
- Username |
- Password |
- Path |
- Time |
-
-
-
- | Loading... |
-
-
-
-
-
-
-
Detected Attack Types
-
-
-
-
-
- | # |
- IP Address |
- Path |
- Attack Types |
- User-Agent |
- Time |
- Actions |
-
-
-
- | Loading... |
-
-
-
-
-
-
-
-
-
Most Recurring Attack Types
-
Top 10
-
-
-
-
-
-
-
-
-
-
-
Most Recurring Attack Patterns
-
-
-
-
-
-
- | # |
- Attack Pattern |
- Attack Type |
- Frequency |
- IPs |
-
-
-
- | Loading... |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-"""
diff --git a/src/templates/jinja2/base.html b/src/templates/jinja2/base.html
new file mode 100644
index 0000000..4583a1d
--- /dev/null
+++ b/src/templates/jinja2/base.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
Krawl Dashboard
+
+
+
+
+
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+
+
+
+ {% block scripts %}{% endblock %}
+
+
diff --git a/src/templates/jinja2/dashboard/index.html b/src/templates/jinja2/dashboard/index.html
new file mode 100644
index 0000000..5ec70f7
--- /dev/null
+++ b/src/templates/jinja2/dashboard/index.html
@@ -0,0 +1,154 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
+ {# GitHub logo #}
+
+
+ Krawl
+
+
+ {# Banlist export dropdown - Alpine.js #}
+
+
+
+
+
+
+
+
Krawl Dashboard
+
+ {# Stats cards - server-rendered #}
+ {% include "dashboard/partials/stats_cards.html" %}
+
+ {# Tab navigation - Alpine.js #}
+
+
+ {# ==================== OVERVIEW TAB ==================== #}
+
+
+ {# Suspicious Activity - server-rendered #}
+ {% include "dashboard/partials/suspicious_table.html" %}
+
+ {# Honeypot Triggers - HTMX loaded #}
+
+
Honeypot Triggers by IP
+
+
+
+ {# Top IPs + Top User-Agents side by side #}
+
+
+ {# Top Paths #}
+
+
+
+ {# ==================== ATTACKS TAB ==================== #}
+
+
+ {# Map section #}
+ {% include "dashboard/partials/map_section.html" %}
+
+ {# Attackers table - HTMX loaded #}
+
+
Attackers by Total Requests
+
+
+
+ {# Credentials table #}
+
+
Captured Credentials
+
+
+
+ {# Attack Types table #}
+
+
Detected Attack Types
+
+
+
+ {# Charts + Patterns side by side #}
+
+
+
Most Recurring Attack Types
+
+
+
+
+
+
Most Recurring Attack Patterns
+
+
+
+
+
+ {# Raw request modal - Alpine.js #}
+ {% include "dashboard/partials/raw_request_modal.html" %}
+
+
+{% endblock %}
diff --git a/src/templates/jinja2/dashboard/partials/attack_types_table.html b/src/templates/jinja2/dashboard/partials/attack_types_table.html
new file mode 100644
index 0000000..8a74572
--- /dev/null
+++ b/src/templates/jinja2/dashboard/partials/attack_types_table.html
@@ -0,0 +1,80 @@
+{# HTMX fragment: Detected Attack Types table #}
+
+
+
+
+ | # |
+ IP Address |
+ Path |
+ Attack Types |
+ User-Agent |
+
+ Time
+ |
+ Actions |
+
+
+
+ {% for attack in items %}
+
+ | {{ loop.index + (pagination.page - 1) * pagination.page_size }} |
+
+ {{ attack.ip | e }}
+ |
+
+
+ {{ attack.path | e }}
+ {% if attack.path | length > 30 %}
+ {{ attack.path | e }}
+ {% endif %}
+
+ |
+
+
+ {{ attack.attack_type | e }}
+ {% if attack.attack_type | length > 30 %}
+ {{ attack.attack_type | e }}
+ {% endif %}
+
+ |
+ {{ (attack.user_agent | default(''))[:50] | e }} |
+ {{ attack.timestamp | format_ts }} |
+
+ {% if attack.log_id %}
+
+ {% endif %}
+ |
+
+
+ |
+
+ |
+
+ {% else %}
+ | No attacks detected |
+ {% endfor %}
+
+
diff --git a/src/templates/jinja2/dashboard/partials/attackers_table.html b/src/templates/jinja2/dashboard/partials/attackers_table.html
new file mode 100644
index 0000000..632137d
--- /dev/null
+++ b/src/templates/jinja2/dashboard/partials/attackers_table.html
@@ -0,0 +1,60 @@
+{# HTMX fragment: Attackers table #}
+
+
+
+
+ | # |
+ IP Address |
+
+ Total Requests
+ |
+ First Seen |
+ Last Seen |
+ Location |
+
+
+
+ {% for ip in items %}
+
+ | {{ loop.index + (pagination.page - 1) * pagination.page_size }} |
+
+ {{ ip.ip | e }}
+ |
+ {{ ip.total_requests }} |
+ {{ ip.first_seen | format_ts }} |
+ {{ ip.last_seen | format_ts }} |
+ {{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }} |
+
+
+ |
+
+ |
+
+ {% else %}
+ | No attackers found |
+ {% endfor %}
+
+
diff --git a/src/templates/jinja2/dashboard/partials/credentials_table.html b/src/templates/jinja2/dashboard/partials/credentials_table.html
new file mode 100644
index 0000000..ccfb364
--- /dev/null
+++ b/src/templates/jinja2/dashboard/partials/credentials_table.html
@@ -0,0 +1,60 @@
+{# HTMX fragment: Captured Credentials table #}
+
+
+
+
+ | # |
+ IP Address |
+ Username |
+ Password |
+ Path |
+
+ Time
+ |
+
+
+
+ {% for cred in items %}
+
+ | {{ loop.index + (pagination.page - 1) * pagination.page_size }} |
+
+ {{ cred.ip | e }}
+ |
+ {{ cred.username | default('N/A') | e }} |
+ {{ cred.password | default('N/A') | e }} |
+ {{ cred.path | default('') | e }} |
+ {{ cred.timestamp | format_ts }} |
+
+
+ |
+
+ |
+
+ {% else %}
+ | No credentials captured |
+ {% endfor %}
+
+
diff --git a/src/templates/jinja2/dashboard/partials/honeypot_table.html b/src/templates/jinja2/dashboard/partials/honeypot_table.html
new file mode 100644
index 0000000..35676fc
--- /dev/null
+++ b/src/templates/jinja2/dashboard/partials/honeypot_table.html
@@ -0,0 +1,54 @@
+{# HTMX fragment: Honeypot triggers table #}
+
+
+
+
+ | # |
+ IP Address |
+
+ Honeypot Triggers
+ |
+
+
+
+ {% for item in items %}
+
+ | {{ loop.index + (pagination.page - 1) * pagination.page_size }} |
+
+ {{ item.ip | e }}
+ |
+ {{ item.count }} |
+
+
+ |
+
+ |
+
+ {% else %}
+ | No data |
+ {% endfor %}
+
+
diff --git a/src/templates/jinja2/dashboard/partials/ip_detail.html b/src/templates/jinja2/dashboard/partials/ip_detail.html
new file mode 100644
index 0000000..8082859
--- /dev/null
+++ b/src/templates/jinja2/dashboard/partials/ip_detail.html
@@ -0,0 +1,131 @@
+{# HTMX fragment: IP detail expansion row content #}
+{# Replaces the ~250 line formatIpStats() JavaScript function #}
+
+
+ Total Requests:
+ {{ stats.total_requests | default('N/A') }}
+
+
+ First Seen:
+ {{ stats.first_seen | format_ts }}
+
+
+ Last Seen:
+ {{ stats.last_seen | format_ts }}
+
+ {% if stats.city or stats.country_code %}
+
+ Location:
+ {{ stats.city | default('') }}{% if stats.city and stats.country_code %}, {% endif %}{{ stats.country_code | default('') }}
+
+ {% endif %}
+ {% if stats.reverse_dns %}
+
+ Reverse DNS:
+ {{ stats.reverse_dns | e }}
+
+ {% endif %}
+ {% if stats.asn_org %}
+
+ ASN Org:
+ {{ stats.asn_org | e }}
+
+ {% endif %}
+ {% if stats.asn %}
+
+ ASN:
+ {{ stats.asn | e }}
+
+ {% endif %}
+ {% if stats.isp %}
+
+ ISP:
+ {{ stats.isp | e }}
+
+ {% endif %}
+
+ {# Flags #}
+ {% set flags = [] %}
+ {% if stats.is_proxy %}{% set _ = flags.append('Proxy') %}{% endif %}
+ {% if stats.is_hosting %}{% set _ = flags.append('Hosting') %}{% endif %}
+ {% if flags %}
+
+ Flags:
+ {{ flags | join(', ') }}
+
+ {% endif %}
+
+ {% if stats.reputation_score is not none %}
+
+ Reputation Score:
+
+ {{ stats.reputation_score }}/100
+
+
+ {% endif %}
+
+ {% if stats.category %}
+
+ Category:
+
+ {{ stats.category | replace('_', ' ') | title }}
+
+
+ {% endif %}
+
+ {# Timeline + Reputation section #}
+ {% if stats.category_history or stats.blocklist_memberships %}
+
+
+ {# Behavior Timeline #}
+ {% if stats.category_history %}
+
+
+
+ {% for entry in stats.category_history %}
+
+
+
+ {{ entry.new_category | default('unknown') | replace('_', ' ') | title }}
+ {% if entry.old_category %} from {{ entry.old_category | replace('_', ' ') | title }}{% endif %}
+
{{ entry.timestamp | format_ts }}
+
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {# Reputation / Listed On #}
+
+
+ {% if stats.blocklist_memberships %}
+
Listed On
+ {% for bl in stats.blocklist_memberships %}
+
{{ bl | e }}
+ {% endfor %}
+ {% else %}
+
Clean - Not listed on any blocklists
+ {% endif %}
+
+
+
+ {% endif %}
+
+
+{# Radar chart (right side) #}
+{% if stats.category_scores %}
+
+{% endif %}
diff --git a/src/templates/jinja2/dashboard/partials/map_section.html b/src/templates/jinja2/dashboard/partials/map_section.html
new file mode 100644
index 0000000..1191671
--- /dev/null
+++ b/src/templates/jinja2/dashboard/partials/map_section.html
@@ -0,0 +1,27 @@
+{# Map section with filter checkboxes #}
+
diff --git a/src/templates/jinja2/dashboard/partials/patterns_table.html b/src/templates/jinja2/dashboard/partials/patterns_table.html
new file mode 100644
index 0000000..260f31d
--- /dev/null
+++ b/src/templates/jinja2/dashboard/partials/patterns_table.html
@@ -0,0 +1,43 @@
+{# HTMX fragment: Attack Patterns table #}
+
+
+
+
+ | # |
+ Attack Pattern |
+ Occurrences |
+
+
+
+ {% for pattern in items %}
+
+ | {{ loop.index + (pagination.page - 1) * pagination.page_size }} |
+
+
+ {{ pattern.pattern | e }}
+ {% if pattern.pattern | length > 40 %}
+ {{ pattern.pattern | e }}
+ {% endif %}
+
+ |
+ {{ pattern.count }} |
+
+ {% else %}
+ | No patterns found |
+ {% endfor %}
+
+
diff --git a/src/templates/jinja2/dashboard/partials/raw_request_modal.html b/src/templates/jinja2/dashboard/partials/raw_request_modal.html
new file mode 100644
index 0000000..06a46bb
--- /dev/null
+++ b/src/templates/jinja2/dashboard/partials/raw_request_modal.html
@@ -0,0 +1,20 @@
+{# Raw request viewer modal - Alpine.js controlled #}
+
diff --git a/src/templates/jinja2/dashboard/partials/stats_cards.html b/src/templates/jinja2/dashboard/partials/stats_cards.html
new file mode 100644
index 0000000..260076c
--- /dev/null
+++ b/src/templates/jinja2/dashboard/partials/stats_cards.html
@@ -0,0 +1,31 @@
+{# Stats cards - server-rendered on initial page load #}
+
+
+
{{ stats.total_accesses }}
+
Total Accesses
+
+
+
{{ stats.unique_ips }}
+
Unique IPs
+
+
+
{{ stats.unique_paths }}
+
Unique Paths
+
+
+
{{ stats.suspicious_accesses }}
+
Suspicious Accesses
+
+
+
{{ stats.honeypot_ips | default(0) }}
+
Honeypot Caught
+
+
+
{{ stats.credential_count | default(0) }}
+
Credentials Captured
+
+
+
{{ stats.unique_attackers | default(0) }}
+
Unique Attackers
+
+
diff --git a/src/templates/jinja2/dashboard/partials/suspicious_table.html b/src/templates/jinja2/dashboard/partials/suspicious_table.html
new file mode 100644
index 0000000..72a0480
--- /dev/null
+++ b/src/templates/jinja2/dashboard/partials/suspicious_table.html
@@ -0,0 +1,39 @@
+{# Recent Suspicious Activity - server-rendered on page load #}
+
+
Recent Suspicious Activity
+
+
+
+ | IP Address |
+ Path |
+ User-Agent |
+ Time |
+
+
+
+ {% for activity in suspicious_activities %}
+
+ |
+ {{ activity.ip | e }}
+ |
+ {{ activity.path | e }} |
+ {{ (activity.user_agent | default(''))[:80] | e }} |
+ {{ activity.timestamp | format_ts(time_only=True) }} |
+
+
+ |
+
+ |
+
+ {% else %}
+ | No suspicious activity detected |
+ {% endfor %}
+
+
+
diff --git a/src/templates/jinja2/dashboard/partials/top_ips_table.html b/src/templates/jinja2/dashboard/partials/top_ips_table.html
new file mode 100644
index 0000000..84b335f
--- /dev/null
+++ b/src/templates/jinja2/dashboard/partials/top_ips_table.html
@@ -0,0 +1,54 @@
+{# HTMX fragment: Top IPs table #}
+
+
+
+
+ | # |
+ IP Address |
+
+ Access Count
+ |
+
+
+
+ {% for item in items %}
+
+ | {{ loop.index + (pagination.page - 1) * pagination.page_size }} |
+
+ {{ item.ip | e }}
+ |
+ {{ item.count }} |
+
+
+ |
+
+ |
+
+ {% else %}
+ | No data |
+ {% endfor %}
+
+
diff --git a/src/templates/jinja2/dashboard/partials/top_paths_table.html b/src/templates/jinja2/dashboard/partials/top_paths_table.html
new file mode 100644
index 0000000..d1ec6d1
--- /dev/null
+++ b/src/templates/jinja2/dashboard/partials/top_paths_table.html
@@ -0,0 +1,41 @@
+{# HTMX fragment: Top Paths table #}
+
+
+
+
+ | # |
+ Path |
+
+ Access Count
+ |
+
+
+
+ {% for item in items %}
+
+ | {{ loop.index + (pagination.page - 1) * pagination.page_size }} |
+ {{ item.path | e }} |
+ {{ item.count }} |
+
+ {% else %}
+ | No data |
+ {% endfor %}
+
+
diff --git a/src/templates/jinja2/dashboard/partials/top_ua_table.html b/src/templates/jinja2/dashboard/partials/top_ua_table.html
new file mode 100644
index 0000000..faf487e
--- /dev/null
+++ b/src/templates/jinja2/dashboard/partials/top_ua_table.html
@@ -0,0 +1,41 @@
+{# HTMX fragment: Top User-Agents table #}
+
+
+
+
+ | # |
+ User-Agent |
+
+ Count
+ |
+
+
+
+ {% for item in items %}
+
+ | {{ loop.index + (pagination.page - 1) * pagination.page_size }} |
+ {{ item.user_agent | e }} |
+ {{ item.count }} |
+
+ {% else %}
+ | No data |
+ {% endfor %}
+
+
diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css
new file mode 100644
index 0000000..8a32b80
--- /dev/null
+++ b/src/templates/static/css/dashboard.css
@@ -0,0 +1,1250 @@
+/* Krawl Dashboard Styles */
+/* Extracted from dashboard_template.py */
+
+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: '\21C5';
+ position: absolute;
+ right: 8px;
+ opacity: 0.5;
+ font-size: 12px;
+}
+th.sortable.asc::after {
+ content: '\25B2';
+ opacity: 1;
+}
+th.sortable.desc::after {
+ content: '\25BC';
+ 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 {
+ 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;
+ }
+}
+
+/* Dynamically injected button styles (previously in JS) */
+.view-btn {
+ padding: 4px 10px;
+ background: #21262d;
+ color: #58a6ff;
+ border: 1px solid #30363d;
+ border-radius: 4px;
+ font-size: 11px;
+ cursor: pointer;
+ transition: all 0.2s;
+ white-space: nowrap;
+}
+.view-btn:hover {
+ background: #30363d;
+ border-color: #58a6ff;
+}
+.pagination-btn {
+ padding: 6px 14px;
+ background: #21262d;
+ color: #c9d1d9;
+ border: 1px solid #30363d;
+ border-radius: 4px;
+ font-size: 12px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+.pagination-btn:hover:not(:disabled) {
+ background: #30363d;
+ border-color: #58a6ff;
+ color: #58a6ff;
+}
+.pagination-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+.pagination-info {
+ color: #8b949e;
+ font-size: 12px;
+}
+
+/* HTMX loading indicator */
+.htmx-indicator {
+ display: none;
+ color: #8b949e;
+ font-style: italic;
+ padding: 20px;
+ text-align: center;
+}
+.htmx-request .htmx-indicator {
+ display: block;
+}
+.htmx-request.htmx-indicator {
+ display: block;
+}
+
+/* Alpine.js cloak */
+[x-cloak] {
+ display: none !important;
+}
diff --git a/src/templates/static/js/charts.js b/src/templates/static/js/charts.js
new file mode 100644
index 0000000..93122bb
--- /dev/null
+++ b/src/templates/static/js/charts.js
@@ -0,0 +1,167 @@
+// Chart.js Attack Types Chart
+// Extracted from dashboard_template.py (lines ~3370-3550)
+
+let attackTypesChart = null;
+let attackTypesChartLoaded = false;
+
+async function loadAttackTypesChart() {
+ const DASHBOARD_PATH = window.__DASHBOARD_PATH__ || '';
+
+ try {
+ const canvas = document.getElementById('attack-types-chart');
+ if (!canvas) return;
+
+ const response = await fetch(DASHBOARD_PATH + '/api/attack-types-stats?limit=10', {
+ 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 attackTypes = data.attack_types || [];
+
+ if (attackTypes.length === 0) {
+ canvas.style.display = 'none';
+ return;
+ }
+
+ const labels = attackTypes.map(item => item.type);
+ const counts = attackTypes.map(item => item.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);
+ }
+}
diff --git a/src/templates/static/js/dashboard.js b/src/templates/static/js/dashboard.js
new file mode 100644
index 0000000..b74a51d
--- /dev/null
+++ b/src/templates/static/js/dashboard.js
@@ -0,0 +1,125 @@
+// Alpine.js Dashboard Application
+document.addEventListener('alpine:init', () => {
+ Alpine.data('dashboardApp', () => ({
+ // State
+ tab: 'overview',
+ dashboardPath: window.__DASHBOARD_PATH__ || '',
+
+ // Banlist dropdown
+ banlistOpen: false,
+
+ // Raw request modal
+ rawModal: { show: false, content: '', logId: null },
+
+ // Map state
+ mapInitialized: false,
+
+ // Chart state
+ chartLoaded: false,
+
+ init() {
+ // Handle hash-based tab routing
+ const hash = window.location.hash.slice(1);
+ if (hash === 'ip-stats' || hash === 'attacks') {
+ this.switchToAttacks();
+ }
+
+ window.addEventListener('hashchange', () => {
+ const h = window.location.hash.slice(1);
+ if (h === 'ip-stats' || h === 'attacks') {
+ this.switchToAttacks();
+ } else {
+ this.switchToOverview();
+ }
+ });
+ },
+
+ switchToAttacks() {
+ this.tab = 'attacks';
+ window.location.hash = '#ip-stats';
+
+ // Delay initialization to ensure the container is visible and
+ // the browser has reflowed after x-show removes display:none.
+ // Leaflet and Chart.js need visible containers with real dimensions.
+ this.$nextTick(() => {
+ setTimeout(() => {
+ if (!this.mapInitialized && typeof initializeAttackerMap === 'function') {
+ initializeAttackerMap();
+ this.mapInitialized = true;
+ }
+ if (!this.chartLoaded && typeof loadAttackTypesChart === 'function') {
+ loadAttackTypesChart();
+ this.chartLoaded = true;
+ }
+ }, 200);
+ });
+ },
+
+ switchToOverview() {
+ this.tab = 'overview';
+ window.location.hash = '#overview';
+ },
+
+ async viewRawRequest(logId) {
+ try {
+ const resp = await fetch(
+ `${this.dashboardPath}/api/raw-request/${logId}`,
+ { cache: 'no-store' }
+ );
+ if (resp.status === 404) {
+ alert('Raw request not available');
+ return;
+ }
+ const data = await resp.json();
+ this.rawModal.content = data.raw_request || 'No content available';
+ this.rawModal.logId = logId;
+ this.rawModal.show = true;
+ } catch (err) {
+ alert('Failed to load raw request');
+ }
+ },
+
+ closeRawModal() {
+ this.rawModal.show = false;
+ this.rawModal.content = '';
+ this.rawModal.logId = null;
+ },
+
+ downloadRawRequest() {
+ if (!this.rawModal.content) return;
+ const blob = new Blob([this.rawModal.content], { type: 'text/plain' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `raw-request-${this.rawModal.logId || Date.now()}.txt`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ },
+
+ toggleIpDetail(event) {
+ const row = event.target.closest('tr');
+ if (!row) return;
+ const detailRow = row.nextElementSibling;
+ if (detailRow && detailRow.classList.contains('ip-stats-row')) {
+ detailRow.style.display =
+ detailRow.style.display === 'table-row' ? 'none' : 'table-row';
+ }
+ },
+ }));
+});
+
+// Utility function for formatting timestamps (used by map popups)
+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 {
+ return isoTimestamp;
+ }
+}
diff --git a/src/templates/static/js/map.js b/src/templates/static/js/map.js
new file mode 100644
index 0000000..6dfaf02
--- /dev/null
+++ b/src/templates/static/js/map.js
@@ -0,0 +1,469 @@
+// IP Map Visualization
+// Extracted from dashboard_template.py (lines ~2978-3348)
+
+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 DASHBOARD_PATH = window.__DASHBOARD_PATH__ || '';
+ 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 = '
No IP location data available
';
+ 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\u00e3o Paulo': [-23.5505, -46.6333], 'Rio de Janeiro': [-22.9068, -43.1729],
+ 'Buenos Aires': [-34.6037, -58.3816], 'Bogot\u00e1': [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 = '\u25CF';
+
+ 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 = `
+
+
+ ${ip.ip}
+
+ ${categoryLabels[category]}
+
+
+
+
+ `;
+
+ 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 = `
+
+
+ ${ip.ip}
+
+ ${categoryLabels[category]}
+
+
+
+ ${ip.city ? (ip.country_code ? `${ip.city}, ${ip.country_code}` : ip.city) : (ip.country_code || 'Unknown')}
+
+
+
Requests: ${ip.total_requests}
+
First Seen: ${formatTimestamp(ip.first_seen)}
+
Last Seen: ${formatTimestamp(ip.last_seen)}
+
+ `;
+
+ // 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 += `
+
+ ${chartHtml}
+
+ `;
+ }
+
+ popupContent += '
';
+
+ // Update popup content
+ console.log('Updating popup content');
+ marker.setPopupContent(popupContent);
+ } catch (err) {
+ console.error('Error fetching IP stats:', err);
+ const errorPopup = `
+
+
+ ${ip.ip}
+
+ ${categoryLabels[category]}
+
+
+
+ ${ip.city ? (ip.country_code ? `${ip.city}, ${ip.country_code}` : ip.city) : (ip.country_code || 'Unknown')}
+
+
+
Requests: ${ip.total_requests}
+
First Seen: ${formatTimestamp(ip.first_seen)}
+
Last Seen: ${formatTimestamp(ip.last_seen)}
+
+
+ Failed to load chart: ${err.message}
+
+
+ `;
+ 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] });
+ }
+
+ // Force Leaflet to recalculate container size after the tab becomes visible.
+ // Without this, tiles may not render correctly when the container was hidden.
+ setTimeout(() => {
+ if (attackerMap) attackerMap.invalidateSize();
+ }, 300);
+
+ } catch (err) {
+ console.error('Error initializing attacker map:', err);
+ mapContainer.innerHTML = '
Failed to load map: ' + err.message + '
';
+ }
+}
+
+// Update map filters based on checkbox selection
+function updateMapFilters() {
+ if (!attackerMap) return;
+
+ const filters = {};
+ document.querySelectorAll('.map-filter').forEach(cb => {
+ const category = cb.getAttribute('data-category');
+ if (category) filters[category] = cb.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]);
+ }
+ }
+ }
+ });
+}
+
+// Generate radar chart SVG for map panel popups
+function generateMapPanelRadarChart(categoryScores) {
+ if (!categoryScores || Object.keys(categoryScores).length === 0) {
+ return '
No category data available
';
+ }
+
+ let html = '
';
+ html += '
';
+ html += '
';
+ return html;
+}
diff --git a/src/templates/static/js/radar.js b/src/templates/static/js/radar.js
new file mode 100644
index 0000000..f531046
--- /dev/null
+++ b/src/templates/static/js/radar.js
@@ -0,0 +1,127 @@
+// Radar chart generation for IP stats
+// Used by map popups and IP detail partials
+// Extracted from dashboard_template.py (lines ~2092-2181)
+
+/**
+ * Generate an SVG radar chart for category scores.
+ * This is a reusable function that can be called from:
+ * - Map popup panels (generateMapPanelRadarChart in map.js)
+ * - IP detail partials (server-side or client-side rendering)
+ *
+ * @param {Object} categoryScores - Object with keys: attacker, good_crawler, bad_crawler, regular_user, unknown
+ * @param {number} [size=200] - Width/height of the SVG in pixels
+ * @param {boolean} [showLegend=true] - Whether to show the legend below the chart
+ * @returns {string} HTML string containing the SVG radar chart
+ */
+function generateRadarChart(categoryScores, size, showLegend) {
+ size = size || 200;
+ if (showLegend === undefined) showLegend = true;
+
+ if (!categoryScores || Object.keys(categoryScores).length === 0) {
+ return '
No category data available
';
+ }
+
+ 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;
+
+ let html = '
';
+ html += `
';
+
+ // Optional legend
+ if (showLegend) {
+ html += '
';
+ keys.forEach(key => {
+ html += '
';
+ html += `
`;
+ html += `
${labels[key]}: ${scores[key]} pt`;
+ html += '
';
+ });
+ html += '
';
+ }
+
+ html += '
';
+ return html;
+}
diff --git a/src/tracker.py b/src/tracker.py
index b7b97d5..292ebba 100644
--- a/src/tracker.py
+++ b/src/tracker.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
-from typing import Dict, List, Tuple, Optional
+from typing import Dict, Tuple, Optional
from collections import defaultdict
from datetime import datetime
from zoneinfo import ZoneInfo
@@ -9,7 +9,20 @@ import urllib.parse
from wordlists import get_wordlists
from database import get_database, DatabaseManager
-from ip_utils import is_local_or_private_ip, is_valid_public_ip
+
+# Module-level singleton for background task access
+_tracker_instance: "AccessTracker | None" = None
+
+
+def get_tracker() -> "AccessTracker | None":
+ """Get the global AccessTracker singleton (set during app startup)."""
+ return _tracker_instance
+
+
+def set_tracker(tracker: "AccessTracker"):
+ """Store the AccessTracker singleton for background task access."""
+ global _tracker_instance
+ _tracker_instance = tracker
class AccessTracker:
@@ -35,16 +48,6 @@ class AccessTracker:
"""
self.max_pages_limit = max_pages_limit
self.ban_duration_seconds = ban_duration_seconds
- self.ip_counts: Dict[str, int] = defaultdict(int)
- self.path_counts: Dict[str, int] = defaultdict(int)
- self.user_agent_counts: Dict[str, int] = defaultdict(int)
- self.access_log: List[Dict] = []
- self.credential_attempts: List[Dict] = []
-
- # Memory limits for in-memory lists (prevents unbounded growth)
- self.max_access_log_size = 10_000 # Keep only recent 10k accesses
- self.max_credential_log_size = 5_000 # Keep only recent 5k attempts
- self.max_counter_keys = 100_000 # Max unique IPs/paths/user agents
# Track pages visited by each IP (for good crawler limiting)
self.ip_page_visits: Dict[str, Dict[str, object]] = defaultdict(dict)
@@ -88,13 +91,10 @@ class AccessTracker:
"path_traversal": r"\.\.",
"sql_injection": r"('|--|;|\bOR\b|\bUNION\b|\bSELECT\b|\bDROP\b)",
"xss_attempt": r"(