diff --git a/src/database.py b/src/database.py
index e3c1406..50fd5a0 100644
--- a/src/database.py
+++ b/src/database.py
@@ -815,8 +815,8 @@ class DatabaseManager:
def flag_stale_ips_for_reevaluation(self) -> int:
"""
Flag IPs for reevaluation where:
- - last_seen is between 15 and 30 days ago
- - last_analysis is more than 10 days ago (or never analyzed)
+ - last_seen is between 5 and 30 days ago
+ - last_analysis is more than 5 days ago
Returns:
Number of IPs flagged for reevaluation
@@ -825,18 +825,15 @@ class DatabaseManager:
try:
now = datetime.now()
last_seen_lower = now - timedelta(days=30)
- last_seen_upper = now - timedelta(days=15)
- last_analysis_cutoff = now - timedelta(days=10)
+ last_seen_upper = now - timedelta(days=5)
+ last_analysis_cutoff = now - timedelta(days=5)
count = (
session.query(IpStats)
.filter(
IpStats.last_seen >= last_seen_lower,
IpStats.last_seen <= last_seen_upper,
- or_(
- IpStats.last_analysis <= last_analysis_cutoff,
- IpStats.last_analysis.is_(None),
- ),
+ IpStats.last_analysis <= last_analysis_cutoff,
IpStats.need_reevaluation == False,
IpStats.manual_category == False,
)
@@ -2029,12 +2026,15 @@ class DatabaseManager:
finally:
self.close_session()
- def get_attack_types_stats(self, limit: int = 20) -> Dict[str, Any]:
+ def get_attack_types_stats(
+ self, limit: int = 20, ip_filter: str | None = None
+ ) -> Dict[str, Any]:
"""
Get aggregated statistics for attack types (efficient for large datasets).
Args:
limit: Maximum number of attack types to return
+ ip_filter: Optional IP address to filter results for
Returns:
Dictionary with attack type counts
@@ -2044,12 +2044,18 @@ class DatabaseManager:
from sqlalchemy import func
# Aggregate attack types with count
+ query = session.query(
+ AttackDetection.attack_type,
+ func.count(AttackDetection.id).label("count"),
+ )
+
+ if ip_filter:
+ query = query.join(
+ AccessLog, AttackDetection.access_log_id == AccessLog.id
+ ).filter(AccessLog.ip == ip_filter)
+
results = (
- session.query(
- AttackDetection.attack_type,
- func.count(AttackDetection.id).label("count"),
- )
- .group_by(AttackDetection.attack_type)
+ query.group_by(AttackDetection.attack_type)
.order_by(func.count(AttackDetection.id).desc())
.limit(limit)
.all()
diff --git a/src/routes/api.py b/src/routes/api.py
index a4e6a7a..d94b3b6 100644
--- a/src/routes/api.py
+++ b/src/routes/api.py
@@ -214,12 +214,13 @@ async def top_user_agents(
async def attack_types_stats(
request: Request,
limit: int = Query(20),
+ ip_filter: str = Query(None),
):
db = get_db()
limit = min(max(1, limit), 100)
try:
- result = db.get_attack_types_stats(limit=limit)
+ result = db.get_attack_types_stats(limit=limit, ip_filter=ip_filter)
return JSONResponse(content=result, headers=_no_cache_headers())
except Exception as e:
get_app_logger().error(f"Error fetching attack types stats: {e}")
diff --git a/src/templates/jinja2/dashboard/ip.html b/src/templates/jinja2/dashboard/ip.html
index 371b771..d09ad88 100644
--- a/src/templates/jinja2/dashboard/ip.html
+++ b/src/templates/jinja2/dashboard/ip.html
@@ -16,187 +16,8 @@
- {# Page header #}
-
-
- {# Main content grid #}
-
- {# Left column: IP Info + Map #}
-
- {# IP Information Card #}
-
-
IP Information
-
-
-
Activity
-
- Total Requests:
- {{ stats.total_requests | default('N/A') }}
-
-
- First Seen:
- {{ stats.first_seen | format_ts }}
-
-
- Last Seen:
- {{ stats.last_seen | format_ts }}
-
- {% if stats.last_analysis %}
-
- Last Analysis:
- {{ stats.last_analysis | format_ts }}
-
- {% endif %}
-
-
-
-
Geo & Network
- {% if stats.city or stats.country %}
-
- Location:
- {{ stats.city | default('') | e }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) | e }}
-
- {% endif %}
- {% if stats.timezone %}
-
- Timezone:
- {{ stats.timezone | e }}
-
- {% endif %}
- {% if stats.isp %}
-
- ISP:
- {{ stats.isp | e }}
-
- {% endif %}
- {% if stats.asn_org %}
-
- Organization:
- {{ stats.asn_org | e }}
-
- {% endif %}
- {% if stats.asn %}
-
- ASN:
- AS{{ stats.asn }}
-
- {% endif %}
- {% if stats.reverse_dns %}
-
- Reverse DNS:
- {{ stats.reverse_dns | e }}
-
- {% endif %}
-
-
-
-
Reputation
- {% set flags = [] %}
- {% if stats.is_proxy %}{% set _ = flags.append('Proxy') %}{% endif %}
- {% if stats.is_hosting %}{% set _ = flags.append('Hosting') %}{% endif %}
- {% if flags %}
-
- Flags:
-
- {% for flag in flags %}
- {{ flag }}
- {% endfor %}
-
-
- {% endif %}
- {% if stats.reputation_score is not none %}
-
- Score:
-
- {{ stats.reputation_score }}/100
-
-
- {% endif %}
- {% if stats.blocklist_memberships %}
-
-
Listed On:
-
- {% for bl in stats.blocklist_memberships %}
- {{ bl | e }}
- {% endfor %}
-
-
- {% else %}
-
- Blocklists:
- Clean
-
- {% endif %}
-
-
-
-
- {# Single IP Map #}
-
-
-
- {# Right column: Radar Chart + Timeline #}
-
- {# Category Analysis Card #}
- {% if stats.category_scores %}
-
- {% endif %}
-
- {# Behavior Timeline #}
- {% if stats.category_history %}
-
- {% endif %}
-
-
-
- {# Access History table #}
-
+ {# Left column: single IP Information card #}
+
+
+
IP Information
+
+ {# Activity section #}
+
Activity
+
+
+
- Total Requests
+ - {{ stats.total_requests | default('N/A') }}
+
+
+
- First Seen
+ - {{ stats.first_seen | format_ts }}
+
+
+
- Last Seen
+ - {{ stats.last_seen | format_ts }}
+
+ {% if stats.last_analysis %}
+
+
- Last Analysis
+ - {{ stats.last_analysis | format_ts }}
+
+ {% endif %}
+
+
+ {# Geo & Network section #}
+
Geo & Network
+
+ {% if stats.city or stats.country %}
+
+
- Location
+ - {{ stats.city | default('') | e }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) | e }}
+
+ {% endif %}
+ {% if stats.region_name %}
+
+
- Region
+ - {{ stats.region_name | e }}
+
+ {% endif %}
+ {% if stats.timezone %}
+
+
- Timezone
+ - {{ stats.timezone | e }}
+
+ {% endif %}
+ {% if stats.isp %}
+
+
- ISP
+ - {{ stats.isp | e }}
+
+ {% endif %}
+ {% if stats.asn_org %}
+
+
- Organization
+ - {{ stats.asn_org | e }}
+
+ {% endif %}
+ {% if stats.asn %}
+
+
- ASN
+ - AS{{ stats.asn }}
+
+ {% endif %}
+ {% if stats.reverse_dns %}
+
+
- Reverse DNS
+ - {{ stats.reverse_dns | e }}
+
+ {% endif %}
+
+
+ {# Reputation section #}
+
Reputation
+
+
+
+
+ {# Right column: Category Analysis + Timeline + Attack Types #}
+
+ {% if stats.category_scores %}
+
+ {% endif %}
+
+ {# Bottom row: Behavior Timeline + Attack Types side by side #}
+
+ {% if stats.category_history %}
+
+ {% endif %}
+
+
+
+
+
+
+{# Location map #}
+{% if stats.latitude and stats.longitude %}
+
- {# Page header #}
-
-
- {# Main content grid #}
-
- {# Left column: IP Info + Map #}
-
- {# IP Information Card #}
-
-
IP Information
-
-
-
Activity
-
- Total Requests:
- {{ stats.total_requests | default('N/A') }}
-
-
- First Seen:
- {{ stats.first_seen | format_ts }}
-
-
- Last Seen:
- {{ stats.last_seen | format_ts }}
-
- {% if stats.last_analysis %}
-
- Last Analysis:
- {{ stats.last_analysis | format_ts }}
-
- {% endif %}
-
-
-
-
Geo & Network
- {% if stats.city or stats.country %}
-
- Location:
- {{ stats.city | default('') | e }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) | e }}
-
- {% endif %}
- {% if stats.timezone %}
-
- Timezone:
- {{ stats.timezone | e }}
-
- {% endif %}
- {% if stats.isp %}
-
- ISP:
- {{ stats.isp | e }}
-
- {% endif %}
- {% if stats.asn_org %}
-
- Organization:
- {{ stats.asn_org | e }}
-
- {% endif %}
- {% if stats.asn %}
-
- ASN:
- AS{{ stats.asn }}
-
- {% endif %}
- {% if stats.reverse_dns %}
-
- Reverse DNS:
- {{ stats.reverse_dns | e }}
-
- {% endif %}
-
-
-
-
Reputation
- {% set flags = [] %}
- {% if stats.is_proxy %}{% set _ = flags.append('Proxy') %}{% endif %}
- {% if stats.is_hosting %}{% set _ = flags.append('Hosting') %}{% endif %}
- {% if flags %}
-
- Flags:
-
- {% for flag in flags %}
- {{ flag }}
- {% endfor %}
-
-
- {% endif %}
- {% if stats.reputation_score is not none %}
-
- Score:
-
- {{ stats.reputation_score }}/100
-
-
- {% endif %}
- {% if stats.blocklist_memberships %}
-
-
Listed On:
-
- {% for bl in stats.blocklist_memberships %}
- {{ bl | e }}
- {% endfor %}
-
-
- {% else %}
-
- Blocklists:
- Clean
-
- {% endif %}
-
-
-
-
-
-
- {# Right column: Radar Chart + Timeline #}
-
- {# Category Analysis Card #}
- {% if stats.category_scores %}
-
- {% endif %}
-
- {# Behavior Timeline #}
- {% if stats.category_history %}
-
- {% endif %}
-
-
-
- {# Single IP Map - full width #}
-
-
- {# Access History table #}
-
+ {% set uid = "insight" %}
+ {% include "dashboard/partials/_ip_detail.html" %}
-
-{# Inline script for initializing map and chart after HTMX swap #}
-
diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css
index 2a58750..5074528 100644
--- a/src/templates/static/css/dashboard.css
+++ b/src/templates/static/css/dashboard.css
@@ -269,8 +269,8 @@ tbody {
}
.radar-chart {
position: relative;
- width: 220px;
- height: 220px;
+ width: 280px;
+ height: 280px;
overflow: visible;
}
.radar-legend {
@@ -452,7 +452,7 @@ tbody {
animation: fadeIn 0.3s ease-in;
}
.ip-page-header {
- margin-bottom: 24px;
+ margin-bottom: 20px;
}
.ip-page-header h1 {
display: flex;
@@ -471,61 +471,214 @@ tbody {
font-size: 14px;
margin: 4px 0 0 0;
}
+
+/* Quick stats bar */
+.ip-stats-bar {
+ display: flex;
+ gap: 12px;
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+}
+.ip-stat-chip {
+ background: #161b22;
+ border: 1px solid #30363d;
+ border-radius: 8px;
+ padding: 12px 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+ flex: 1 1 0;
+}
+.ip-stat-chip-value {
+ color: #e6edf3;
+ font-size: 16px;
+ font-weight: 700;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.ip-stat-chip-label {
+ color: #8b949e;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-weight: 500;
+}
+
+/* Two-column grid */
.ip-page-grid {
display: grid;
- grid-template-columns: 3fr 2fr;
+ grid-template-columns: 1fr 1fr;
gap: 20px;
- align-items: start;
+ align-items: stretch;
}
.ip-page-left,
.ip-page-right {
display: flex;
flex-direction: column;
gap: 20px;
+ min-height: 0;
}
-.ip-info-card h2 {
+/* Left card fills column height */
+.ip-info-card {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+/* Timeline card grows to fill remaining space */
+.ip-timeline-card {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+/* Detail cards */
+.ip-detail-card h2 {
margin-top: 0;
+ margin-bottom: 16px;
}
-.ip-info-grid {
- display: grid;
- grid-template-columns: 1fr 1fr 1fr;
+/* Remove bottom margin inside grid columns (gap handles spacing) */
+.ip-page-left .table-container,
+.ip-page-right .table-container {
+ margin-bottom: 0;
+}
+
+/* Definition list for IP info */
+.ip-dl {
+ margin: 0;
+ display: flex;
+ flex-direction: column;
gap: 0;
}
-.ip-info-section {
- padding: 14px 16px;
- border-right: 1px solid #21262d;
-}
-.ip-info-section:last-child {
- border-right: none;
-}
-.ip-info-section h3 {
- color: #58a6ff;
- font-size: 13px;
- font-weight: 600;
- margin: 0 0 10px 0;
- padding-bottom: 6px;
+.ip-dl-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ padding: 8px 0;
border-bottom: 1px solid #21262d;
+ gap: 16px;
}
-.ip-info-section .stat-row {
- padding: 3px 0;
+.ip-dl-row:last-child {
+ border-bottom: none;
+}
+.ip-dl dt {
+ color: #8b949e;
font-size: 13px;
+ font-weight: 500;
+ flex-shrink: 0;
+ min-width: 100px;
}
-.blocklist-badges {
+.ip-dl dd {
+ margin: 0;
+ color: #e6edf3;
+ font-size: 13px;
+ font-weight: 500;
+ text-align: right;
+ word-break: break-word;
+}
+.ip-dl-mono {
+ font-family: monospace;
+ font-size: 12px;
+}
+
+/* Section headings inside IP info card */
+.ip-section-heading {
+ color: #e6edf3;
+ font-size: 15px;
+ font-weight: 700;
+ margin: 18px 0 8px 0;
+ padding: 0;
+}
+.ip-section-heading:first-of-type {
+ margin-top: 0;
+}
+/* Highlighted date values */
+.ip-dl-highlight {
+ color: #58a6ff;
+}
+
+/* Scrollable reputation container */
+.ip-rep-scroll {
+ max-height: 200px;
+ overflow-y: auto;
+ scrollbar-width: thin;
+ scrollbar-color: #30363d #161b22;
+}
+.ip-rep-scroll::-webkit-scrollbar {
+ width: 6px;
+}
+.ip-rep-scroll::-webkit-scrollbar-track {
+ background: #161b22;
+ border-radius: 3px;
+}
+.ip-rep-scroll::-webkit-scrollbar-thumb {
+ background: #30363d;
+ border-radius: 3px;
+}
+.ip-rep-scroll::-webkit-scrollbar-thumb:hover {
+ background: #484f58;
+}
+
+/* Scrollable behavior timeline – show ~5 entries max */
+.ip-timeline-scroll {
+ max-height: 230px;
+ overflow-y: auto;
+ min-height: 0;
+ scrollbar-width: thin;
+ scrollbar-color: #30363d #161b22;
+}
+.ip-timeline-scroll::-webkit-scrollbar {
+ width: 6px;
+}
+.ip-timeline-scroll::-webkit-scrollbar-track {
+ background: #161b22;
+ border-radius: 3px;
+}
+.ip-timeline-scroll::-webkit-scrollbar-thumb {
+ background: #30363d;
+ border-radius: 3px;
+}
+.ip-timeline-scroll::-webkit-scrollbar-thumb:hover {
+ background: #484f58;
+}
+
+/* Reputation section */
+.ip-rep-row {
+ padding: 10px 0;
+ border-bottom: 1px solid #21262d;
+ display: flex;
+ align-items: flex-start;
+ gap: 16px;
+}
+.ip-rep-row:last-child {
+ border-bottom: none;
+}
+.ip-rep-label {
+ color: #8b949e;
+ font-size: 13px;
+ font-weight: 500;
+ flex-shrink: 0;
+ min-width: 80px;
+ padding-top: 2px;
+}
+.ip-rep-tags {
display: flex;
flex-wrap: wrap;
- gap: 5px;
- max-height: 120px;
- overflow-y: auto;
+ gap: 6px;
}
+
+/* Flags & badges */
.ip-flag {
display: inline-block;
background: #1c2128;
- border: 1px solid #30363d;
+ border: 1px solid #f0883e4d;
border-radius: 4px;
- padding: 2px 8px;
- font-size: 11px;
+ padding: 3px 10px;
+ font-size: 12px;
color: #f0883e;
- margin-right: 4px;
+ font-weight: 500;
}
.reputation-score {
font-weight: 700;
@@ -533,29 +686,130 @@ tbody {
.reputation-score.bad { color: #f85149; }
.reputation-score.medium { color: #f0883e; }
.reputation-score.good { color: #3fb950; }
+.blocklist-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+/* Bottom row: Timeline + Attack Types side by side */
+.ip-bottom-row {
+ display: flex;
+ gap: 20px;
+ flex: 1;
+ min-height: 0;
+}
+.ip-bottom-row .ip-timeline-card {
+ flex: 1;
+ min-width: 0;
+}
+.ip-attack-types-card {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+.ip-attack-chart-wrapper {
+ flex: 1;
+ position: relative;
+ min-height: 180px;
+}
+
+/* Radar chart */
.radar-chart-container {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 0;
}
-.ip-timeline-scroll {
- max-height: 280px;
- overflow-y: auto;
+
+/* ── Behavior Timeline (full-width horizontal) ──── */
+.ip-timeline-hz {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ position: relative;
+ padding-left: 24px;
}
+.ip-timeline-hz::before {
+ content: '';
+ position: absolute;
+ left: 7px;
+ top: 8px;
+ bottom: 8px;
+ width: 2px;
+ background: #30363d;
+}
+.ip-tl-entry {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ position: relative;
+ padding: 10px 0;
+}
+.ip-tl-entry:not(:last-child) {
+ border-bottom: 1px solid #161b22;
+}
+.ip-tl-dot {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ border: 2px solid #0d1117;
+ position: absolute;
+ left: -24px;
+ top: 12px;
+ z-index: 1;
+}
+.ip-tl-dot.attacker { background: #f85149; box-shadow: 0 0 6px #f8514980; }
+.ip-tl-dot.good-crawler { background: #3fb950; box-shadow: 0 0 6px #3fb95080; }
+.ip-tl-dot.bad-crawler { background: #f0883e; box-shadow: 0 0 6px #f0883e80; }
+.ip-tl-dot.regular-user { background: #58a6ff; box-shadow: 0 0 6px #58a6ff80; }
+.ip-tl-dot.unknown { background: #8b949e; }
+.ip-tl-content {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ flex-wrap: wrap;
+ min-width: 0;
+}
+.ip-tl-cat {
+ color: #e6edf3;
+ font-weight: 600;
+ font-size: 14px;
+}
+.ip-tl-from {
+ color: #8b949e;
+ font-size: 13px;
+}
+.ip-tl-time {
+ color: #484f58;
+ font-size: 12px;
+ margin-left: auto;
+ white-space: nowrap;
+}
+
+/* Legacy compat (unused) */
+
@media (max-width: 900px) {
.ip-page-grid {
grid-template-columns: 1fr;
}
- .ip-info-grid {
- grid-template-columns: 1fr;
+ .ip-stats-bar {
+ flex-direction: column;
}
- .ip-info-section {
- border-right: none;
- border-bottom: 1px solid #21262d;
+ .ip-stat-chip {
+ flex: 1 1 auto;
}
- .ip-info-section:last-child {
- border-bottom: none;
+ .ip-bottom-row {
+ flex-direction: column;
+ }
+ .ip-tl-content {
+ flex-direction: column;
+ gap: 2px;
+ }
+ .ip-tl-time {
+ margin-left: 0;
}
}
diff --git a/src/templates/static/js/charts.js b/src/templates/static/js/charts.js
index 93122bb..749019b 100644
--- a/src/templates/static/js/charts.js
+++ b/src/templates/static/js/charts.js
@@ -4,14 +4,25 @@
let attackTypesChart = null;
let attackTypesChartLoaded = false;
-async function loadAttackTypesChart() {
+/**
+ * Load an attack types doughnut chart into a canvas element.
+ * @param {string} [canvasId='attack-types-chart'] - Canvas element ID
+ * @param {string} [ipFilter] - Optional IP address to scope results
+ * @param {string} [legendPosition='right'] - Legend position
+ */
+async function loadAttackTypesChart(canvasId, ipFilter, legendPosition) {
+ canvasId = canvasId || 'attack-types-chart';
+ legendPosition = legendPosition || 'right';
const DASHBOARD_PATH = window.__DASHBOARD_PATH__ || '';
try {
- const canvas = document.getElementById('attack-types-chart');
+ const canvas = document.getElementById(canvasId);
if (!canvas) return;
- const response = await fetch(DASHBOARD_PATH + '/api/attack-types-stats?limit=10', {
+ let url = DASHBOARD_PATH + '/api/attack-types-stats?limit=10';
+ if (ipFilter) url += '&ip_filter=' + encodeURIComponent(ipFilter);
+
+ const response = await fetch(url, {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
@@ -25,7 +36,7 @@ async function loadAttackTypesChart() {
const attackTypes = data.attack_types || [];
if (attackTypes.length === 0) {
- canvas.style.display = 'none';
+ canvas.parentElement.innerHTML = '