From 8fc2d47e9694f5721ddc6113b9227a5e4360b8d2 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 1 Mar 2026 17:00:10 +0100 Subject: [PATCH] feat: Add detailed IP information view and refactor IP insight template - Introduced a new partial template `_ip_detail.html` for displaying comprehensive IP details, including activity, geo & network information, reputation, and access history. - Updated `ip_insight.html` to include the new `_ip_detail.html` partial, streamlining the code and enhancing maintainability. - Enhanced CSS styles for improved layout and responsiveness, including adjustments to the radar chart size and the introduction of a two-column grid layout for IP details. - Refactored JavaScript for loading attack types charts to support multiple instances and improved error handling. --- src/database.py | 34 +- src/routes/api.py | 3 +- src/templates/jinja2/dashboard/ip.html | 260 +------------- .../jinja2/dashboard/partials/_ip_detail.html | 282 +++++++++++++++ .../jinja2/dashboard/partials/ip_insight.html | 264 +------------- src/templates/static/css/dashboard.css | 338 +++++++++++++++--- src/templates/static/js/charts.js | 32 +- 7 files changed, 628 insertions(+), 585 deletions(-) create mode 100644 src/templates/jinja2/dashboard/partials/_ip_detail.html 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 #} -
-

- {{ ip_address }} - {% if stats.category %} - - {{ stats.category | replace('_', ' ') | title }} - - {% endif %} -

- {% if stats.city or stats.country %} -

- {{ stats.city | default('') }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) }} -

- {% endif %} -
- - {# 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 #} -
-

Location

-
-
-
- - {# Right column: Radar Chart + Timeline #} -
- {# Category Analysis Card #} - {% if stats.category_scores %} -
-

Category Analysis

-
-
-
-
- {% endif %} - - {# Behavior Timeline #} - {% if stats.category_history %} -
-

Behavior Timeline

-
- {% 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 %} -
-
- - {# Access History table #} -
-

Access History

-
-
Loading...
-
-
+ {% set uid = "ip" %} + {% include "dashboard/partials/_ip_detail.html" %} {# Raw Request Modal #}
@@ -215,80 +36,3 @@
{% endblock %} - -{% block scripts %} - -{% endblock %} 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..42bdcf1 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/_ip_detail.html @@ -0,0 +1,282 @@ +{# Shared IP detail content – included by ip.html and ip_insight.html. + Expects: stats, ip_address, dashboard_path, uid (unique prefix for element IDs) #} + +{# Page header #} +
+

+ {{ ip_address }} + {% if stats.category %} + + {{ stats.category | replace('_', ' ') | title }} + + {% endif %} +

+ {% if stats.city or stats.country %} +

+ {{ stats.city | default('') }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) }} +

+ {% endif %} +
+ +{# ── Two-column layout: Info + Radar/Timeline ───── #} +
+ {# 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

+
+ {# Flags #} + {% 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 %} + + {# Blocklists #} +
+ Listed On + {% if stats.blocklist_memberships %} +
+ {% for bl in stats.blocklist_memberships %} + {{ bl | e }} + {% endfor %} +
+ {% else %} + Clean + {% endif %} +
+
+
+
+ + {# Right column: Category Analysis + Timeline + Attack Types #} +
+ {% if stats.category_scores %} +
+

Category Analysis

+
+
+
+
+ {% endif %} + + {# Bottom row: Behavior Timeline + Attack Types side by side #} +
+ {% if stats.category_history %} +
+

Behavior Timeline

+
+
+ {% for entry in stats.category_history %} +
+
+
+ {{ entry.new_category | default('unknown') | replace('_', ' ') | title }} + {% if entry.old_category %} + from {{ entry.old_category | replace('_', ' ') | title }} + {% else %} + initial classification + {% endif %} + {{ entry.timestamp | format_ts }} +
+
+ {% endfor %} +
+
+
+ {% endif %} + +
+

Attack Types

+
+ +
+
+
+
+
+ +{# Location map #} +{% if stats.latitude and stats.longitude %} +
+

Location

+
+
+{% endif %} + +{# Access History table #} +
+

Access History

+
+
Loading...
+
+
+ +{# Inline init script #} + diff --git a/src/templates/jinja2/dashboard/partials/ip_insight.html b/src/templates/jinja2/dashboard/partials/ip_insight.html index 8c8ab13..e7977b7 100644 --- a/src/templates/jinja2/dashboard/partials/ip_insight.html +++ b/src/templates/jinja2/dashboard/partials/ip_insight.html @@ -1,263 +1,5 @@ -{# HTMX fragment: IP Insight - full IP detail view for inline display #} +{# HTMX fragment: IP Insight - inline display within dashboard tabs #}
- {# Page header #} -
-

- {{ ip_address }} - {% if stats.category %} - - {{ stats.category | replace('_', ' ') | title }} - - {% endif %} -

- {% if stats.city or stats.country %} -

- {{ stats.city | default('') }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) }} -

- {% endif %} -
- - {# 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 %} -
-

Category Analysis

-
-
-
-
- {% endif %} - - {# Behavior Timeline #} - {% if stats.category_history %} -
-

Behavior Timeline

-
- {% 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 %} -
-
- - {# Single IP Map - full width #} -
-

Location

-
-
- - {# Access History table #} -
-

Access History

-
-
Loading...
-
-
+ {% 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 = '
No attack data
'; return; } @@ -63,13 +74,14 @@ async function loadAttackTypesChart() { const borderColors = labels.map(label => generateColorFromHash(label).border); const hoverColors = labels.map(label => generateColorFromHash(label).hover); - // Create or update chart - if (attackTypesChart) { - attackTypesChart.destroy(); + // Create or update chart (track per canvas) + if (!loadAttackTypesChart._instances) loadAttackTypesChart._instances = {}; + if (loadAttackTypesChart._instances[canvasId]) { + loadAttackTypesChart._instances[canvasId].destroy(); } const ctx = canvas.getContext('2d'); - attackTypesChart = new Chart(ctx, { + const chartInstance = new Chart(ctx, { type: 'doughnut', data: { labels: labels, @@ -88,7 +100,7 @@ async function loadAttackTypesChart() { maintainAspectRatio: false, plugins: { legend: { - position: 'right', + position: legendPosition, labels: { color: '#c9d1d9', font: { @@ -160,6 +172,8 @@ async function loadAttackTypesChart() { }] }); + loadAttackTypesChart._instances[canvasId] = chartInstance; + attackTypesChart = chartInstance; attackTypesChartLoaded = true; } catch (err) { console.error('Error loading attack types chart:', err);