diff --git a/src/database.py b/src/database.py index 2d56712..6bd282b 100644 --- a/src/database.py +++ b/src/database.py @@ -2050,6 +2050,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/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/routes/htmx.py b/src/routes/htmx.py index ef2d5c1..976fc35 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 @@ -369,3 +369,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 647a00c..97e7ce1 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 9a2451a..e149bfc 100644 --- a/src/templates/jinja2/dashboard/partials/attack_types_table.html +++ b/src/templates/jinja2/dashboard/partials/attack_types_table.html @@ -77,7 +77,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 23c61e8..1bcbb40 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 @@ -60,7 +68,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 491b5b2..c7ee193 100644 --- a/src/templates/jinja2/dashboard/partials/credentials_table.html +++ b/src/templates/jinja2/dashboard/partials/credentials_table.html @@ -60,7 +60,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 3215bc7..302df69 100644 --- a/src/templates/jinja2/dashboard/partials/honeypot_table.html +++ b/src/templates/jinja2/dashboard/partials/honeypot_table.html @@ -54,7 +54,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 c10502c..333e8df 100644 --- a/src/templates/jinja2/dashboard/partials/suspicious_table.html +++ b/src/templates/jinja2/dashboard/partials/suspicious_table.html @@ -38,7 +38,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 afcf26b..d4614c2 100644 --- a/src/templates/jinja2/dashboard/partials/top_ips_table.html +++ b/src/templates/jinja2/dashboard/partials/top_ips_table.html @@ -60,7 +60,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 9ed0186..a4dae5f 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; @@ -1263,339 +1266,133 @@ tbody { display: none !important; } -/* ======================================== - Single IP Page Styles - ======================================== */ - -.ip-page-header { - text-align: center; - margin-bottom: 30px; - padding-top: 20px; +/* ── Search Bar ────────────────────────────────────── */ +.search-bar-container { + max-width: 100%; + margin: 0 0 20px 0; } - -.ip-page-header h1 { +.search-bar { + position: relative; display: flex; align-items: center; - justify-content: center; - gap: 15px; - flex-wrap: wrap; - margin-bottom: 8px; } - -.ip-address-title { - font-family: 'SF Mono', Monaco, Consolas, monospace; - font-size: 32px; - color: #58a6ff; -} - -.ip-location-subtitle { +.search-icon { + position: absolute; + left: 14px; + width: 18px; + height: 18px; color: #8b949e; - font-size: 16px; - margin: 0; + pointer-events: none; } - -.ip-page-grid { - display: grid; - grid-template-columns: 2fr 1fr; - gap: 20px; - margin-bottom: 20px; -} - -.ip-page-left, -.ip-page-right { - display: flex; - flex-direction: column; - gap: 20px; -} - -.ip-info-card h2 { - margin-bottom: 20px; -} - -.ip-info-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 20px; -} - -.ip-info-section { - background: #0d1117; - border: 1px solid #21262d; - border-radius: 6px; - padding: 15px; -} - -.ip-info-section h3 { - color: #58a6ff; - font-size: 13px; - font-weight: 600; - text-transform: uppercase; - margin: 0 0 12px 0; - padding-bottom: 8px; - border-bottom: 1px solid #21262d; -} - -.ip-flag { - display: inline-block; - padding: 2px 8px; - background: #f0883e1a; - color: #f0883e; - border: 1px solid #f0883e4d; - border-radius: 4px; - font-size: 11px; - font-weight: 600; - margin-right: 4px; -} - -.reputation-score { - font-weight: 700; - padding: 2px 8px; - border-radius: 4px; -} - -.reputation-score.bad { - background: #f851491a; - color: #f85149; -} - -.reputation-score.medium { - background: #f0883e1a; - color: #f0883e; -} - -.reputation-score.good { - background: #3fb9501a; - color: #3fb950; -} - -.radar-chart-container { - display: flex; - justify-content: center; - padding: 20px 0; -} - -/* Single IP page: radar chart with legend on the right */ -.ip-page-right .radar-chart-container { - padding: 10px 0; - justify-content: flex-start; -} - -/* Target the wrapper div injected by generateRadarChart inside radar chart containers */ -.ip-page-right #ip-radar-chart > div, -.ip-page-right #insight-radar-chart > div { - display: flex !important; - flex-direction: row !important; - align-items: center !important; - gap: 15px; -} - -.ip-page-right #ip-radar-chart > div > svg, -.ip-page-right #insight-radar-chart > div > svg { - flex-shrink: 0; -} - -.ip-page-right #ip-radar-chart .radar-legend, -.ip-page-right #insight-radar-chart .radar-legend { - margin-top: 0; - text-align: left; -} - -/* Single IP page: limit timeline height to match map */ -.ip-page-right .timeline { - max-height: 250px; - overflow-y: auto; - padding-right: 10px; -} - -/* Dark theme scrollbar for timeline */ -.ip-page-right .timeline::-webkit-scrollbar { - width: 6px; -} - -.ip-page-right .timeline::-webkit-scrollbar-track { - background: #21262d; - border-radius: 3px; -} - -.ip-page-right .timeline::-webkit-scrollbar-thumb { - background: #484f58; - border-radius: 3px; -} - -.ip-page-right .timeline::-webkit-scrollbar-thumb:hover { - background: #6e7681; -} - -.single-ip-marker { - background: none !important; - border: none !important; -} - -/* Mobile responsiveness for IP page */ -@media (max-width: 1024px) { - .ip-page-grid { - grid-template-columns: 1fr; - } - - .ip-page-right { - order: -1; - } - - .ip-info-grid { - grid-template-columns: 1fr; - } - - /* On mobile, stack legend below chart again */ - .ip-page-right #ip-radar-chart > div, - .ip-page-right #insight-radar-chart > div { - flex-direction: column !important; - } - - .ip-page-right #ip-radar-chart .radar-legend, - .ip-page-right #insight-radar-chart .radar-legend { - margin-top: 10px; - text-align: center; - } - - /* On mobile, remove timeline height limit */ - .ip-page-right .timeline { - max-height: none; - } -} - -@media (max-width: 768px) { - .ip-address-title { - font-size: 24px; - } - - .ip-page-header h1 { - flex-direction: column; - gap: 10px; - } - - .ip-info-section { - padding: 12px; - } - - .ip-info-section h3 { - font-size: 12px; - } -} - -@media (max-width: 480px) { - .ip-address-title { - font-size: 18px; - } - - .ip-location-subtitle { - font-size: 14px; - } -} - -/* ======================================== - IP Lookup Panel - ======================================== */ - -.ip-lookup-panel { - background: #161b22; - border: 1px solid #30363d; - border-radius: 8px; - padding: 20px; - margin-bottom: 30px; - display: flex; - justify-content: center; -} - -.ip-lookup-form { - display: flex; - gap: 12px; +.search-bar input[type="search"] { width: 100%; - max-width: 500px; -} - -.ip-lookup-input { - flex: 1; - padding: 12px 16px; + padding: 12px 40px 12px 42px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 14px; - font-family: 'SF Mono', Monaco, Consolas, monospace; outline: none; transition: border-color 0.2s, box-shadow 0.2s; } - -.ip-lookup-input:focus { +.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); } - -.ip-lookup-input::placeholder { - color: #6e7681; -} - -.ip-lookup-btn { - padding: 12px 24px; - background: #238636; - color: #ffffff; - border: 1px solid #2ea043; - border-radius: 6px; - font-weight: 600; - font-size: 14px; +.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; - transition: background 0.2s; - white-space: nowrap; } - -.ip-lookup-btn:hover { - background: #2ea043; -} - -.ip-lookup-btn:active { - background: #1f7a2f; -} - -@media (max-width: 480px) { - .ip-lookup-form { - flex-direction: column; - } - - .ip-lookup-btn { - width: 100%; - } -} - -/* ======================================== - Inspect Button - ======================================== */ - -.inspect-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 4px; - padding: 4px 10px; - background: #21262d; +.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: 4px; - font-size: 11px; - font-weight: 500; + 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; - transition: all 0.2s; - text-decoration: none; - white-space: nowrap; + 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; } -.inspect-btn:hover { - background: #30363d; - border-color: #58a6ff; - color: #79c0ff; -} - -.inspect-btn svg { - width: 12px; - height: 12px; - fill: currentColor; +/* ── Empty State (no data rows) ───────────────────── */ +.empty-state { + text-align: center; + color: #4a515a; + padding: 20px 12px; }