From 799b5d5164e58d8869d012c6102e345fc6f4824b Mon Sep 17 00:00:00 2001 From: Matthias-vdE Date: Fri, 27 Feb 2026 10:52:05 +0100 Subject: [PATCH 1/2] Make attackers table sortable and cleanup time display. This update makes the Attackers by Total Requests table sortable by First Seen and Last Seen. It also changes the way datetimes are being displayed everywhere: Only show the time when the event happened today, show the full datetime when the event happened on another day. --- src/dependencies.py | 4 ++-- .../jinja2/dashboard/partials/attackers_table.html | 12 ++++++++++-- .../jinja2/dashboard/partials/suspicious_table.html | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/dependencies.py b/src/dependencies.py index 774d9dd..a713738 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -30,7 +30,7 @@ def get_templates() -> Jinja2Templates: return _templates -def _format_ts(value, time_only=False): +def _format_ts(value): """Custom Jinja2 filter for formatting ISO timestamps.""" if not value: return "N/A" @@ -39,7 +39,7 @@ def _format_ts(value, time_only=False): value = datetime.fromisoformat(value) except (ValueError, TypeError): return value - if time_only: + if value.date() == datetime.now().date(): return value.strftime("%H:%M:%S") return value.strftime("%m/%d/%Y %H:%M:%S") diff --git a/src/templates/jinja2/dashboard/partials/attackers_table.html b/src/templates/jinja2/dashboard/partials/attackers_table.html index 632137d..a235130 100644 --- a/src/templates/jinja2/dashboard/partials/attackers_table.html +++ b/src/templates/jinja2/dashboard/partials/attackers_table.html @@ -25,8 +25,16 @@ hx-swap="innerHTML"> Total Requests - First Seen - Last Seen + + First Seen + + Last Seen Location diff --git a/src/templates/jinja2/dashboard/partials/suspicious_table.html b/src/templates/jinja2/dashboard/partials/suspicious_table.html index 72a0480..4884dec 100644 --- a/src/templates/jinja2/dashboard/partials/suspicious_table.html +++ b/src/templates/jinja2/dashboard/partials/suspicious_table.html @@ -22,7 +22,7 @@ {{ activity.path | e }} {{ (activity.user_agent | default(''))[:80] | e }} - {{ activity.timestamp | format_ts(time_only=True) }} + {{ activity.timestamp | format_ts }} From 62bb091926d24b96fe6a97443b54b49b7ba768c9 Mon Sep 17 00:00:00 2001 From: BlessedRebuS Date: Sat, 28 Feb 2026 18:43:09 +0100 Subject: [PATCH 2/2] added search bar feature, refactored the dashboard --- src/database.py | 116 +++++++++++++ src/routes/htmx.py | 32 +++- src/templates/jinja2/base.html | 1 + src/templates/jinja2/dashboard/index.html | 21 +++ .../partials/attack_types_table.html | 2 +- .../dashboard/partials/attackers_table.html | 2 +- .../dashboard/partials/credentials_table.html | 2 +- .../dashboard/partials/honeypot_table.html | 2 +- .../dashboard/partials/patterns_table.html | 2 +- .../dashboard/partials/search_results.html | 158 ++++++++++++++++++ .../dashboard/partials/suspicious_table.html | 2 +- .../dashboard/partials/top_ips_table.html | 2 +- .../dashboard/partials/top_paths_table.html | 2 +- .../dashboard/partials/top_ua_table.html | 4 +- src/templates/static/css/dashboard.css | 148 +++++++++++++++- 15 files changed, 478 insertions(+), 18 deletions(-) create mode 100644 src/templates/jinja2/dashboard/partials/search_results.html diff --git a/src/database.py b/src/database.py index 9daca49..5b8d685 100644 --- a/src/database.py +++ b/src/database.py @@ -1973,6 +1973,122 @@ class DatabaseManager: finally: self.close_session() + def search_attacks_and_ips( + self, + query: str, + page: int = 1, + page_size: int = 20, + ) -> Dict[str, Any]: + """ + Search attacks and IPs matching a query string. + + Searches across AttackDetection (attack_type, matched_pattern), + AccessLog (ip, path), and IpStats (ip, city, country, isp, asn_org). + + Args: + query: Search term (partial match) + page: Page number (1-indexed) + page_size: Results per page + + Returns: + Dictionary with matching attacks, ips, and pagination info + """ + session = self.session + try: + offset = (page - 1) * page_size + like_q = f"%{query}%" + + # --- Search attacks (AccessLog + AttackDetection) --- + attack_query = ( + session.query(AccessLog) + .join(AttackDetection) + .filter( + or_( + AccessLog.ip.ilike(like_q), + AccessLog.path.ilike(like_q), + AttackDetection.attack_type.ilike(like_q), + AttackDetection.matched_pattern.ilike(like_q), + ) + ) + .distinct(AccessLog.id) + ) + + total_attacks = attack_query.count() + attack_logs = ( + attack_query.order_by(AccessLog.timestamp.desc()) + .offset(offset) + .limit(page_size) + .all() + ) + + attacks = [ + { + "id": log.id, + "ip": log.ip, + "path": log.path, + "user_agent": log.user_agent, + "timestamp": log.timestamp.isoformat() if log.timestamp else None, + "attack_types": [d.attack_type for d in log.attack_detections], + "log_id": log.id, + } + for log in attack_logs + ] + + # --- Search IPs (IpStats) --- + ip_query = session.query(IpStats).filter( + or_( + IpStats.ip.ilike(like_q), + IpStats.city.ilike(like_q), + IpStats.country.ilike(like_q), + IpStats.country_code.ilike(like_q), + IpStats.isp.ilike(like_q), + IpStats.asn_org.ilike(like_q), + IpStats.reverse.ilike(like_q), + ) + ) + + total_ips = ip_query.count() + ips = ( + ip_query.order_by(IpStats.total_requests.desc()) + .offset(offset) + .limit(page_size) + .all() + ) + + ip_results = [ + { + "ip": stat.ip, + "total_requests": stat.total_requests, + "first_seen": stat.first_seen.isoformat() if stat.first_seen else None, + "last_seen": stat.last_seen.isoformat() if stat.last_seen else None, + "country_code": stat.country_code, + "city": stat.city, + "category": stat.category, + "isp": stat.isp, + "asn_org": stat.asn_org, + } + for stat in ips + ] + + total = total_attacks + total_ips + total_pages = max(1, (max(total_attacks, total_ips) + page_size - 1) // page_size) + + return { + "attacks": attacks, + "ips": ip_results, + "query": query, + "pagination": { + "page": page, + "page_size": page_size, + "total_attacks": total_attacks, + "total_ips": total_ips, + "total": total, + "total_pages": total_pages, + }, + } + finally: + self.close_session() + # Module-level singleton instance _db_manager = DatabaseManager() diff --git a/src/routes/htmx.py b/src/routes/htmx.py index 4013ce5..0023598 100644 --- a/src/routes/htmx.py +++ b/src/routes/htmx.py @@ -2,7 +2,7 @@ """ HTMX fragment endpoints. -Server-rendered HTML partials for table pagination, sorting, and IP details. +Server-rendered HTML partials for table pagination, sorting, IP details, and search. """ from fastapi import APIRouter, Request, Response, Query @@ -305,3 +305,33 @@ async def htmx_ip_detail(ip_address: str, request: Request): "stats": stats, }, ) + + +# ── Search ─────────────────────────────────────────────────────────── + + +@router.get("/htmx/search") +async def htmx_search( + request: Request, + q: str = Query(""), + page: int = Query(1), +): + q = q.strip() + if not q: + return Response(content="", media_type="text/html") + + db = get_db() + result = db.search_attacks_and_ips(query=q, page=max(1, page), page_size=20) + + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/search_results.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + "attacks": result["attacks"], + "ips": result["ips"], + "query": q, + "pagination": result["pagination"], + }, + ) diff --git a/src/templates/jinja2/base.html b/src/templates/jinja2/base.html index 1ba2af5..48097f0 100644 --- a/src/templates/jinja2/base.html +++ b/src/templates/jinja2/base.html @@ -8,6 +8,7 @@ + diff --git a/src/templates/jinja2/dashboard/index.html b/src/templates/jinja2/dashboard/index.html index 5ec70f7..83d708c 100644 --- a/src/templates/jinja2/dashboard/index.html +++ b/src/templates/jinja2/dashboard/index.html @@ -31,6 +31,27 @@ {# Stats cards - server-rendered #} {% include "dashboard/partials/stats_cards.html" %} + {# Search bar #} +
+ +
+
+ {# Tab navigation - Alpine.js #}
Overview diff --git a/src/templates/jinja2/dashboard/partials/attack_types_table.html b/src/templates/jinja2/dashboard/partials/attack_types_table.html index 8a74572..befd6e6 100644 --- a/src/templates/jinja2/dashboard/partials/attack_types_table.html +++ b/src/templates/jinja2/dashboard/partials/attack_types_table.html @@ -74,7 +74,7 @@ {% else %} - No attacks detected + No attacks detected {% endfor %} diff --git a/src/templates/jinja2/dashboard/partials/attackers_table.html b/src/templates/jinja2/dashboard/partials/attackers_table.html index a235130..656a341 100644 --- a/src/templates/jinja2/dashboard/partials/attackers_table.html +++ b/src/templates/jinja2/dashboard/partials/attackers_table.html @@ -62,7 +62,7 @@ {% else %} - No attackers found + No attackers found {% endfor %} diff --git a/src/templates/jinja2/dashboard/partials/credentials_table.html b/src/templates/jinja2/dashboard/partials/credentials_table.html index ccfb364..49c3abc 100644 --- a/src/templates/jinja2/dashboard/partials/credentials_table.html +++ b/src/templates/jinja2/dashboard/partials/credentials_table.html @@ -54,7 +54,7 @@ {% else %} - No credentials captured + No credentials captured {% endfor %} diff --git a/src/templates/jinja2/dashboard/partials/honeypot_table.html b/src/templates/jinja2/dashboard/partials/honeypot_table.html index 35676fc..53ac150 100644 --- a/src/templates/jinja2/dashboard/partials/honeypot_table.html +++ b/src/templates/jinja2/dashboard/partials/honeypot_table.html @@ -48,7 +48,7 @@ {% else %} - No data + No data {% endfor %} diff --git a/src/templates/jinja2/dashboard/partials/patterns_table.html b/src/templates/jinja2/dashboard/partials/patterns_table.html index 260f31d..003f7e3 100644 --- a/src/templates/jinja2/dashboard/partials/patterns_table.html +++ b/src/templates/jinja2/dashboard/partials/patterns_table.html @@ -37,7 +37,7 @@ {{ pattern.count }} {% else %} - No patterns found + No patterns found {% endfor %} diff --git a/src/templates/jinja2/dashboard/partials/search_results.html b/src/templates/jinja2/dashboard/partials/search_results.html new file mode 100644 index 0000000..a1e4046 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/search_results.html @@ -0,0 +1,158 @@ +{# HTMX fragment: Search results for attacks and IPs #} +
+ +
+ + Found {{ pagination.total_attacks }} attack{{ 's' if pagination.total_attacks != 1 else '' }} + and {{ pagination.total_ips }} IP{{ 's' if pagination.total_ips != 1 else '' }} + for “{{ query | e }}” + + +
+ + {# ── Matching IPs ─────────────────────────────────── #} + {% if ips %} +
+

Matching IPs

+ + + + + + + + + + + + + + {% for ip in ips %} + + + + + + + + + + + + + {% endfor %} + +
#IP AddressRequestsCategoryLocationISP / ASNLast Seen
{{ loop.index + (pagination.page - 1) * pagination.page_size }} + {{ ip.ip | e }} + {{ ip.total_requests }} + {% if ip.category %} + + {{ ip.category | e }} + + {% else %} + unknown + {% endif %} + {{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }}{{ ip.isp | default(ip.asn_org | default('N/A')) | e }}{{ ip.last_seen | format_ts }}
+
+ {% endif %} + + {# ── Matching Attacks ─────────────────────────────── #} + {% if attacks %} +
+

Matching Attacks

+ + + + + + + + + + + + + + {% for attack in attacks %} + + + + + + + + + + + + + {% endfor %} + +
#IP AddressPathAttack TypesUser-AgentTimeActions
{{ loop.index + (pagination.page - 1) * pagination.page_size }} + {{ attack.ip | e }} + +
+ {{ attack.path | e }} + {% if attack.path | length > 30 %} +
{{ attack.path | e }}
+ {% endif %} +
+
+
+ {% set types_str = attack.attack_types | join(', ') %} + {{ types_str | e }} + {% if types_str | length > 30 %} +
{{ types_str | e }}
+ {% endif %} +
+
{{ (attack.user_agent | default(''))[:50] | e }}{{ attack.timestamp | format_ts }} + {% if attack.log_id %} + + {% endif %} +
+
+ {% endif %} + + {# ── Pagination ───────────────────────────────────── #} + {% if pagination.total_pages > 1 %} +
+ Page {{ pagination.page }}/{{ pagination.total_pages }} +
+ + +
+
+ {% endif %} + + {# ── No results ───────────────────────────────────── #} + {% if not attacks and not ips %} +
+ No results found for “{{ query | e }}” +
+ {% endif %} + +
diff --git a/src/templates/jinja2/dashboard/partials/suspicious_table.html b/src/templates/jinja2/dashboard/partials/suspicious_table.html index 4884dec..97a11c8 100644 --- a/src/templates/jinja2/dashboard/partials/suspicious_table.html +++ b/src/templates/jinja2/dashboard/partials/suspicious_table.html @@ -32,7 +32,7 @@ {% else %} - No suspicious activity detected + 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 index 84b335f..cbfc959 100644 --- a/src/templates/jinja2/dashboard/partials/top_ips_table.html +++ b/src/templates/jinja2/dashboard/partials/top_ips_table.html @@ -48,7 +48,7 @@ {% else %} - No data + 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 index d1ec6d1..c102410 100644 --- a/src/templates/jinja2/dashboard/partials/top_paths_table.html +++ b/src/templates/jinja2/dashboard/partials/top_paths_table.html @@ -35,7 +35,7 @@ {{ item.count }} {% else %} - No data + 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 index faf487e..dc38e90 100644 --- a/src/templates/jinja2/dashboard/partials/top_ua_table.html +++ b/src/templates/jinja2/dashboard/partials/top_ua_table.html @@ -31,11 +31,11 @@ {% for item in items %} {{ loop.index + (pagination.page - 1) * pagination.page_size }} - {{ item.user_agent | e }} + {{ item.user_agent | e }} {{ item.count }} {% else %} - No data + No data {% endfor %} diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css index c7cd3a5..4ca038f 100644 --- a/src/templates/static/css/dashboard.css +++ b/src/templates/static/css/dashboard.css @@ -41,6 +41,8 @@ h1 { color: #58a6ff; text-align: center; margin-bottom: 40px; + font-weight: 900; + font-family: 'Google Sans Flex', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } .download-section { position: absolute; @@ -74,20 +76,21 @@ h1 { display: block; width: 100%; padding: 8px 14px; - background: #238636; - color: #ffffff; + background: rgba(35, 134, 54, 0.4); + color: rgba(255, 255, 255, 0.7); text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 13px; - transition: background 0.2s; - border: 1px solid #2ea043; + transition: background 0.2s, color 0.2s; + border: 1px solid rgba(46, 160, 67, 0.4); cursor: pointer; text-align: left; box-sizing: border-box; } .banlist-dropdown-btn:hover { - background: #2ea043; + background: rgba(46, 160, 67, 0.6); + color: #ffffff; } .banlist-dropdown-menu { display: none; @@ -189,8 +192,8 @@ tr:hover { font-weight: bold; } .alert-section { - background: #1c1917; - border-left: 4px solid #f85149; + background: #161b22; + border-left: 6px solid rgba(248, 81, 73, 0.4); } th.sortable { cursor: pointer; @@ -1253,3 +1256,134 @@ tbody { [x-cloak] { display: none !important; } + +/* ── Search Bar ────────────────────────────────────── */ +.search-bar-container { + max-width: 100%; + margin: 0 0 20px 0; +} +.search-bar { + position: relative; + display: flex; + align-items: center; +} +.search-icon { + position: absolute; + left: 14px; + width: 18px; + height: 18px; + color: #8b949e; + pointer-events: none; +} +.search-bar input[type="search"] { + width: 100%; + padding: 12px 40px 12px 42px; + background: #0d1117; + border: 1px solid #30363d; + border-radius: 6px; + color: #c9d1d9; + font-size: 14px; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} +.search-bar input[type="search"]::placeholder { + color: #6e7681; +} +.search-bar input[type="search"]:focus { + border-color: #58a6ff; + box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15); +} +.search-bar input[type="search"]::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%238b949e'%3E%3Cpath d='M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z'/%3E%3C/svg%3E") center/contain no-repeat; + cursor: pointer; +} +.search-spinner { + position: absolute; + right: 14px; + font-size: 18px; + color: #58a6ff; + animation: spin 0.8s linear infinite; +} +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* ── Search Results ───────────────────────────────── */ +.search-results { + margin-top: 12px; + background: #161b22; + border: 1px solid #30363d; + border-radius: 6px; + padding: 16px; + animation: fadeIn 0.3s ease-in; +} +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} +.search-results-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 14px; + padding-bottom: 10px; + border-bottom: 1px solid #30363d; +} +.search-results-summary { + color: #8b949e; + font-size: 13px; +} +.search-results-summary strong { + color: #58a6ff; +} +.search-close-btn { + background: none; + border: none; + color: #8b949e; + font-size: 22px; + cursor: pointer; + padding: 0 4px; + line-height: 1; + transition: color 0.2s; +} +.search-close-btn:hover { + color: #f85149; +} +.search-section { + margin-bottom: 16px; +} +.search-section:last-of-type { + margin-bottom: 0; +} +.search-section-title { + color: #58a6ff; + font-size: 14px; + font-weight: 600; + margin: 0 0 8px 0; +} +.search-pagination { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid #30363d; +} +.search-no-results { + text-align: center; + color: #4a515a; + padding: 24px 0; + font-size: 14px; +} + +/* ── Empty State (no data rows) ───────────────────── */ +.empty-state { + text-align: center; + color: #4a515a; + padding: 20px 12px; +}