feat: add IP insight feature with detailed view and actions

- Updated various tables to include "Actions" column with inspect buttons for IP insights.
- Created a new IP insight template for displaying detailed information about an IP address.
- Implemented JavaScript functions to handle opening the IP insight view and loading data via HTMX.
- Enhanced map markers to include inspect buttons for quick access to IP insights.
- Added styles for the new IP insight page and buttons to maintain UI consistency.
This commit is contained in:
Lorenzo Venerandi
2026-02-28 17:43:50 +01:00
parent ce713d8072
commit d9ae55c0aa
16 changed files with 1070 additions and 23 deletions

View File

@@ -1084,6 +1084,8 @@ class DatabaseManager:
"region": stat.region,
"region_name": stat.region_name,
"timezone": stat.timezone,
"latitude": stat.latitude,
"longitude": stat.longitude,
"isp": stat.isp,
"reverse": stat.reverse,
"asn": stat.asn,

View File

@@ -50,6 +50,10 @@ async def ip_page(ip_address: str, request: Request):
dashboard_path = "/" + config.dashboard_secret_path.lstrip("/")
if stats:
# Transform fields for template compatibility
list_on = stats.get("list_on") or {}
stats["blocklist_memberships"] = list(list_on.keys()) if list_on else []
stats["reverse_dns"] = stats.get("reverse")
templates = get_templates()
return templates.TemplateResponse(

View File

@@ -58,7 +58,7 @@ async def htmx_top_ips(
):
db = get_db()
result = db.get_top_ips_paginated(
page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order
page=max(1, page), page_size=8, sort_by=sort_by, sort_order=sort_order
)
templates = get_templates()
@@ -316,6 +316,34 @@ async def htmx_patterns(
)
# ── IP Insight (full IP page as partial) ─────────────────────────────
@router.get("/htmx/ip-insight/{ip_address:path}")
async def htmx_ip_insight(ip_address: str, request: Request):
db = get_db()
stats = db.get_ip_stats_by_ip(ip_address)
if not stats:
stats = {"ip": ip_address, "total_requests": "N/A"}
# Transform fields for template compatibility
list_on = stats.get("list_on") or {}
stats["blocklist_memberships"] = list(list_on.keys()) if list_on else []
stats["reverse_dns"] = stats.get("reverse")
templates = get_templates()
return templates.TemplateResponse(
"dashboard/partials/ip_insight.html",
{
"request": request,
"dashboard_path": _dashboard_path(request),
"stats": stats,
"ip_address": ip_address,
},
)
# ── IP Detail ────────────────────────────────────────────────────────

View File

@@ -35,6 +35,9 @@
<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 === 'attacks' }" @click.prevent="switchToAttacks()" href="#ip-stats">Attacks</a>
<a class="tab-button" :class="{ active: tab === 'ip-insight', disabled: !insightIp }" @click.prevent="insightIp && switchToIpInsight()" href="#ip-insight">
IP Insight<span x-show="insightIp" x-text="' (' + insightIp + ')'"></span>
</a>
</div>
{# ==================== OVERVIEW TAB ==================== #}
@@ -147,6 +150,19 @@
</div>
</div>
{# ==================== IP INSIGHT TAB ==================== #}
<div x-show="tab === 'ip-insight'" x-cloak>
{# IP Insight content - loaded via HTMX when IP is selected #}
<div id="ip-insight-container">
<template x-if="!insightIp">
<div class="table-container" style="text-align: center; padding: 60px 20px;">
<p style="color: #8b949e; font-size: 16px;">Select an IP address from any table to view detailed insights.</p>
</div>
</template>
<div x-show="insightIp" id="ip-insight-htmx-container"></div>
</div>
</div>
{# Raw request modal - Alpine.js #}
{% include "dashboard/partials/raw_request_modal.html" %}

View File

@@ -9,22 +9,302 @@
<span class="github-logo-text">Krawl</span>
</a>
<h1>Krawl {{ ip_address }} analysis </h1>
{# Back to dashboard link #}
<div style="position: absolute; top: 0; right: 0;">
<a href="{{ dashboard_path }}/" class="download-btn" style="text-decoration: none;">
&larr; Back to Dashboard
</a>
</div>
{# 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>
{% include "dashboard/partials/map_section.html" %}
{% include "dashboard/partials/ip_detail.html" %}
{# 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>
{# Attack Types table #}
<div class="table-container alert-section">
<h2>{{ip_address}} Access History</h2>
<div class="ip-info-section">
<h3>Location</h3>
{% if stats.city %}
<div class="stat-row">
<span class="stat-label-sm">City:</span>
<span class="stat-value-sm">{{ stats.city | e }}</span>
</div>
{% endif %}
{% if stats.region_name %}
<div class="stat-row">
<span class="stat-label-sm">Region:</span>
<span class="stat-value-sm">{{ stats.region_name | e }}</span>
</div>
{% endif %}
{% if stats.country %}
<div class="stat-row">
<span class="stat-label-sm">Country:</span>
<span class="stat-value-sm">{{ stats.country | e }} ({{ 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 %}
</div>
<div class="ip-info-section">
<h3>Network</h3>
{% 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>Flags & 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">Reputation:</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: 8px;">
<span class="stat-label-sm">Listed On:</span>
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
{% 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">
{% 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-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 #}
<div class="raw-request-modal" x-show="rawModal.show" @click.self="closeRawModal()" x-cloak>
<div class="raw-request-modal-content">
<div class="raw-request-modal-header">
<h3>Raw Request</h3>
<span class="raw-request-modal-close" @click="closeRawModal()">&times;</span>
</div>
<div class="raw-request-modal-body">
<pre class="raw-request-content" x-text="rawModal.content"></pre>
</div>
<div class="raw-request-modal-footer">
<button class="raw-request-download-btn" @click="downloadRawRequest()">Download</button>
</div>
</div>
</div>
</div>
{% 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);
}
{% 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: '&copy; CartoDB | &copy; 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 %}

View File

@@ -44,8 +44,8 @@
<td>{{ (log.user_agent | default(''))[:50] | e }}</td>
<td>{{ log.timestamp | format_ts }}</td>
<td>
{% if log.log_id %}
<button class="view-btn" @click="viewRawRequest({{ log.log_id }})">View Request</button>
{% if log.id %}
<button class="view-btn" @click="viewRawRequest({{ log.id }})">View Request</button>
{% endif %}
</td>
</tr>

View File

@@ -60,10 +60,13 @@
</td>
<td>{{ (attack.user_agent | default(''))[:50] | e }}</td>
<td>{{ attack.timestamp | format_ts }}</td>
<td>
<td style="display: flex; gap: 6px; flex-wrap: wrap;">
{% if attack.log_id %}
<button class="view-btn" @click="viewRawRequest({{ attack.log_id }})">View Request</button>
{% endif %}
<button class="inspect-btn" @click="openIpInsight('{{ attack.ip | e }}')" title="Inspect IP">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
</button>
</td>
</tr>
<tr class="ip-stats-row" style="display: none;">

View File

@@ -28,6 +28,7 @@
<th>First Seen</th>
<th>Last Seen</th>
<th>Location</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@@ -45,16 +46,21 @@
<td>{{ ip.first_seen | format_ts }}</td>
<td>{{ ip.last_seen | format_ts }}</td>
<td>{{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }}</td>
<td>
<button class="inspect-btn" @click="openIpInsight('{{ ip.ip | e }}')" title="Inspect IP">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
</button>
</td>
</tr>
<tr class="ip-stats-row" style="display: none;">
<td colspan="6" class="ip-stats-cell">
<td colspan="7" class="ip-stats-cell">
<div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="6" style="text-align: center;">No attackers found</td></tr>
<tr><td colspan="7" style="text-align: center;">No attackers found</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -28,6 +28,7 @@
hx-swap="innerHTML">
Time
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@@ -45,16 +46,21 @@
<td>{{ cred.password | default('N/A') | e }}</td>
<td>{{ cred.path | default('') | e }}</td>
<td>{{ cred.timestamp | format_ts }}</td>
<td>
<button class="inspect-btn" @click="openIpInsight('{{ cred.ip | e }}')" title="Inspect IP">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
</button>
</td>
</tr>
<tr class="ip-stats-row" style="display: none;">
<td colspan="6" class="ip-stats-cell">
<td colspan="7" class="ip-stats-cell">
<div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="6" style="text-align: center;">No credentials captured</td></tr>
<tr><td colspan="7" style="text-align: center;">No credentials captured</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -25,6 +25,7 @@
hx-swap="innerHTML">
Honeypot Triggers
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@@ -39,16 +40,21 @@
{{ item.ip | e }}
</td>
<td>{{ item.count }}</td>
<td>
<button class="inspect-btn" @click="openIpInsight('{{ item.ip | e }}')" title="Inspect IP">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
</button>
</td>
</tr>
<tr class="ip-stats-row" style="display: none;">
<td colspan="3" class="ip-stats-cell">
<td colspan="4" class="ip-stats-cell">
<div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="3" style="text-align: center;">No data</td></tr>
<tr><td colspan="4" style="text-align: center;">No data</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -0,0 +1,279 @@
{# HTMX fragment: IP Insight - full IP detail view for inline display #}
<div class="ip-insight-content" id="ip-insight-content">
{# 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>
{# 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>Location</h3>
{% if stats.city %}
<div class="stat-row">
<span class="stat-label-sm">City:</span>
<span class="stat-value-sm">{{ stats.city | e }}</span>
</div>
{% endif %}
{% if stats.region_name %}
<div class="stat-row">
<span class="stat-label-sm">Region:</span>
<span class="stat-value-sm">{{ stats.region_name | e }}</span>
</div>
{% endif %}
{% if stats.country %}
<div class="stat-row">
<span class="stat-label-sm">Country:</span>
<span class="stat-value-sm">{{ stats.country | e }} ({{ 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 %}
</div>
<div class="ip-info-section">
<h3>Network</h3>
{% 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>Flags & 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">Reputation:</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: 8px;">
<span class="stat-label-sm">Listed On:</span>
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
{% 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">
{% 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>
{# 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);
}
{% 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: '&copy; CartoDB | &copy; 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>

View File

@@ -8,6 +8,7 @@
<th>Path</th>
<th>User-Agent</th>
<th>Time</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@@ -23,16 +24,21 @@
<td>{{ activity.path | e }}</td>
<td style="word-break: break-all;">{{ (activity.user_agent | default(''))[:80] | e }}</td>
<td>{{ activity.timestamp | format_ts(time_only=True) }}</td>
<td>
<button class="inspect-btn" @click="openIpInsight('{{ activity.ip | e }}')" title="Inspect IP">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
</button>
</td>
</tr>
<tr class="ip-stats-row" style="display: none;">
<td colspan="4" class="ip-stats-cell">
<td colspan="5" class="ip-stats-cell">
<div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="4" style="text-align:center;">No suspicious activity detected</td></tr>
<tr><td colspan="5" style="text-align:center;">No suspicious activity detected</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -25,6 +25,7 @@
hx-swap="innerHTML">
Access Count
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@@ -39,16 +40,21 @@
{{ item.ip | e }}
</td>
<td>{{ item.count }}</td>
<td>
<button class="inspect-btn" @click="openIpInsight('{{ item.ip | e }}')" title="Inspect IP">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
</button>
</td>
</tr>
<tr class="ip-stats-row" style="display: none;">
<td colspan="3" class="ip-stats-cell">
<td colspan="4" class="ip-stats-cell">
<div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="3" style="text-align: center;">No data</td></tr>
<tr><td colspan="4" style="text-align: center;">No data</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -474,6 +474,15 @@ tbody {
color: #58a6ff;
border-bottom-color: #58a6ff;
}
.tab-button.disabled {
color: #484f58;
cursor: not-allowed;
opacity: 0.6;
}
.tab-button.disabled:hover {
color: #484f58;
background: transparent;
}
.tab-content {
display: none;
}
@@ -1253,3 +1262,340 @@ tbody {
[x-cloak] {
display: none !important;
}
/* ========================================
Single IP Page Styles
======================================== */
.ip-page-header {
text-align: center;
margin-bottom: 30px;
padding-top: 20px;
}
.ip-page-header h1 {
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 {
color: #8b949e;
font-size: 16px;
margin: 0;
}
.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%;
max-width: 500px;
}
.ip-lookup-input {
flex: 1;
padding: 12px 16px;
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 {
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;
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;
color: #58a6ff;
border: 1px solid #30363d;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
white-space: nowrap;
}
.inspect-btn:hover {
background: #30363d;
border-color: #58a6ff;
color: #79c0ff;
}
.inspect-btn svg {
width: 12px;
height: 12px;
fill: currentColor;
}

View File

@@ -17,19 +17,26 @@ document.addEventListener('alpine:init', () => {
// Chart state
chartLoaded: false,
// IP Insight state
insightIp: null,
init() {
// Handle hash-based tab routing
const hash = window.location.hash.slice(1);
if (hash === 'ip-stats' || hash === 'attacks') {
this.switchToAttacks();
}
// ip-insight tab is only accessible via lens buttons, not direct hash navigation
window.addEventListener('hashchange', () => {
const h = window.location.hash.slice(1);
if (h === 'ip-stats' || h === 'attacks') {
this.switchToAttacks();
} else {
this.switchToOverview();
} else if (h !== 'ip-insight') {
// Don't switch away from ip-insight via hash if already there
if (this.tab !== 'ip-insight') {
this.switchToOverview();
}
}
});
},
@@ -60,6 +67,31 @@ document.addEventListener('alpine:init', () => {
window.location.hash = '#overview';
},
switchToIpInsight() {
// Only allow switching if an IP is selected
if (!this.insightIp) return;
this.tab = 'ip-insight';
window.location.hash = '#ip-insight';
},
openIpInsight(ip) {
// Set the IP and load the insight content
this.insightIp = ip;
this.tab = 'ip-insight';
window.location.hash = '#ip-insight';
// Load IP insight content via HTMX
this.$nextTick(() => {
const container = document.getElementById('ip-insight-htmx-container');
if (container && typeof htmx !== 'undefined') {
htmx.ajax('GET', `${this.dashboardPath}/htmx/ip-insight/${encodeURIComponent(ip)}`, {
target: '#ip-insight-htmx-container',
swap: 'innerHTML'
});
}
});
},
async viewRawRequest(logId) {
try {
const resp = await fetch(
@@ -110,6 +142,19 @@ document.addEventListener('alpine:init', () => {
}));
});
// Global function for opening IP Insight (used by map popups)
window.openIpInsight = function(ip) {
// Find the Alpine component and call openIpInsight
const container = document.querySelector('[x-data="dashboardApp()"]');
if (container) {
// Try Alpine 3.x API first, then fall back to older API
const data = Alpine.$data ? Alpine.$data(container) : (container._x_dataStack && container._x_dataStack[0]);
if (data && typeof data.openIpInsight === 'function') {
data.openIpInsight(ip);
}
}
};
// Utility function for formatting timestamps (used by map popups)
function formatTimestamp(isoTimestamp) {
if (!isoTimestamp) return 'N/A';

View File

@@ -340,6 +340,15 @@ function buildMapMarkers(ips) {
`;
}
// Add inspect button
popupContent += `
<div style="margin-top: 12px; border-top: 1px solid #30363d; padding-top: 12px; text-align: center;">
<button onclick="window.openIpInsight('${ip.ip}')" class="inspect-btn" style="display: inline-flex; align-items: center; gap: 4px; padding: 6px 12px; background: #21262d; color: #58a6ff; border: 1px solid #30363d; border-radius: 4px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.2s;">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
</button>
</div>
`;
popupContent += '</div>';
marker.setPopupContent(popupContent);
} catch (err) {
@@ -363,6 +372,11 @@ function buildMapMarkers(ips) {
<div style="margin-top: 12px; border-top: 1px solid #30363d; padding-top: 12px; text-align: center; color: #f85149; font-size: 11px;">
Failed to load chart: ${err.message}
</div>
<div style="margin-top: 12px; text-align: center;">
<button onclick="window.openIpInsight('${ip.ip}')" class="inspect-btn" style="display: inline-flex; align-items: center; gap: 4px; padding: 6px 12px; background: #21262d; color: #58a6ff; border: 1px solid #30363d; border-radius: 4px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.2s;">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
</button>
</div>
</div>
`;
marker.setPopupContent(errorPopup);