Merge pull request #106 from BlessedRebuS/feat/add-search-bar
Feat/add search bar
This commit is contained in:
116
src/database.py
116
src/database.py
@@ -2050,6 +2050,122 @@ class DatabaseManager:
|
|||||||
finally:
|
finally:
|
||||||
self.close_session()
|
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
|
# Module-level singleton instance
|
||||||
_db_manager = DatabaseManager()
|
_db_manager = DatabaseManager()
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ def get_templates() -> Jinja2Templates:
|
|||||||
return _templates
|
return _templates
|
||||||
|
|
||||||
|
|
||||||
def _format_ts(value, time_only=False):
|
def _format_ts(value):
|
||||||
"""Custom Jinja2 filter for formatting ISO timestamps."""
|
"""Custom Jinja2 filter for formatting ISO timestamps."""
|
||||||
if not value:
|
if not value:
|
||||||
return "N/A"
|
return "N/A"
|
||||||
@@ -39,7 +39,7 @@ def _format_ts(value, time_only=False):
|
|||||||
value = datetime.fromisoformat(value)
|
value = datetime.fromisoformat(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return value
|
return value
|
||||||
if time_only:
|
if value.date() == datetime.now().date():
|
||||||
return value.strftime("%H:%M:%S")
|
return value.strftime("%H:%M:%S")
|
||||||
return value.strftime("%m/%d/%Y %H:%M:%S")
|
return value.strftime("%m/%d/%Y %H:%M:%S")
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
HTMX fragment endpoints.
|
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
|
from fastapi import APIRouter, Request, Response, Query
|
||||||
@@ -369,3 +369,33 @@ async def htmx_ip_detail(ip_address: str, request: Request):
|
|||||||
"stats": stats,
|
"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"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" crossorigin="anonymous" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" crossorigin="anonymous" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/MarkerCluster.css" crossorigin="anonymous" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/MarkerCluster.css" crossorigin="anonymous" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/MarkerCluster.Default.css" crossorigin="anonymous" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/MarkerCluster.Default.css" crossorigin="anonymous" />
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Google+Sans+Flex:wght@400;500;700;900&display=swap" />
|
||||||
<link rel="stylesheet" href="{{ dashboard_path }}/static/css/dashboard.css" />
|
<link rel="stylesheet" href="{{ dashboard_path }}/static/css/dashboard.css" />
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js" crossorigin="anonymous" defer></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js" crossorigin="anonymous" defer></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/leaflet.markercluster.js" crossorigin="anonymous" defer></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/leaflet.markercluster.js" crossorigin="anonymous" defer></script>
|
||||||
|
|||||||
@@ -31,6 +31,27 @@
|
|||||||
{# Stats cards - server-rendered #}
|
{# Stats cards - server-rendered #}
|
||||||
{% include "dashboard/partials/stats_cards.html" %}
|
{% include "dashboard/partials/stats_cards.html" %}
|
||||||
|
|
||||||
|
{# Search bar #}
|
||||||
|
<div class="search-bar-container">
|
||||||
|
<div class="search-bar">
|
||||||
|
<svg class="search-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
<input id="search-input"
|
||||||
|
type="search"
|
||||||
|
name="q"
|
||||||
|
placeholder="Search attacks, IPs, patterns, locations..."
|
||||||
|
autocomplete="off"
|
||||||
|
hx-get="{{ dashboard_path }}/htmx/search"
|
||||||
|
hx-trigger="input changed delay:300ms, search"
|
||||||
|
hx-target="#search-results-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-indicator="#search-spinner" />
|
||||||
|
<span id="search-spinner" class="htmx-indicator search-spinner">↻</span>
|
||||||
|
</div>
|
||||||
|
<div id="search-results-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# Tab navigation - Alpine.js #}
|
{# Tab navigation - Alpine.js #}
|
||||||
<div class="tabs-container">
|
<div class="tabs-container">
|
||||||
<a class="tab-button" :class="{ active: tab === 'overview' }" @click.prevent="switchToOverview()" href="#overview">Overview</a>
|
<a class="tab-button" :class="{ active: tab === 'overview' }" @click.prevent="switchToOverview()" href="#overview">Overview</a>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="7" style="text-align: center;">No attacks detected</td></tr>
|
<tr><td colspan="7" class="empty-state">No attacks detected</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -25,8 +25,16 @@
|
|||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
Total Requests
|
Total Requests
|
||||||
</th>
|
</th>
|
||||||
<th>First Seen</th>
|
<th class="sortable {% if sort_by == 'first_seen' %}{{ sort_order }}{% endif %}"
|
||||||
<th>Last Seen</th>
|
hx-get="{{ dashboard_path }}/htmx/attackers?page=1&sort_by=first_seen&sort_order={% if sort_by == 'first_seen' and sort_order == 'desc' %}asc{% else %}desc{% endif %}"
|
||||||
|
hx-target="closest .htmx-container"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
First Seen</th>
|
||||||
|
<th class="sortable {% if sort_by == 'last_seen' %}{{ sort_order }}{% endif %}"
|
||||||
|
hx-get="{{ dashboard_path }}/htmx/attackers?page=1&sort_by=last_seen&sort_order={% if sort_by == 'last_seen' and sort_order == 'desc' %}asc{% else %}desc{% endif %}"
|
||||||
|
hx-target="closest .htmx-container"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Last Seen</th>
|
||||||
<th>Location</th>
|
<th>Location</th>
|
||||||
<th style="width: 40px;"></th>
|
<th style="width: 40px;"></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -60,7 +68,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="7" style="text-align: center;">No attackers found</td></tr>
|
<tr><td colspan="6" class="empty-state">No attackers found</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="7" style="text-align: center;">No credentials captured</td></tr>
|
<tr><td colspan="6" class="empty-state">No credentials captured</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="4" style="text-align: center;">No data</td></tr>
|
<tr><td colspan="3" class="empty-state">No data</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<td>{{ pattern.count }}</td>
|
<td>{{ pattern.count }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="3" style="text-align: center;">No patterns found</td></tr>
|
<tr><td colspan="3" class="empty-state">No patterns found</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
158
src/templates/jinja2/dashboard/partials/search_results.html
Normal file
158
src/templates/jinja2/dashboard/partials/search_results.html
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
{# HTMX fragment: Search results for attacks and IPs #}
|
||||||
|
<div class="search-results">
|
||||||
|
|
||||||
|
<div class="search-results-header">
|
||||||
|
<span class="search-results-summary">
|
||||||
|
Found <strong>{{ pagination.total_attacks }}</strong> attack{{ 's' if pagination.total_attacks != 1 else '' }}
|
||||||
|
and <strong>{{ pagination.total_ips }}</strong> IP{{ 's' if pagination.total_ips != 1 else '' }}
|
||||||
|
for “<em>{{ query | e }}</em>”
|
||||||
|
</span>
|
||||||
|
<button class="search-close-btn" onclick="document.getElementById('search-input').value=''; document.getElementById('search-results-container').innerHTML='';">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Matching IPs ─────────────────────────────────── #}
|
||||||
|
{% if ips %}
|
||||||
|
<div class="search-section">
|
||||||
|
<h3 class="search-section-title">Matching IPs</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Requests</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>ISP / ASN</th>
|
||||||
|
<th>Last Seen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ip in ips %}
|
||||||
|
<tr class="ip-row" data-ip="{{ ip.ip | e }}">
|
||||||
|
<td class="rank">{{ loop.index + (pagination.page - 1) * pagination.page_size }}</td>
|
||||||
|
<td class="ip-clickable"
|
||||||
|
hx-get="{{ dashboard_path }}/htmx/ip-detail/{{ ip.ip | e }}"
|
||||||
|
hx-target="next .ip-stats-row .ip-stats-dropdown"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
@click="toggleIpDetail($event)">
|
||||||
|
{{ ip.ip | e }}
|
||||||
|
</td>
|
||||||
|
<td>{{ ip.total_requests }}</td>
|
||||||
|
<td>
|
||||||
|
{% if ip.category %}
|
||||||
|
<span class="category-badge category-{{ ip.category | default('unknown') | replace(' ', '-') | lower }}">
|
||||||
|
{{ ip.category | e }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="category-badge category-unknown">unknown</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }}</td>
|
||||||
|
<td>{{ ip.isp | default(ip.asn_org | default('N/A')) | e }}</td>
|
||||||
|
<td>{{ ip.last_seen | format_ts }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="ip-stats-row" style="display: none;">
|
||||||
|
<td colspan="7" class="ip-stats-cell">
|
||||||
|
<div class="ip-stats-dropdown">
|
||||||
|
<div class="loading">Loading stats...</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Matching Attacks ─────────────────────────────── #}
|
||||||
|
{% if attacks %}
|
||||||
|
<div class="search-section">
|
||||||
|
<h3 class="search-section-title">Matching Attacks</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Path</th>
|
||||||
|
<th>Attack Types</th>
|
||||||
|
<th>User-Agent</th>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for attack in attacks %}
|
||||||
|
<tr class="ip-row" data-ip="{{ attack.ip | e }}">
|
||||||
|
<td class="rank">{{ loop.index + (pagination.page - 1) * pagination.page_size }}</td>
|
||||||
|
<td class="ip-clickable"
|
||||||
|
hx-get="{{ dashboard_path }}/htmx/ip-detail/{{ attack.ip | e }}"
|
||||||
|
hx-target="next .ip-stats-row .ip-stats-dropdown"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
@click="toggleIpDetail($event)">
|
||||||
|
{{ attack.ip | e }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="path-cell-container">
|
||||||
|
<span class="path-truncated">{{ attack.path | e }}</span>
|
||||||
|
{% if attack.path | length > 30 %}
|
||||||
|
<div class="path-tooltip">{{ attack.path | e }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="attack-types-cell">
|
||||||
|
{% set types_str = attack.attack_types | join(', ') %}
|
||||||
|
<span class="attack-types-truncated">{{ types_str | e }}</span>
|
||||||
|
{% if types_str | length > 30 %}
|
||||||
|
<div class="attack-types-tooltip">{{ types_str | e }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ (attack.user_agent | default(''))[:50] | e }}</td>
|
||||||
|
<td>{{ attack.timestamp | format_ts }}</td>
|
||||||
|
<td>
|
||||||
|
{% if attack.log_id %}
|
||||||
|
<button class="view-btn" @click="viewRawRequest({{ attack.log_id }})">View Request</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="ip-stats-row" style="display: none;">
|
||||||
|
<td colspan="7" class="ip-stats-cell">
|
||||||
|
<div class="ip-stats-dropdown">
|
||||||
|
<div class="loading">Loading stats...</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Pagination ───────────────────────────────────── #}
|
||||||
|
{% if pagination.total_pages > 1 %}
|
||||||
|
<div class="search-pagination">
|
||||||
|
<span class="pagination-info">Page {{ pagination.page }}/{{ pagination.total_pages }}</span>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<button class="pagination-btn"
|
||||||
|
hx-get="{{ dashboard_path }}/htmx/search?q={{ query | urlencode }}&page={{ pagination.page - 1 }}"
|
||||||
|
hx-target="#search-results-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
{% if pagination.page <= 1 %}disabled{% endif %}>Prev</button>
|
||||||
|
<button class="pagination-btn"
|
||||||
|
hx-get="{{ dashboard_path }}/htmx/search?q={{ query | urlencode }}&page={{ pagination.page + 1 }}"
|
||||||
|
hx-target="#search-results-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
{% if pagination.page >= pagination.total_pages %}disabled{% endif %}>Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── No results ───────────────────────────────────── #}
|
||||||
|
{% if not attacks and not ips %}
|
||||||
|
<div class="search-no-results">
|
||||||
|
No results found for “<em>{{ query | e }}</em>”
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="5" style="text-align:center;">No suspicious activity detected</td></tr>
|
<tr><td colspan="4" class="empty-state">No suspicious activity detected</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="5" style="text-align: center;">No data</td></tr>
|
<tr><td colspan="3" class="empty-state">No data</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<td>{{ item.count }}</td>
|
<td>{{ item.count }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="3" style="text-align: center;">No data</td></tr>
|
<tr><td colspan="3" class="empty-state">No data</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -31,11 +31,11 @@
|
|||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="rank">{{ loop.index + (pagination.page - 1) * pagination.page_size }}</td>
|
<td class="rank">{{ loop.index + (pagination.page - 1) * pagination.page_size }}</td>
|
||||||
<td>{{ item.user_agent | e }}</td>
|
<td style="font-size: 11px; word-break: break-all; max-width: 400px;">{{ item.user_agent | e }}</td>
|
||||||
<td>{{ item.count }}</td>
|
<td>{{ item.count }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="3" style="text-align: center;">No data</td></tr>
|
<tr><td colspan="3" class="empty-state">No data</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ h1 {
|
|||||||
color: #58a6ff;
|
color: #58a6ff;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
|
font-weight: 900;
|
||||||
|
font-family: 'Google Sans Flex', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
}
|
}
|
||||||
.download-section {
|
.download-section {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -74,20 +76,21 @@ h1 {
|
|||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
background: #238636;
|
background: rgba(35, 134, 54, 0.4);
|
||||||
color: #ffffff;
|
color: rgba(255, 255, 255, 0.7);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s, color 0.2s;
|
||||||
border: 1px solid #2ea043;
|
border: 1px solid rgba(46, 160, 67, 0.4);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.banlist-dropdown-btn:hover {
|
.banlist-dropdown-btn:hover {
|
||||||
background: #2ea043;
|
background: rgba(46, 160, 67, 0.6);
|
||||||
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
.banlist-dropdown-menu {
|
.banlist-dropdown-menu {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -189,8 +192,8 @@ tr:hover {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
.alert-section {
|
.alert-section {
|
||||||
background: #1c1917;
|
background: #161b22;
|
||||||
border-left: 4px solid #f85149;
|
border-left: 6px solid rgba(248, 81, 73, 0.4);
|
||||||
}
|
}
|
||||||
th.sortable {
|
th.sortable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1263,339 +1266,133 @@ tbody {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ── Search Bar ────────────────────────────────────── */
|
||||||
Single IP Page Styles
|
.search-bar-container {
|
||||||
======================================== */
|
max-width: 100%;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
.ip-page-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
padding-top: 20px;
|
|
||||||
}
|
}
|
||||||
|
.search-bar {
|
||||||
.ip-page-header h1 {
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
|
||||||
gap: 15px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
.search-icon {
|
||||||
.ip-address-title {
|
position: absolute;
|
||||||
font-family: 'SF Mono', Monaco, Consolas, monospace;
|
left: 14px;
|
||||||
font-size: 32px;
|
width: 18px;
|
||||||
color: #58a6ff;
|
height: 18px;
|
||||||
}
|
|
||||||
|
|
||||||
.ip-location-subtitle {
|
|
||||||
color: #8b949e;
|
color: #8b949e;
|
||||||
font-size: 16px;
|
pointer-events: none;
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
.search-bar input[type="search"] {
|
||||||
.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;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 500px;
|
padding: 12px 40px 12px 42px;
|
||||||
}
|
|
||||||
|
|
||||||
.ip-lookup-input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: #0d1117;
|
background: #0d1117;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #30363d;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #c9d1d9;
|
color: #c9d1d9;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-family: 'SF Mono', Monaco, Consolas, monospace;
|
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
.search-bar input[type="search"]::placeholder {
|
||||||
.ip-lookup-input:focus {
|
color: #6e7681;
|
||||||
|
}
|
||||||
|
.search-bar input[type="search"]:focus {
|
||||||
border-color: #58a6ff;
|
border-color: #58a6ff;
|
||||||
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
|
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
.search-bar input[type="search"]::-webkit-search-cancel-button {
|
||||||
.ip-lookup-input::placeholder {
|
-webkit-appearance: none;
|
||||||
color: #6e7681;
|
appearance: none;
|
||||||
}
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
.ip-lookup-btn {
|
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;
|
||||||
padding: 12px 24px;
|
|
||||||
background: #238636;
|
|
||||||
color: #ffffff;
|
|
||||||
border: 1px solid #2ea043;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
.search-spinner {
|
||||||
.ip-lookup-btn:hover {
|
position: absolute;
|
||||||
background: #2ea043;
|
right: 14px;
|
||||||
}
|
font-size: 18px;
|
||||||
|
|
||||||
.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;
|
|
||||||
color: #58a6ff;
|
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: 1px solid #30363d;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
font-size: 11px;
|
padding: 16px;
|
||||||
font-weight: 500;
|
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;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
padding: 0 4px;
|
||||||
text-decoration: none;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
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 {
|
/* ── Empty State (no data rows) ───────────────────── */
|
||||||
background: #30363d;
|
.empty-state {
|
||||||
border-color: #58a6ff;
|
text-align: center;
|
||||||
color: #79c0ff;
|
color: #4a515a;
|
||||||
}
|
padding: 20px 12px;
|
||||||
|
|
||||||
.inspect-btn svg {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
fill: currentColor;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user