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.
This commit is contained in:
@@ -815,8 +815,8 @@ class DatabaseManager:
|
|||||||
def flag_stale_ips_for_reevaluation(self) -> int:
|
def flag_stale_ips_for_reevaluation(self) -> int:
|
||||||
"""
|
"""
|
||||||
Flag IPs for reevaluation where:
|
Flag IPs for reevaluation where:
|
||||||
- last_seen is between 15 and 30 days ago
|
- last_seen is between 5 and 30 days ago
|
||||||
- last_analysis is more than 10 days ago (or never analyzed)
|
- last_analysis is more than 5 days ago
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of IPs flagged for reevaluation
|
Number of IPs flagged for reevaluation
|
||||||
@@ -825,18 +825,15 @@ class DatabaseManager:
|
|||||||
try:
|
try:
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
last_seen_lower = now - timedelta(days=30)
|
last_seen_lower = now - timedelta(days=30)
|
||||||
last_seen_upper = now - timedelta(days=15)
|
last_seen_upper = now - timedelta(days=5)
|
||||||
last_analysis_cutoff = now - timedelta(days=10)
|
last_analysis_cutoff = now - timedelta(days=5)
|
||||||
|
|
||||||
count = (
|
count = (
|
||||||
session.query(IpStats)
|
session.query(IpStats)
|
||||||
.filter(
|
.filter(
|
||||||
IpStats.last_seen >= last_seen_lower,
|
IpStats.last_seen >= last_seen_lower,
|
||||||
IpStats.last_seen <= last_seen_upper,
|
IpStats.last_seen <= last_seen_upper,
|
||||||
or_(
|
IpStats.last_analysis <= last_analysis_cutoff,
|
||||||
IpStats.last_analysis <= last_analysis_cutoff,
|
|
||||||
IpStats.last_analysis.is_(None),
|
|
||||||
),
|
|
||||||
IpStats.need_reevaluation == False,
|
IpStats.need_reevaluation == False,
|
||||||
IpStats.manual_category == False,
|
IpStats.manual_category == False,
|
||||||
)
|
)
|
||||||
@@ -2029,12 +2026,15 @@ class DatabaseManager:
|
|||||||
finally:
|
finally:
|
||||||
self.close_session()
|
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).
|
Get aggregated statistics for attack types (efficient for large datasets).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
limit: Maximum number of attack types to return
|
limit: Maximum number of attack types to return
|
||||||
|
ip_filter: Optional IP address to filter results for
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with attack type counts
|
Dictionary with attack type counts
|
||||||
@@ -2044,12 +2044,18 @@ class DatabaseManager:
|
|||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
# Aggregate attack types with count
|
# 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 = (
|
results = (
|
||||||
session.query(
|
query.group_by(AttackDetection.attack_type)
|
||||||
AttackDetection.attack_type,
|
|
||||||
func.count(AttackDetection.id).label("count"),
|
|
||||||
)
|
|
||||||
.group_by(AttackDetection.attack_type)
|
|
||||||
.order_by(func.count(AttackDetection.id).desc())
|
.order_by(func.count(AttackDetection.id).desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.all()
|
.all()
|
||||||
|
|||||||
@@ -214,12 +214,13 @@ async def top_user_agents(
|
|||||||
async def attack_types_stats(
|
async def attack_types_stats(
|
||||||
request: Request,
|
request: Request,
|
||||||
limit: int = Query(20),
|
limit: int = Query(20),
|
||||||
|
ip_filter: str = Query(None),
|
||||||
):
|
):
|
||||||
db = get_db()
|
db = get_db()
|
||||||
limit = min(max(1, limit), 100)
|
limit = min(max(1, limit), 100)
|
||||||
|
|
||||||
try:
|
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())
|
return JSONResponse(content=result, headers=_no_cache_headers())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
get_app_logger().error(f"Error fetching attack types stats: {e}")
|
get_app_logger().error(f"Error fetching attack types stats: {e}")
|
||||||
|
|||||||
@@ -16,187 +16,8 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Page header #}
|
{% set uid = "ip" %}
|
||||||
<div class="ip-page-header">
|
{% include "dashboard/partials/_ip_detail.html" %}
|
||||||
<h1>
|
|
||||||
<span class="ip-address-title">{{ ip_address }}</span>
|
|
||||||
{% if stats.category %}
|
|
||||||
<span class="category-badge category-{{ stats.category | lower | replace('_', '-') }}">
|
|
||||||
{{ stats.category | replace('_', ' ') | title }}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</h1>
|
|
||||||
{% if stats.city or stats.country %}
|
|
||||||
<p class="ip-location-subtitle">
|
|
||||||
{{ stats.city | default('') }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Main content grid #}
|
|
||||||
<div class="ip-page-grid">
|
|
||||||
{# Left column: IP Info + Map #}
|
|
||||||
<div class="ip-page-left">
|
|
||||||
{# IP Information Card #}
|
|
||||||
<div class="table-container ip-info-card">
|
|
||||||
<h2>IP Information</h2>
|
|
||||||
<div class="ip-info-grid">
|
|
||||||
<div class="ip-info-section">
|
|
||||||
<h3>Activity</h3>
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Total Requests:</span>
|
|
||||||
<span class="stat-value-sm">{{ stats.total_requests | default('N/A') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">First Seen:</span>
|
|
||||||
<span class="stat-value-sm">{{ stats.first_seen | format_ts }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Last Seen:</span>
|
|
||||||
<span class="stat-value-sm">{{ stats.last_seen | format_ts }}</span>
|
|
||||||
</div>
|
|
||||||
{% if stats.last_analysis %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Last Analysis:</span>
|
|
||||||
<span class="stat-value-sm">{{ stats.last_analysis | format_ts }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ip-info-section">
|
|
||||||
<h3>Geo & Network</h3>
|
|
||||||
{% if stats.city or stats.country %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Location:</span>
|
|
||||||
<span class="stat-value-sm">{{ stats.city | default('') | e }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) | e }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if stats.timezone %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Timezone:</span>
|
|
||||||
<span class="stat-value-sm">{{ stats.timezone | e }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if stats.isp %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">ISP:</span>
|
|
||||||
<span class="stat-value-sm">{{ stats.isp | e }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if stats.asn_org %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Organization:</span>
|
|
||||||
<span class="stat-value-sm">{{ stats.asn_org | e }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if stats.asn %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">ASN:</span>
|
|
||||||
<span class="stat-value-sm">AS{{ stats.asn }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if stats.reverse_dns %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Reverse DNS:</span>
|
|
||||||
<span class="stat-value-sm" style="word-break: break-all;">{{ stats.reverse_dns | e }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ip-info-section">
|
|
||||||
<h3>Reputation</h3>
|
|
||||||
{% set flags = [] %}
|
|
||||||
{% if stats.is_proxy %}{% set _ = flags.append('Proxy') %}{% endif %}
|
|
||||||
{% if stats.is_hosting %}{% set _ = flags.append('Hosting') %}{% endif %}
|
|
||||||
{% if flags %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Flags:</span>
|
|
||||||
<span class="stat-value-sm">
|
|
||||||
{% for flag in flags %}
|
|
||||||
<span class="ip-flag">{{ flag }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if stats.reputation_score is not none %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Score:</span>
|
|
||||||
<span class="stat-value-sm reputation-score {% if stats.reputation_score <= 30 %}bad{% elif stats.reputation_score <= 60 %}medium{% else %}good{% endif %}">
|
|
||||||
{{ stats.reputation_score }}/100
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if stats.blocklist_memberships %}
|
|
||||||
<div class="stat-row" style="flex-direction: column; align-items: flex-start; gap: 6px;">
|
|
||||||
<span class="stat-label-sm">Listed On:</span>
|
|
||||||
<div class="blocklist-badges">
|
|
||||||
{% for bl in stats.blocklist_memberships %}
|
|
||||||
<span class="reputation-badge">{{ bl | e }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Blocklists:</span>
|
|
||||||
<span class="reputation-clean">Clean</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Single IP Map #}
|
|
||||||
<div class="table-container">
|
|
||||||
<h2>Location</h2>
|
|
||||||
<div id="single-ip-map" style="height: 300px; border-radius: 6px; border: 1px solid #30363d;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Right column: Radar Chart + Timeline #}
|
|
||||||
<div class="ip-page-right">
|
|
||||||
{# Category Analysis Card #}
|
|
||||||
{% if stats.category_scores %}
|
|
||||||
<div class="table-container">
|
|
||||||
<h2>Category Analysis</h2>
|
|
||||||
<div class="radar-chart-container">
|
|
||||||
<div class="radar-chart" id="ip-radar-chart"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Behavior Timeline #}
|
|
||||||
{% if stats.category_history %}
|
|
||||||
<div class="table-container">
|
|
||||||
<h2>Behavior Timeline</h2>
|
|
||||||
<div class="timeline ip-timeline-scroll">
|
|
||||||
{% for entry in stats.category_history %}
|
|
||||||
<div class="timeline-item">
|
|
||||||
<div class="timeline-marker {{ entry.new_category | default('unknown') | replace('_', '-') }}"></div>
|
|
||||||
<div>
|
|
||||||
<strong style="color: #e6edf3;">{{ entry.new_category | default('unknown') | replace('_', ' ') | title }}</strong>
|
|
||||||
{% if entry.old_category %}
|
|
||||||
<span style="color: #8b949e;"> from {{ entry.old_category | replace('_', ' ') | title }}</span>
|
|
||||||
{% endif %}
|
|
||||||
<br><span style="color: #8b949e; font-size: 11px;">{{ entry.timestamp | format_ts }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Access History table #}
|
|
||||||
<div class="table-container alert-section" style="margin-top: 20px;">
|
|
||||||
<h2>Access History</h2>
|
|
||||||
<div class="htmx-container"
|
|
||||||
hx-get="{{ dashboard_path }}/htmx/access-logs?page=1&ip_filter={{ ip_address }}"
|
|
||||||
hx-trigger="revealed"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<div class="htmx-indicator">Loading...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Raw Request Modal #}
|
{# Raw Request Modal #}
|
||||||
<div class="raw-request-modal" x-show="rawModal.show" @click.self="closeRawModal()" x-cloak>
|
<div class="raw-request-modal" x-show="rawModal.show" @click.self="closeRawModal()" x-cloak>
|
||||||
@@ -215,80 +36,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
// Initialize radar chart
|
|
||||||
{% if stats.category_scores %}
|
|
||||||
const scores = {{ stats.category_scores | tojson }};
|
|
||||||
const container = document.getElementById('ip-radar-chart');
|
|
||||||
if (container && typeof generateRadarChart === 'function') {
|
|
||||||
container.innerHTML = generateRadarChart(scores, 220, true, 'side');
|
|
||||||
}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
// Initialize single IP map
|
|
||||||
{% if stats.latitude and stats.longitude %}
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
setTimeout(function() {
|
|
||||||
const mapContainer = document.getElementById('single-ip-map');
|
|
||||||
if (!mapContainer || typeof L === 'undefined') return;
|
|
||||||
|
|
||||||
const lat = {{ stats.latitude }};
|
|
||||||
const lng = {{ stats.longitude }};
|
|
||||||
const category = '{{ stats.category | default("unknown") | lower }}';
|
|
||||||
|
|
||||||
const categoryColors = {
|
|
||||||
attacker: '#f85149',
|
|
||||||
bad_crawler: '#f0883e',
|
|
||||||
good_crawler: '#3fb950',
|
|
||||||
regular_user: '#58a6ff',
|
|
||||||
unknown: '#8b949e'
|
|
||||||
};
|
|
||||||
|
|
||||||
const map = L.map('single-ip-map', {
|
|
||||||
center: [lat, lng],
|
|
||||||
zoom: 6,
|
|
||||||
zoomControl: true,
|
|
||||||
scrollWheelZoom: true
|
|
||||||
});
|
|
||||||
|
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
||||||
attribution: '© CartoDB | © OpenStreetMap contributors',
|
|
||||||
maxZoom: 19,
|
|
||||||
subdomains: 'abcd'
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
const color = categoryColors[category] || '#8b949e';
|
|
||||||
const markerHtml = `
|
|
||||||
<div style="
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
background: ${color};
|
|
||||||
border: 3px solid #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 0 12px ${color}, 0 0 24px ${color}80;
|
|
||||||
"></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const icon = L.divIcon({
|
|
||||||
html: markerHtml,
|
|
||||||
iconSize: [24, 24],
|
|
||||||
className: 'single-ip-marker'
|
|
||||||
});
|
|
||||||
|
|
||||||
L.marker([lat, lng], { icon: icon }).addTo(map);
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
{% else %}
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const mapContainer = document.getElementById('single-ip-map');
|
|
||||||
if (mapContainer) {
|
|
||||||
mapContainer.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #8b949e;">Location data not available</div>';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
{% endif %}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
282
src/templates/jinja2/dashboard/partials/_ip_detail.html
Normal file
282
src/templates/jinja2/dashboard/partials/_ip_detail.html
Normal file
@@ -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 #}
|
||||||
|
<div class="ip-page-header">
|
||||||
|
<h1>
|
||||||
|
<span class="ip-address-title">{{ ip_address }}</span>
|
||||||
|
{% if stats.category %}
|
||||||
|
<span class="category-badge category-{{ stats.category | lower | replace('_', '-') }}">
|
||||||
|
{{ stats.category | replace('_', ' ') | title }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</h1>
|
||||||
|
{% if stats.city or stats.country %}
|
||||||
|
<p class="ip-location-subtitle">
|
||||||
|
{{ stats.city | default('') }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Two-column layout: Info + Radar/Timeline ───── #}
|
||||||
|
<div class="ip-page-grid">
|
||||||
|
{# Left column: single IP Information card #}
|
||||||
|
<div class="ip-page-left">
|
||||||
|
<div class="table-container ip-detail-card ip-info-card">
|
||||||
|
<h2>IP Information</h2>
|
||||||
|
|
||||||
|
{# Activity section #}
|
||||||
|
<h3 class="ip-section-heading">Activity</h3>
|
||||||
|
<dl class="ip-dl">
|
||||||
|
<div class="ip-dl-row">
|
||||||
|
<dt>Total Requests</dt>
|
||||||
|
<dd>{{ stats.total_requests | default('N/A') }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="ip-dl-row">
|
||||||
|
<dt>First Seen</dt>
|
||||||
|
<dd class="ip-dl-highlight">{{ stats.first_seen | format_ts }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="ip-dl-row">
|
||||||
|
<dt>Last Seen</dt>
|
||||||
|
<dd class="ip-dl-highlight">{{ stats.last_seen | format_ts }}</dd>
|
||||||
|
</div>
|
||||||
|
{% if stats.last_analysis %}
|
||||||
|
<div class="ip-dl-row">
|
||||||
|
<dt>Last Analysis</dt>
|
||||||
|
<dd class="ip-dl-highlight">{{ stats.last_analysis | format_ts }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{# Geo & Network section #}
|
||||||
|
<h3 class="ip-section-heading">Geo & Network</h3>
|
||||||
|
<dl class="ip-dl">
|
||||||
|
{% if stats.city or stats.country %}
|
||||||
|
<div class="ip-dl-row">
|
||||||
|
<dt>Location</dt>
|
||||||
|
<dd>{{ stats.city | default('') | e }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) | e }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if stats.region_name %}
|
||||||
|
<div class="ip-dl-row">
|
||||||
|
<dt>Region</dt>
|
||||||
|
<dd>{{ stats.region_name | e }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if stats.timezone %}
|
||||||
|
<div class="ip-dl-row">
|
||||||
|
<dt>Timezone</dt>
|
||||||
|
<dd>{{ stats.timezone | e }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if stats.isp %}
|
||||||
|
<div class="ip-dl-row">
|
||||||
|
<dt>ISP</dt>
|
||||||
|
<dd>{{ stats.isp | e }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if stats.asn_org %}
|
||||||
|
<div class="ip-dl-row">
|
||||||
|
<dt>Organization</dt>
|
||||||
|
<dd>{{ stats.asn_org | e }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if stats.asn %}
|
||||||
|
<div class="ip-dl-row">
|
||||||
|
<dt>ASN</dt>
|
||||||
|
<dd>AS{{ stats.asn }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if stats.reverse_dns %}
|
||||||
|
<div class="ip-dl-row">
|
||||||
|
<dt>Reverse DNS</dt>
|
||||||
|
<dd class="ip-dl-mono">{{ stats.reverse_dns | e }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{# Reputation section #}
|
||||||
|
<h3 class="ip-section-heading">Reputation</h3>
|
||||||
|
<div class="ip-rep-scroll">
|
||||||
|
{# Flags #}
|
||||||
|
{% set flags = [] %}
|
||||||
|
{% if stats.is_proxy %}{% set _ = flags.append('Proxy') %}{% endif %}
|
||||||
|
{% if stats.is_hosting %}{% set _ = flags.append('Hosting') %}{% endif %}
|
||||||
|
{% if flags %}
|
||||||
|
<div class="ip-rep-row">
|
||||||
|
<span class="ip-rep-label">Flags</span>
|
||||||
|
<div class="ip-rep-tags">
|
||||||
|
{% for flag in flags %}
|
||||||
|
<span class="ip-flag">{{ flag }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Blocklists #}
|
||||||
|
<div class="ip-rep-row">
|
||||||
|
<span class="ip-rep-label">Listed On</span>
|
||||||
|
{% if stats.blocklist_memberships %}
|
||||||
|
<div class="ip-rep-tags">
|
||||||
|
{% for bl in stats.blocklist_memberships %}
|
||||||
|
<span class="reputation-badge">{{ bl | e }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="reputation-clean">Clean</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Right column: Category Analysis + Timeline + Attack Types #}
|
||||||
|
<div class="ip-page-right">
|
||||||
|
{% if stats.category_scores %}
|
||||||
|
<div class="table-container ip-detail-card">
|
||||||
|
<h2>Category Analysis</h2>
|
||||||
|
<div class="radar-chart-container">
|
||||||
|
<div class="radar-chart" id="{{ uid }}-radar-chart"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Bottom row: Behavior Timeline + Attack Types side by side #}
|
||||||
|
<div class="ip-bottom-row">
|
||||||
|
{% if stats.category_history %}
|
||||||
|
<div class="table-container ip-detail-card ip-timeline-card">
|
||||||
|
<h2>Behavior Timeline</h2>
|
||||||
|
<div class="ip-timeline-scroll">
|
||||||
|
<div class="ip-timeline-hz">
|
||||||
|
{% for entry in stats.category_history %}
|
||||||
|
<div class="ip-tl-entry">
|
||||||
|
<div class="ip-tl-dot {{ entry.new_category | default('unknown') | replace('_', '-') }}"></div>
|
||||||
|
<div class="ip-tl-content">
|
||||||
|
<span class="ip-tl-cat">{{ entry.new_category | default('unknown') | replace('_', ' ') | title }}</span>
|
||||||
|
{% if entry.old_category %}
|
||||||
|
<span class="ip-tl-from">from {{ entry.old_category | replace('_', ' ') | title }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="ip-tl-from">initial classification</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="ip-tl-time">{{ entry.timestamp | format_ts }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="table-container ip-detail-card ip-attack-types-card">
|
||||||
|
<h2>Attack Types</h2>
|
||||||
|
<div class="ip-attack-chart-wrapper">
|
||||||
|
<canvas id="{{ uid }}-attack-types-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Location map #}
|
||||||
|
{% if stats.latitude and stats.longitude %}
|
||||||
|
<div class="table-container" style="margin-top: 20px;">
|
||||||
|
<h2>Location</h2>
|
||||||
|
<div id="{{ uid }}-ip-map" style="height: 300px; border-radius: 6px; border: 1px solid #30363d;"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Access History table #}
|
||||||
|
<div class="table-container alert-section" style="margin-top: 20px;">
|
||||||
|
<h2>Access History</h2>
|
||||||
|
<div class="htmx-container"
|
||||||
|
hx-get="{{ dashboard_path }}/htmx/access-logs?page=1&ip_filter={{ ip_address }}"
|
||||||
|
hx-trigger="revealed"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="htmx-indicator">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Inline init script #}
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var UID = '{{ uid }}';
|
||||||
|
|
||||||
|
// Radar chart
|
||||||
|
{% if stats.category_scores %}
|
||||||
|
var scores = {{ stats.category_scores | tojson }};
|
||||||
|
var radarEl = document.getElementById(UID + '-radar-chart');
|
||||||
|
if (radarEl && typeof generateRadarChart === 'function') {
|
||||||
|
radarEl.innerHTML = generateRadarChart(scores, 280, true, 'side');
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
// Attack types chart
|
||||||
|
function initAttackChart() {
|
||||||
|
if (typeof loadAttackTypesChart === 'function') {
|
||||||
|
loadAttackTypesChart(UID + '-attack-types-chart', '{{ ip_address }}', 'bottom');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof Chart !== 'undefined') {
|
||||||
|
initAttackChart();
|
||||||
|
} else {
|
||||||
|
document.addEventListener('DOMContentLoaded', initAttackChart);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location map
|
||||||
|
{% if stats.latitude and stats.longitude %}
|
||||||
|
function initMap() {
|
||||||
|
var mapContainer = document.getElementById(UID + '-ip-map');
|
||||||
|
if (!mapContainer || typeof L === 'undefined') return;
|
||||||
|
|
||||||
|
if (mapContainer._leaflet_id) {
|
||||||
|
mapContainer._leaflet_id = null;
|
||||||
|
}
|
||||||
|
mapContainer.innerHTML = '';
|
||||||
|
|
||||||
|
var lat = {{ stats.latitude }};
|
||||||
|
var lng = {{ stats.longitude }};
|
||||||
|
var category = '{{ stats.category | default("unknown") | lower }}';
|
||||||
|
|
||||||
|
var categoryColors = {
|
||||||
|
attacker: '#f85149',
|
||||||
|
bad_crawler: '#f0883e',
|
||||||
|
good_crawler: '#3fb950',
|
||||||
|
regular_user: '#58a6ff',
|
||||||
|
unknown: '#8b949e'
|
||||||
|
};
|
||||||
|
|
||||||
|
var map = L.map(UID + '-ip-map', {
|
||||||
|
center: [lat, lng],
|
||||||
|
zoom: 6,
|
||||||
|
zoomControl: true,
|
||||||
|
scrollWheelZoom: true
|
||||||
|
});
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
attribution: '© CartoDB | © OpenStreetMap contributors',
|
||||||
|
maxZoom: 19,
|
||||||
|
subdomains: 'abcd'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
var color = categoryColors[category] || '#8b949e';
|
||||||
|
var markerHtml = '<div style="width:24px;height:24px;background:' + color +
|
||||||
|
';border:3px solid #fff;border-radius:50%;box-shadow:0 0 12px ' + color +
|
||||||
|
',0 0 24px ' + color + '80;"></div>';
|
||||||
|
|
||||||
|
var icon = L.divIcon({
|
||||||
|
html: markerHtml,
|
||||||
|
iconSize: [24, 24],
|
||||||
|
className: 'single-ip-marker'
|
||||||
|
});
|
||||||
|
|
||||||
|
L.marker([lat, lng], { icon: icon }).addTo(map);
|
||||||
|
}
|
||||||
|
setTimeout(initMap, 100);
|
||||||
|
{% else %}
|
||||||
|
var mapContainer = document.getElementById(UID + '-ip-map');
|
||||||
|
if (mapContainer) {
|
||||||
|
mapContainer.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#8b949e;">Location data not available</div>';
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -1,263 +1,5 @@
|
|||||||
{# HTMX fragment: IP Insight - full IP detail view for inline display #}
|
{# HTMX fragment: IP Insight - inline display within dashboard tabs #}
|
||||||
<div class="ip-insight-content" id="ip-insight-content">
|
<div class="ip-insight-content" id="ip-insight-content">
|
||||||
{# Page header #}
|
{% set uid = "insight" %}
|
||||||
<div class="ip-page-header">
|
{% include "dashboard/partials/_ip_detail.html" %}
|
||||||
<h1>
|
|
||||||
<span class="ip-address-title">{{ ip_address }}</span>
|
|
||||||
{% if stats.category %}
|
|
||||||
<span class="category-badge category-{{ stats.category | lower | replace('_', '-') }}">
|
|
||||||
{{ stats.category | replace('_', ' ') | title }}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</h1>
|
|
||||||
{% if stats.city or stats.country %}
|
|
||||||
<p class="ip-location-subtitle">
|
|
||||||
{{ stats.city | default('') }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Main content grid #}
|
|
||||||
<div class="ip-page-grid">
|
|
||||||
{# Left column: IP Info + Map #}
|
|
||||||
<div class="ip-page-left">
|
|
||||||
{# IP Information Card #}
|
|
||||||
<div class="table-container ip-info-card">
|
|
||||||
<h2>IP Information</h2>
|
|
||||||
<div class="ip-info-grid">
|
|
||||||
<div class="ip-info-section">
|
|
||||||
<h3>Activity</h3>
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Total Requests:</span>
|
|
||||||
<span class="stat-value-sm">{{ stats.total_requests | default('N/A') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">First Seen:</span>
|
|
||||||
<span class="stat-value-sm">{{ stats.first_seen | format_ts }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Last Seen:</span>
|
|
||||||
<span class="stat-value-sm">{{ stats.last_seen | format_ts }}</span>
|
|
||||||
</div>
|
|
||||||
{% if stats.last_analysis %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Last Analysis:</span>
|
|
||||||
<span class="stat-value-sm">{{ stats.last_analysis | format_ts }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ip-info-section">
|
|
||||||
<h3>Geo & Network</h3>
|
|
||||||
{% if stats.city or stats.country %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Location:</span>
|
|
||||||
<span class="stat-value-sm">{{ stats.city | default('') | e }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) | e }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if stats.timezone %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Timezone:</span>
|
|
||||||
<span class="stat-value-sm">{{ stats.timezone | e }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if stats.isp %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">ISP:</span>
|
|
||||||
<span class="stat-value-sm">{{ stats.isp | e }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if stats.asn_org %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Organization:</span>
|
|
||||||
<span class="stat-value-sm">{{ stats.asn_org | e }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if stats.asn %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">ASN:</span>
|
|
||||||
<span class="stat-value-sm">AS{{ stats.asn }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if stats.reverse_dns %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Reverse DNS:</span>
|
|
||||||
<span class="stat-value-sm" style="word-break: break-all;">{{ stats.reverse_dns | e }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ip-info-section">
|
|
||||||
<h3>Reputation</h3>
|
|
||||||
{% set flags = [] %}
|
|
||||||
{% if stats.is_proxy %}{% set _ = flags.append('Proxy') %}{% endif %}
|
|
||||||
{% if stats.is_hosting %}{% set _ = flags.append('Hosting') %}{% endif %}
|
|
||||||
{% if flags %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Flags:</span>
|
|
||||||
<span class="stat-value-sm">
|
|
||||||
{% for flag in flags %}
|
|
||||||
<span class="ip-flag">{{ flag }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if stats.reputation_score is not none %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Score:</span>
|
|
||||||
<span class="stat-value-sm reputation-score {% if stats.reputation_score <= 30 %}bad{% elif stats.reputation_score <= 60 %}medium{% else %}good{% endif %}">
|
|
||||||
{{ stats.reputation_score }}/100
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if stats.blocklist_memberships %}
|
|
||||||
<div class="stat-row" style="flex-direction: column; align-items: flex-start; gap: 6px;">
|
|
||||||
<span class="stat-label-sm">Listed On:</span>
|
|
||||||
<div class="blocklist-badges">
|
|
||||||
{% for bl in stats.blocklist_memberships %}
|
|
||||||
<span class="reputation-badge">{{ bl | e }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label-sm">Blocklists:</span>
|
|
||||||
<span class="reputation-clean">Clean</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Right column: Radar Chart + Timeline #}
|
|
||||||
<div class="ip-page-right">
|
|
||||||
{# Category Analysis Card #}
|
|
||||||
{% if stats.category_scores %}
|
|
||||||
<div class="table-container">
|
|
||||||
<h2>Category Analysis</h2>
|
|
||||||
<div class="radar-chart-container">
|
|
||||||
<div class="radar-chart" id="insight-radar-chart"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Behavior Timeline #}
|
|
||||||
{% if stats.category_history %}
|
|
||||||
<div class="table-container">
|
|
||||||
<h2>Behavior Timeline</h2>
|
|
||||||
<div class="timeline ip-timeline-scroll">
|
|
||||||
{% for entry in stats.category_history %}
|
|
||||||
<div class="timeline-item">
|
|
||||||
<div class="timeline-marker {{ entry.new_category | default('unknown') | replace('_', '-') }}"></div>
|
|
||||||
<div>
|
|
||||||
<strong style="color: #e6edf3;">{{ entry.new_category | default('unknown') | replace('_', ' ') | title }}</strong>
|
|
||||||
{% if entry.old_category %}
|
|
||||||
<span style="color: #8b949e;"> from {{ entry.old_category | replace('_', ' ') | title }}</span>
|
|
||||||
{% endif %}
|
|
||||||
<br><span style="color: #8b949e; font-size: 11px;">{{ entry.timestamp | format_ts }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Single IP Map - full width #}
|
|
||||||
<div class="table-container" style="margin-top: 20px;">
|
|
||||||
<h2>Location</h2>
|
|
||||||
<div id="insight-ip-map" style="height: 300px; border-radius: 6px; border: 1px solid #30363d;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Access History table #}
|
|
||||||
<div class="table-container alert-section" style="margin-top: 20px;">
|
|
||||||
<h2>Access History</h2>
|
|
||||||
<div class="htmx-container"
|
|
||||||
hx-get="{{ dashboard_path }}/htmx/access-logs?page=1&ip_filter={{ ip_address }}"
|
|
||||||
hx-trigger="revealed"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<div class="htmx-indicator">Loading...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Inline script for initializing map and chart after HTMX swap #}
|
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
// Initialize radar chart
|
|
||||||
{% if stats.category_scores %}
|
|
||||||
const scores = {{ stats.category_scores | tojson }};
|
|
||||||
const container = document.getElementById('insight-radar-chart');
|
|
||||||
if (container && typeof generateRadarChart === 'function') {
|
|
||||||
container.innerHTML = generateRadarChart(scores, 220, true, 'side');
|
|
||||||
}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
// Initialize single IP map
|
|
||||||
{% if stats.latitude and stats.longitude %}
|
|
||||||
setTimeout(function() {
|
|
||||||
const mapContainer = document.getElementById('insight-ip-map');
|
|
||||||
if (!mapContainer || typeof L === 'undefined') return;
|
|
||||||
|
|
||||||
// Clean up any existing map instance
|
|
||||||
if (mapContainer._leaflet_id) {
|
|
||||||
mapContainer._leaflet_id = null;
|
|
||||||
}
|
|
||||||
mapContainer.innerHTML = '';
|
|
||||||
|
|
||||||
const lat = {{ stats.latitude }};
|
|
||||||
const lng = {{ stats.longitude }};
|
|
||||||
const category = '{{ stats.category | default("unknown") | lower }}';
|
|
||||||
|
|
||||||
const categoryColors = {
|
|
||||||
attacker: '#f85149',
|
|
||||||
bad_crawler: '#f0883e',
|
|
||||||
good_crawler: '#3fb950',
|
|
||||||
regular_user: '#58a6ff',
|
|
||||||
unknown: '#8b949e'
|
|
||||||
};
|
|
||||||
|
|
||||||
const map = L.map('insight-ip-map', {
|
|
||||||
center: [lat, lng],
|
|
||||||
zoom: 6,
|
|
||||||
zoomControl: true,
|
|
||||||
scrollWheelZoom: true
|
|
||||||
});
|
|
||||||
|
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
||||||
attribution: '© CartoDB | © OpenStreetMap contributors',
|
|
||||||
maxZoom: 19,
|
|
||||||
subdomains: 'abcd'
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
const color = categoryColors[category] || '#8b949e';
|
|
||||||
const markerHtml = `
|
|
||||||
<div style="
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
background: ${color};
|
|
||||||
border: 3px solid #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 0 12px ${color}, 0 0 24px ${color}80;
|
|
||||||
"></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const icon = L.divIcon({
|
|
||||||
html: markerHtml,
|
|
||||||
iconSize: [24, 24],
|
|
||||||
className: 'single-ip-marker'
|
|
||||||
});
|
|
||||||
|
|
||||||
L.marker([lat, lng], { icon: icon }).addTo(map);
|
|
||||||
}, 100);
|
|
||||||
{% else %}
|
|
||||||
const mapContainer = document.getElementById('insight-ip-map');
|
|
||||||
if (mapContainer) {
|
|
||||||
mapContainer.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #8b949e;">Location data not available</div>';
|
|
||||||
}
|
|
||||||
{% endif %}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -269,8 +269,8 @@ tbody {
|
|||||||
}
|
}
|
||||||
.radar-chart {
|
.radar-chart {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 220px;
|
width: 280px;
|
||||||
height: 220px;
|
height: 280px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.radar-legend {
|
.radar-legend {
|
||||||
@@ -452,7 +452,7 @@ tbody {
|
|||||||
animation: fadeIn 0.3s ease-in;
|
animation: fadeIn 0.3s ease-in;
|
||||||
}
|
}
|
||||||
.ip-page-header {
|
.ip-page-header {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
.ip-page-header h1 {
|
.ip-page-header h1 {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -471,61 +471,214 @@ tbody {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin: 4px 0 0 0;
|
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 {
|
.ip-page-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 3fr 2fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
align-items: start;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.ip-page-left,
|
.ip-page-left,
|
||||||
.ip-page-right {
|
.ip-page-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
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-top: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.ip-info-grid {
|
/* Remove bottom margin inside grid columns (gap handles spacing) */
|
||||||
display: grid;
|
.ip-page-left .table-container,
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
.ip-page-right .table-container {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Definition list for IP info */
|
||||||
|
.ip-dl {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
.ip-info-section {
|
.ip-dl-row {
|
||||||
padding: 14px 16px;
|
display: flex;
|
||||||
border-right: 1px solid #21262d;
|
justify-content: space-between;
|
||||||
}
|
align-items: baseline;
|
||||||
.ip-info-section:last-child {
|
padding: 8px 0;
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
.ip-info-section h3 {
|
|
||||||
color: #58a6ff;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 10px 0;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
border-bottom: 1px solid #21262d;
|
border-bottom: 1px solid #21262d;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
.ip-info-section .stat-row {
|
.ip-dl-row:last-child {
|
||||||
padding: 3px 0;
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.ip-dl dt {
|
||||||
|
color: #8b949e;
|
||||||
font-size: 13px;
|
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;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 5px;
|
gap: 6px;
|
||||||
max-height: 120px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Flags & badges */
|
||||||
.ip-flag {
|
.ip-flag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: #1c2128;
|
background: #1c2128;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #f0883e4d;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 2px 8px;
|
padding: 3px 10px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: #f0883e;
|
color: #f0883e;
|
||||||
margin-right: 4px;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.reputation-score {
|
.reputation-score {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -533,29 +686,130 @@ tbody {
|
|||||||
.reputation-score.bad { color: #f85149; }
|
.reputation-score.bad { color: #f85149; }
|
||||||
.reputation-score.medium { color: #f0883e; }
|
.reputation-score.medium { color: #f0883e; }
|
||||||
.reputation-score.good { color: #3fb950; }
|
.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 {
|
.radar-chart-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
}
|
}
|
||||||
.ip-timeline-scroll {
|
|
||||||
max-height: 280px;
|
/* ── Behavior Timeline (full-width horizontal) ──── */
|
||||||
overflow-y: auto;
|
.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) {
|
@media (max-width: 900px) {
|
||||||
.ip-page-grid {
|
.ip-page-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.ip-info-grid {
|
.ip-stats-bar {
|
||||||
grid-template-columns: 1fr;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.ip-info-section {
|
.ip-stat-chip {
|
||||||
border-right: none;
|
flex: 1 1 auto;
|
||||||
border-bottom: 1px solid #21262d;
|
|
||||||
}
|
}
|
||||||
.ip-info-section:last-child {
|
.ip-bottom-row {
|
||||||
border-bottom: none;
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.ip-tl-content {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.ip-tl-time {
|
||||||
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,25 @@
|
|||||||
let attackTypesChart = null;
|
let attackTypesChart = null;
|
||||||
let attackTypesChartLoaded = false;
|
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__ || '';
|
const DASHBOARD_PATH = window.__DASHBOARD_PATH__ || '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const canvas = document.getElementById('attack-types-chart');
|
const canvas = document.getElementById(canvasId);
|
||||||
if (!canvas) return;
|
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',
|
cache: 'no-store',
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
@@ -25,7 +36,7 @@ async function loadAttackTypesChart() {
|
|||||||
const attackTypes = data.attack_types || [];
|
const attackTypes = data.attack_types || [];
|
||||||
|
|
||||||
if (attackTypes.length === 0) {
|
if (attackTypes.length === 0) {
|
||||||
canvas.style.display = 'none';
|
canvas.parentElement.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#8b949e;font-size:13px;">No attack data</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,13 +74,14 @@ async function loadAttackTypesChart() {
|
|||||||
const borderColors = labels.map(label => generateColorFromHash(label).border);
|
const borderColors = labels.map(label => generateColorFromHash(label).border);
|
||||||
const hoverColors = labels.map(label => generateColorFromHash(label).hover);
|
const hoverColors = labels.map(label => generateColorFromHash(label).hover);
|
||||||
|
|
||||||
// Create or update chart
|
// Create or update chart (track per canvas)
|
||||||
if (attackTypesChart) {
|
if (!loadAttackTypesChart._instances) loadAttackTypesChart._instances = {};
|
||||||
attackTypesChart.destroy();
|
if (loadAttackTypesChart._instances[canvasId]) {
|
||||||
|
loadAttackTypesChart._instances[canvasId].destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
attackTypesChart = new Chart(ctx, {
|
const chartInstance = new Chart(ctx, {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
data: {
|
data: {
|
||||||
labels: labels,
|
labels: labels,
|
||||||
@@ -88,7 +100,7 @@ async function loadAttackTypesChart() {
|
|||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: 'right',
|
position: legendPosition,
|
||||||
labels: {
|
labels: {
|
||||||
color: '#c9d1d9',
|
color: '#c9d1d9',
|
||||||
font: {
|
font: {
|
||||||
@@ -160,6 +172,8 @@ async function loadAttackTypesChart() {
|
|||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
loadAttackTypesChart._instances[canvasId] = chartInstance;
|
||||||
|
attackTypesChart = chartInstance;
|
||||||
attackTypesChartLoaded = true;
|
attackTypesChartLoaded = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading attack types chart:', err);
|
console.error('Error loading attack types chart:', err);
|
||||||
|
|||||||
Reference in New Issue
Block a user