Merge pull request #107 from BlessedRebuS/feat/dashboard-single-ip-page

Feat/dashboard single ip page
This commit is contained in:
Patrick Di Fazio
2026-03-01 18:06:24 +01:00
committed by GitHub
54 changed files with 2053 additions and 245 deletions

View File

@@ -50,7 +50,7 @@ jobs:
run: safety check --json || true
- name: Trivy vulnerability scan
uses: aquasecurity/trivy-action@master
uses: aquasecurity/trivy-action@0.31.0
with:
scan-type: 'fs'
scan-ref: '.'

View File

@@ -33,6 +33,9 @@ backups:
exports:
path: "exports"
logging:
level: "DEBUG" # DEBUG, INFO, WARNING, ERROR, CRITICAL
database:
path: "data/krawl.db"
retention_days: 30

View File

@@ -2,8 +2,8 @@ apiVersion: v2
name: krawl-chart
description: A Helm chart for Krawl honeypot server
type: application
version: 1.0.9
appVersion: 1.0.9
version: 1.0.10
appVersion: 1.0.10
keywords:
- honeypot
- security

View File

@@ -28,6 +28,8 @@ data:
enabled: {{ .Values.config.backups.enabled }}
exports:
path: {{ .Values.config.exports.path | quote }}
logging:
level: {{ .Values.config.logging.level | quote }}
database:
path: {{ .Values.config.database.path | quote }}
retention_days: {{ .Values.config.database.retention_days }}

View File

@@ -8,6 +8,8 @@ spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
strategy:
type: Recreate
selector:
matchLabels:
{{- include "krawl.selectorLabels" . | nindent 6 }}
@@ -29,7 +31,7 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
- name: krawl
{{- with .Values.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}

View File

@@ -86,10 +86,12 @@ config:
secret_path: null # Auto-generated if not set, or set to "/my-secret-dashboard"
backups:
path: "backups"
enabled: true
enabled: false
cron: "*/30 * * * *"
exports:
path: "exports"
logging:
level: "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
database:
path: "data/krawl.db"
retention_days: 30

View File

@@ -68,6 +68,14 @@ data:
token_tries: 10
dashboard:
secret_path: null
backups:
path: "backups"
cron: "*/30 * * * *"
enabled: false
exports:
path: "exports"
logging:
level: "INFO"
database:
path: "data/krawl.db"
retention_days: 30
@@ -154,6 +162,8 @@ metadata:
app.kubernetes.io/version: "1.0.0"
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: krawl

View File

@@ -26,6 +26,14 @@ data:
token_tries: 10
dashboard:
secret_path: null
backups:
path: "backups"
cron: "*/30 * * * *"
enabled: false
exports:
path: "exports"
logging:
level: "INFO"
database:
path: "data/krawl.db"
retention_days: 30

View File

@@ -10,6 +10,8 @@ metadata:
app.kubernetes.io/version: "1.0.0"
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: krawl

View File

@@ -26,7 +26,7 @@ async def lifespan(app: FastAPI):
config = get_config()
# Initialize logging
initialize_logging()
initialize_logging(log_level=config.log_level)
app_logger = get_app_logger()
# Initialize database and run pending migrations before accepting traffic

View File

@@ -56,6 +56,8 @@ class Config:
user_agents_used_threshold: float = None
attack_urls_threshold: float = None
log_level: str = "INFO"
_server_ip: Optional[str] = None
_server_ip_cache_time: float = 0
_ip_cache_ttl: int = 300
@@ -163,6 +165,7 @@ class Config:
behavior = data.get("behavior", {})
analyzer = data.get("analyzer") or {}
crawl = data.get("crawl", {})
logging_cfg = data.get("logging", {})
# Handle dashboard_secret_path - auto-generate if null/not set
dashboard_path = dashboard.get("secret_path")
@@ -217,6 +220,9 @@ class Config:
),
max_pages_limit=crawl.get("max_pages_limit", 250),
ban_duration_seconds=crawl.get("ban_duration_seconds", 600),
log_level=os.getenv(
"KRAWL_LOG_LEVEL", logging_cfg.get("level", "INFO")
).upper(),
)

View File

@@ -815,8 +815,8 @@ class DatabaseManager:
def flag_stale_ips_for_reevaluation(self) -> int:
"""
Flag IPs for reevaluation where:
- last_seen is between 15 and 30 days ago
- last_analysis is more than 10 days ago (or never analyzed)
- last_seen is between 5 and 30 days ago
- last_analysis is more than 5 days ago
Returns:
Number of IPs flagged for reevaluation
@@ -825,18 +825,15 @@ class DatabaseManager:
try:
now = datetime.now()
last_seen_lower = now - timedelta(days=30)
last_seen_upper = now - timedelta(days=15)
last_analysis_cutoff = now - timedelta(days=10)
last_seen_upper = now - timedelta(days=5)
last_analysis_cutoff = now - timedelta(days=5)
count = (
session.query(IpStats)
.filter(
IpStats.last_seen >= last_seen_lower,
IpStats.last_seen <= last_seen_upper,
or_(
IpStats.last_analysis <= last_analysis_cutoff,
IpStats.last_analysis.is_(None),
),
IpStats.last_analysis <= last_analysis_cutoff,
IpStats.need_reevaluation == False,
IpStats.manual_category == False,
)
@@ -850,6 +847,99 @@ class DatabaseManager:
except Exception as e:
session.rollback()
raise
def flag_all_ips_for_reevaluation(self) -> int:
"""
Flag ALL IPs for reevaluation, regardless of staleness.
Skips IPs that have a manual category set.
Returns:
Number of IPs flagged for reevaluation
"""
session = self.session
try:
count = (
session.query(IpStats)
.filter(
IpStats.need_reevaluation == False,
IpStats.manual_category == False,
)
.update(
{IpStats.need_reevaluation: True},
synchronize_session=False,
)
)
session.commit()
return count
except Exception as e:
session.rollback()
raise
def get_access_logs_paginated(
self,
page: int = 1,
page_size: int = 25,
ip_filter: Optional[str] = None,
suspicious_only: bool = False,
since_minutes: Optional[int] = None,
) -> Dict[str, Any]:
"""
Retrieve access logs with pagination and optional filtering.
Args:
page: Page to retrieve
page_size: Number of records for page
ip_filter: Filter by IP address
suspicious_only: Only return suspicious requests
since_minutes: Only return logs from the last N minutes
Returns:
List of access log dictionaries
"""
session = self.session
try:
offset = (page - 1) * page_size
query = session.query(AccessLog).order_by(AccessLog.timestamp.desc())
if ip_filter:
query = query.filter(AccessLog.ip == sanitize_ip(ip_filter))
if suspicious_only:
query = query.filter(AccessLog.is_suspicious == True)
if since_minutes is not None:
cutoff_time = datetime.now() - timedelta(minutes=since_minutes)
query = query.filter(AccessLog.timestamp >= cutoff_time)
logs = query.offset(offset).limit(page_size).all()
# Get total count of attackers
total_access_logs = (
session.query(AccessLog)
.filter(AccessLog.ip == sanitize_ip(ip_filter))
.count()
)
total_pages = (total_access_logs + page_size - 1) // page_size
return {
"access_logs": [
{
"id": log.id,
"ip": log.ip,
"path": log.path,
"user_agent": log.user_agent,
"method": log.method,
"is_suspicious": log.is_suspicious,
"is_honeypot_trigger": log.is_honeypot_trigger,
"timestamp": log.timestamp.isoformat(),
"attack_types": [d.attack_type for d in log.attack_detections],
}
for log in logs
],
"pagination": {
"page": page,
"page_size": page_size,
"total_logs": total_access_logs,
"total_pages": total_pages,
},
}
finally:
self.close_session()
@@ -1018,6 +1108,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,
@@ -1316,26 +1408,16 @@ class DatabaseManager:
"""
session = self.session
try:
# Get server IP to filter it out
from config import get_config
config = get_config()
server_ip = config.get_server_ip()
results = (
session.query(AccessLog.ip, func.count(AccessLog.id).label("count"))
.group_by(AccessLog.ip)
.order_by(func.count(AccessLog.id).desc())
.all()
)
query = session.query(IpStats.ip, IpStats.total_requests)
query = self._public_ip_filter(query, IpStats.ip, server_ip)
results = query.order_by(IpStats.total_requests.desc()).limit(limit).all()
# Filter out local/private IPs and server IP, then limit results
filtered = [
(row.ip, row.count)
for row in results
if is_valid_public_ip(row.ip, server_ip)
]
return filtered[:limit]
return [(row.ip, row.total_requests) for row in results]
finally:
self.close_session()
@@ -1402,23 +1484,18 @@ class DatabaseManager:
"""
session = self.session
try:
# Get server IP to filter it out
from config import get_config
config = get_config()
server_ip = config.get_server_ip()
logs = (
query = (
session.query(AccessLog)
.filter(AccessLog.is_suspicious == True)
.order_by(AccessLog.timestamp.desc())
.all()
)
# Filter out local/private IPs and server IP
filtered_logs = [
log for log in logs if is_valid_public_ip(log.ip, server_ip)
]
query = self._public_ip_filter(query, AccessLog.ip, server_ip)
logs = query.limit(limit).all()
return [
{
@@ -1427,7 +1504,7 @@ class DatabaseManager:
"user_agent": log.user_agent,
"timestamp": log.timestamp.isoformat(),
}
for log in filtered_logs[:limit]
for log in logs
]
finally:
self.close_session()
@@ -1532,44 +1609,59 @@ class DatabaseManager:
offset = (page - 1) * page_size
# Get honeypot triggers grouped by IP
results = (
session.query(AccessLog.ip, AccessLog.path)
.filter(AccessLog.is_honeypot_trigger == True)
.all()
# Count distinct paths per IP using SQL GROUP BY
count_col = func.count(distinct(AccessLog.path)).label("path_count")
base_query = session.query(AccessLog.ip, count_col).filter(
AccessLog.is_honeypot_trigger == True
)
base_query = self._public_ip_filter(base_query, AccessLog.ip, server_ip)
base_query = base_query.group_by(AccessLog.ip)
# Get total count of distinct honeypot IPs
total_honeypots = base_query.count()
# Apply sorting
if sort_by == "count":
order_expr = (
count_col.desc() if sort_order == "desc" else count_col.asc()
)
else:
order_expr = (
AccessLog.ip.desc() if sort_order == "desc" else AccessLog.ip.asc()
)
ip_rows = (
base_query.order_by(order_expr).offset(offset).limit(page_size).all()
)
# Group paths by IP, filtering out invalid IPs
ip_paths: Dict[str, List[str]] = {}
for row in results:
if not is_valid_public_ip(row.ip, server_ip):
continue
if row.ip not in ip_paths:
ip_paths[row.ip] = []
if row.path not in ip_paths[row.ip]:
ip_paths[row.ip].append(row.path)
# Create list and sort
honeypot_list = [
{"ip": ip, "paths": paths, "count": len(paths)}
for ip, paths in ip_paths.items()
]
if sort_by == "count":
honeypot_list.sort(
key=lambda x: x["count"], reverse=(sort_order == "desc")
)
else: # sort by ip
honeypot_list.sort(
key=lambda x: x["ip"], reverse=(sort_order == "desc")
# Fetch distinct paths only for the paginated IPs
paginated_ips = [row.ip for row in ip_rows]
honeypot_list = []
if paginated_ips:
path_rows = (
session.query(AccessLog.ip, AccessLog.path)
.filter(
AccessLog.is_honeypot_trigger == True,
AccessLog.ip.in_(paginated_ips),
)
.distinct(AccessLog.ip, AccessLog.path)
.all()
)
ip_paths: Dict[str, List[str]] = {}
for row in path_rows:
ip_paths.setdefault(row.ip, []).append(row.path)
total_honeypots = len(honeypot_list)
paginated = honeypot_list[offset : offset + page_size]
total_pages = (total_honeypots + page_size - 1) // page_size
# Preserve the order from the sorted query
for row in ip_rows:
paths = ip_paths.get(row.ip, [])
honeypot_list.append(
{"ip": row.ip, "paths": paths, "count": row.path_count}
)
total_pages = max(1, (total_honeypots + page_size - 1) // page_size)
return {
"honeypots": paginated,
"honeypots": honeypot_list,
"pagination": {
"page": page,
"page_size": page_size,
@@ -1668,6 +1760,9 @@ class DatabaseManager:
"""
Retrieve paginated list of top IP addresses by access count.
Uses the IpStats table (which already stores total_requests per IP)
instead of doing a costly GROUP BY on the large access_logs table.
Args:
page: Page number (1-indexed)
page_size: Number of results per page
@@ -1686,30 +1781,34 @@ class DatabaseManager:
offset = (page - 1) * page_size
results = (
session.query(AccessLog.ip, func.count(AccessLog.id).label("count"))
.group_by(AccessLog.ip)
.all()
)
base_query = session.query(IpStats)
base_query = self._public_ip_filter(base_query, IpStats.ip, server_ip)
# Filter out local/private IPs and server IP, then sort
filtered = [
{"ip": row.ip, "count": row.count}
for row in results
if is_valid_public_ip(row.ip, server_ip)
]
total_ips = base_query.count()
if sort_by == "count":
filtered.sort(key=lambda x: x["count"], reverse=(sort_order == "desc"))
else: # sort by ip
filtered.sort(key=lambda x: x["ip"], reverse=(sort_order == "desc"))
order_col = IpStats.total_requests
else:
order_col = IpStats.ip
total_ips = len(filtered)
paginated = filtered[offset : offset + page_size]
total_pages = (total_ips + page_size - 1) // page_size
if sort_order == "desc":
base_query = base_query.order_by(order_col.desc())
else:
base_query = base_query.order_by(order_col.asc())
results = base_query.offset(offset).limit(page_size).all()
total_pages = max(1, (total_ips + page_size - 1) // page_size)
return {
"ips": paginated,
"ips": [
{
"ip": row.ip,
"count": row.total_requests,
"category": row.category or "unknown",
}
for row in results
],
"pagination": {
"page": page,
"page_size": page_size,
@@ -1743,28 +1842,32 @@ class DatabaseManager:
try:
offset = (page - 1) * page_size
results = (
session.query(AccessLog.path, func.count(AccessLog.id).label("count"))
.group_by(AccessLog.path)
.all()
count_col = func.count(AccessLog.id).label("count")
# Get total number of distinct paths
total_paths = (
session.query(func.count(distinct(AccessLog.path))).scalar() or 0
)
# Create list and sort
paths_list = [{"path": row.path, "count": row.count} for row in results]
# Build query with SQL-level sorting and pagination
query = session.query(AccessLog.path, count_col).group_by(AccessLog.path)
if sort_by == "count":
paths_list.sort(
key=lambda x: x["count"], reverse=(sort_order == "desc")
order_expr = (
count_col.desc() if sort_order == "desc" else count_col.asc()
)
else:
order_expr = (
AccessLog.path.desc()
if sort_order == "desc"
else AccessLog.path.asc()
)
else: # sort by path
paths_list.sort(key=lambda x: x["path"], reverse=(sort_order == "desc"))
total_paths = len(paths_list)
paginated = paths_list[offset : offset + page_size]
total_pages = (total_paths + page_size - 1) // page_size
results = query.order_by(order_expr).offset(offset).limit(page_size).all()
total_pages = max(1, (total_paths + page_size - 1) // page_size)
return {
"paths": paginated,
"paths": [{"path": row.path, "count": row.count} for row in results],
"pagination": {
"page": page,
"page_size": page_size,
@@ -1798,33 +1901,44 @@ class DatabaseManager:
try:
offset = (page - 1) * page_size
results = (
session.query(
AccessLog.user_agent, func.count(AccessLog.id).label("count")
)
.filter(AccessLog.user_agent.isnot(None), AccessLog.user_agent != "")
.group_by(AccessLog.user_agent)
.all()
count_col = func.count(AccessLog.id).label("count")
base_filter = [AccessLog.user_agent.isnot(None), AccessLog.user_agent != ""]
# Get total number of distinct user agents
total_uas = (
session.query(func.count(distinct(AccessLog.user_agent)))
.filter(*base_filter)
.scalar()
or 0
)
# Create list and sort
ua_list = [
{"user_agent": row.user_agent, "count": row.count} for row in results
]
# Build query with SQL-level sorting and pagination
query = (
session.query(AccessLog.user_agent, count_col)
.filter(*base_filter)
.group_by(AccessLog.user_agent)
)
if sort_by == "count":
ua_list.sort(key=lambda x: x["count"], reverse=(sort_order == "desc"))
else: # sort by user_agent
ua_list.sort(
key=lambda x: x["user_agent"], reverse=(sort_order == "desc")
order_expr = (
count_col.desc() if sort_order == "desc" else count_col.asc()
)
else:
order_expr = (
AccessLog.user_agent.desc()
if sort_order == "desc"
else AccessLog.user_agent.asc()
)
total_uas = len(ua_list)
paginated = ua_list[offset : offset + page_size]
total_pages = (total_uas + page_size - 1) // page_size
results = query.order_by(order_expr).offset(offset).limit(page_size).all()
total_pages = max(1, (total_uas + page_size - 1) // page_size)
return {
"user_agents": paginated,
"user_agents": [
{"user_agent": row.user_agent, "count": row.count}
for row in results
],
"pagination": {
"page": page,
"page_size": page_size,
@@ -1841,6 +1955,7 @@ class DatabaseManager:
page_size: int = 5,
sort_by: str = "timestamp",
sort_order: str = "desc",
ip_filter: Optional[str] = None,
) -> Dict[str, Any]:
"""
Retrieve paginated list of detected attack types with access logs.
@@ -1850,6 +1965,7 @@ class DatabaseManager:
page_size: Number of results per page
sort_by: Field to sort by (timestamp, ip, attack_type)
sort_order: Sort order (asc or desc)
ip_filter: Optional IP address to filter results
Returns:
Dictionary with attacks list and pagination info
@@ -1865,18 +1981,22 @@ class DatabaseManager:
sort_order.lower() if sort_order.lower() in {"asc", "desc"} else "desc"
)
# Base query filter
base_filters = []
if ip_filter:
base_filters.append(AccessLog.ip == ip_filter)
# Count total unique access logs with attack detections
total_attacks = (
session.query(AccessLog)
.join(AttackDetection)
.distinct(AccessLog.id)
.count()
)
count_query = session.query(AccessLog).join(AttackDetection)
if base_filters:
count_query = count_query.filter(*base_filters)
total_attacks = count_query.distinct(AccessLog.id).count()
# Get paginated access logs with attack detections
query = (
session.query(AccessLog).join(AttackDetection).distinct(AccessLog.id)
)
query = session.query(AccessLog).join(AttackDetection)
if base_filters:
query = query.filter(*base_filters)
query = query.distinct(AccessLog.id)
if sort_by == "timestamp":
query = query.order_by(
@@ -1939,12 +2059,15 @@ class DatabaseManager:
finally:
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).
Args:
limit: Maximum number of attack types to return
ip_filter: Optional IP address to filter results for
Returns:
Dictionary with attack type counts
@@ -1954,12 +2077,18 @@ class DatabaseManager:
from sqlalchemy import func
# 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 = (
session.query(
AttackDetection.attack_type,
func.count(AttackDetection.id).label("count"),
)
.group_by(AttackDetection.attack_type)
query.group_by(AttackDetection.attack_type)
.order_by(func.count(AttackDetection.id).desc())
.limit(limit)
.all()
@@ -1973,6 +2102,126 @@ class DatabaseManager:
finally:
self.close_session()
def search_attacks_and_ips(
self,
query: str,
page: int = 1,
page_size: int = 20,
) -> Dict[str, Any]:
"""
Search attacks and IPs matching a query string.
Searches across AttackDetection (attack_type, matched_pattern),
AccessLog (ip, path), and IpStats (ip, city, country, isp, asn_org).
Args:
query: Search term (partial match)
page: Page number (1-indexed)
page_size: Results per page
Returns:
Dictionary with matching attacks, ips, and pagination info
"""
session = self.session
try:
offset = (page - 1) * page_size
like_q = f"%{query}%"
# --- Search attacks (AccessLog + AttackDetection) ---
attack_query = (
session.query(AccessLog)
.join(AttackDetection)
.filter(
or_(
AccessLog.ip.ilike(like_q),
AccessLog.path.ilike(like_q),
AttackDetection.attack_type.ilike(like_q),
AttackDetection.matched_pattern.ilike(like_q),
)
)
.distinct(AccessLog.id)
)
total_attacks = attack_query.count()
attack_logs = (
attack_query.order_by(AccessLog.timestamp.desc())
.offset(offset)
.limit(page_size)
.all()
)
attacks = [
{
"id": log.id,
"ip": log.ip,
"path": log.path,
"user_agent": log.user_agent,
"timestamp": log.timestamp.isoformat() if log.timestamp else None,
"attack_types": [d.attack_type for d in log.attack_detections],
"log_id": log.id,
}
for log in attack_logs
]
# --- Search IPs (IpStats) ---
ip_query = session.query(IpStats).filter(
or_(
IpStats.ip.ilike(like_q),
IpStats.city.ilike(like_q),
IpStats.country.ilike(like_q),
IpStats.country_code.ilike(like_q),
IpStats.isp.ilike(like_q),
IpStats.asn_org.ilike(like_q),
IpStats.reverse.ilike(like_q),
)
)
total_ips = ip_query.count()
ips = (
ip_query.order_by(IpStats.total_requests.desc())
.offset(offset)
.limit(page_size)
.all()
)
ip_results = [
{
"ip": stat.ip,
"total_requests": stat.total_requests,
"first_seen": (
stat.first_seen.isoformat() if stat.first_seen else None
),
"last_seen": stat.last_seen.isoformat() if stat.last_seen else None,
"country_code": stat.country_code,
"city": stat.city,
"category": stat.category,
"isp": stat.isp,
"asn_org": stat.asn_org,
}
for stat in ips
]
total = total_attacks + total_ips
total_pages = max(
1, (max(total_attacks, total_ips) + page_size - 1) // page_size
)
return {
"attacks": attacks,
"ips": ip_results,
"query": query,
"pagination": {
"page": page,
"page_size": page_size,
"total_attacks": total_attacks,
"total_ips": total_ips,
"total": total,
"total_pages": total_pages,
},
}
finally:
self.close_session()
# Module-level singleton instance
_db_manager = DatabaseManager()

View File

@@ -30,7 +30,7 @@ def get_templates() -> Jinja2Templates:
return _templates
def _format_ts(value):
def _format_ts(value, time_only=False):
"""Custom Jinja2 filter for formatting ISO timestamps."""
if not value:
return "N/A"
@@ -39,6 +39,8 @@ def _format_ts(value):
value = datetime.fromisoformat(value)
except (ValueError, TypeError):
return value
if time_only:
return value.strftime("%H:%M:%S")
if value.date() == datetime.now().date():
return value.strftime("%H:%M:%S")
return value.strftime("%m/%d/%Y %H:%M:%S")

View File

@@ -36,12 +36,13 @@ class LoggerManager:
cls._instance._initialized = False
return cls._instance
def initialize(self, log_dir: str = "logs") -> None:
def initialize(self, log_dir: str = "logs", log_level: str = "INFO") -> None:
"""
Initialize the logging system with rotating file handlers.loggers
Args:
log_dir: Directory for log files (created if not exists)
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
"""
if self._initialized:
return
@@ -59,9 +60,11 @@ class LoggerManager:
max_bytes = 1048576 # 1MB
backup_count = 5
level = getattr(logging, log_level.upper(), logging.INFO)
# Setup application logger
self._app_logger = logging.getLogger("krawl.app")
self._app_logger.setLevel(logging.INFO)
self._app_logger.setLevel(level)
self._app_logger.handlers.clear()
app_file_handler = RotatingFileHandler(
@@ -78,7 +81,7 @@ class LoggerManager:
# Setup access logger
self._access_logger = logging.getLogger("krawl.access")
self._access_logger.setLevel(logging.INFO)
self._access_logger.setLevel(level)
self._access_logger.handlers.clear()
access_file_handler = RotatingFileHandler(
@@ -95,7 +98,7 @@ class LoggerManager:
# Setup credential logger (special format, no stream handler)
self._credential_logger = logging.getLogger("krawl.credentials")
self._credential_logger.setLevel(logging.INFO)
self._credential_logger.setLevel(level)
self._credential_logger.handlers.clear()
# Credential logger uses a simple format: timestamp|ip|username|password|path
@@ -152,6 +155,6 @@ def get_credential_logger() -> logging.Logger:
return _logger_manager.credentials
def initialize_logging(log_dir: str = "logs") -> None:
def initialize_logging(log_dir: str = "logs", log_level: str = "INFO") -> None:
"""Initialize the logging system."""
_logger_manager.initialize(log_dir)
_logger_manager.initialize(log_dir, log_level)

View File

@@ -7,7 +7,6 @@ All endpoints are prefixed with the secret dashboard path.
"""
import os
import json
from fastapi import APIRouter, Request, Response, Query
from fastapi.responses import JSONResponse, PlainTextResponse
@@ -215,12 +214,13 @@ async def top_user_agents(
async def attack_types_stats(
request: Request,
limit: int = Query(20),
ip_filter: str = Query(None),
):
db = get_db()
limit = min(max(1, limit), 100)
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())
except Exception as e:
get_app_logger().error(f"Error fetching attack types stats: {e}")

View File

@@ -6,6 +6,8 @@ Renders the main dashboard page with server-side data for initial load.
"""
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from logger import get_app_logger
from dependencies import get_db, get_templates
@@ -21,7 +23,7 @@ async def dashboard_page(request: Request):
# Get initial data for server-rendered sections
stats = db.get_dashboard_counts()
suspicious = db.get_recent_suspicious(limit=20)
suspicious = db.get_recent_suspicious(limit=10)
# Get credential count for the stats card
cred_result = db.get_credentials_paginated(page=1, page_size=1)
@@ -37,3 +39,36 @@ async def dashboard_page(request: Request):
"suspicious_activities": suspicious,
},
)
@router.get("/ip/{ip_address:path}")
async def ip_page(ip_address: str, request: Request):
db = get_db()
try:
stats = db.get_ip_stats_by_ip(ip_address)
config = request.app.state.config
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(
"dashboard/ip.html",
{
"request": request,
"dashboard_path": dashboard_path,
"stats": stats,
"ip_address": ip_address,
},
)
else:
return JSONResponse(
content={"error": "IP not found"},
)
except Exception as e:
get_app_logger().error(f"Error fetching IP stats: {e}")
return JSONResponse(content={"error": str(e)})

View File

@@ -2,7 +2,7 @@
"""
HTMX fragment endpoints.
Server-rendered HTML partials for table pagination, sorting, and IP details.
Server-rendered HTML partials for table pagination, sorting, IP details, and search.
"""
from fastapi import APIRouter, Request, Response, Query
@@ -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()
@@ -167,6 +167,42 @@ async def htmx_attackers(
)
# ── Access logs by ip ────────────────────────────────────────────────────────
@router.get("/htmx/access-logs")
async def htmx_access_logs_by_ip(
request: Request,
page: int = Query(1),
sort_by: str = Query("total_requests"),
sort_order: str = Query("desc"),
ip_filter: str = Query("ip_filter"),
):
db = get_db()
result = db.get_access_logs_paginated(
page=max(1, page), page_size=25, ip_filter=ip_filter
)
# Normalize pagination key (DB returns total_attackers, template expects total)
pagination = result["pagination"]
if "total_access_logs" in pagination and "total" not in pagination:
pagination["total"] = pagination["total_access_logs"]
templates = get_templates()
return templates.TemplateResponse(
"dashboard/partials/access_by_ip_table.html",
{
"request": request,
"dashboard_path": _dashboard_path(request),
"items": result["access_logs"],
"pagination": pagination,
"sort_by": sort_by,
"sort_order": sort_order,
"ip_filter": ip_filter,
},
)
# ── Credentials ──────────────────────────────────────────────────────
@@ -205,10 +241,15 @@ async def htmx_attacks(
page: int = Query(1),
sort_by: str = Query("timestamp"),
sort_order: str = Query("desc"),
ip_filter: str = Query(None),
):
db = get_db()
result = db.get_attack_types_paginated(
page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order
page=max(1, page),
page_size=5,
sort_by=sort_by,
sort_order=sort_order,
ip_filter=ip_filter,
)
# Transform attack data for template (join attack_types list, map id to log_id)
@@ -235,6 +276,7 @@ async def htmx_attacks(
"pagination": result["pagination"],
"sort_by": sort_by,
"sort_order": sort_order,
"ip_filter": ip_filter or "",
},
)
@@ -280,6 +322,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 ────────────────────────────────────────────────────────
@@ -305,3 +375,33 @@ async def htmx_ip_detail(ip_address: str, request: Request):
"stats": stats,
},
)
# ── Search ───────────────────────────────────────────────────────────
@router.get("/htmx/search")
async def htmx_search(
request: Request,
q: str = Query(""),
page: int = Query(1),
):
q = q.strip()
if not q:
return Response(content="", media_type="text/html")
db = get_db()
result = db.search_attacks_and_ips(query=q, page=max(1, page), page_size=20)
templates = get_templates()
return templates.TemplateResponse(
"dashboard/partials/search_results.html",
{
"request": request,
"dashboard_path": _dashboard_path(request),
"attacks": result["attacks"],
"ips": result["ips"],
"query": q,
"pagination": result["pagination"],
},
)

View File

@@ -70,7 +70,7 @@ def main():
"risky_http_methods": 6,
"robots_violations": 4,
"uneven_request_timing": 3,
"different_user_agents": 8,
"different_user_agents": 2,
"attack_url": 15,
},
"good_crawler": {
@@ -84,7 +84,7 @@ def main():
"risky_http_methods": 2,
"robots_violations": 7,
"uneven_request_timing": 0,
"different_user_agents": 5,
"different_user_agents": 7,
"attack_url": 5,
},
"regular_user": {

View File

@@ -9,24 +9,37 @@ TASK_CONFIG = {
"name": "flag-stale-ips",
"cron": "0 2 * * *", # Run daily at 2 AM
"enabled": True,
"run_when_loaded": False,
"run_when_loaded": True,
}
# Set to True to force all IPs to be flagged for reevaluation on next run.
# Resets to False automatically after execution.
FORCE_IP_RESCAN = False
def main():
global FORCE_IP_RESCAN
app_logger = get_app_logger()
db = get_database()
try:
count = db.flag_stale_ips_for_reevaluation()
if count > 0:
if FORCE_IP_RESCAN:
count = db.flag_all_ips_for_reevaluation()
FORCE_IP_RESCAN = False
app_logger.info(
f"[Background Task] flag-stale-ips: Flagged {count} stale IPs for reevaluation"
f"[Background Task] flag-stale-ips: FORCE RESCAN - Flagged {count} IPs for reevaluation"
)
else:
app_logger.debug(
"[Background Task] flag-stale-ips: No stale IPs found to flag"
)
count = db.flag_stale_ips_for_reevaluation()
if count > 0:
app_logger.info(
f"[Background Task] flag-stale-ips: Flagged {count} stale IPs for reevaluation"
)
else:
app_logger.debug(
"[Background Task] flag-stale-ips: No stale IPs found to flag"
)
except Exception as e:
app_logger.error(
f"[Background Task] flag-stale-ips: Error flagging stale IPs: {e}"

View File

@@ -5,15 +5,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Krawl Dashboard</title>
<link rel="icon" type="image/svg+xml" href="{{ dashboard_path }}/static/krawl-svg.svg" />
<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.Default.css" crossorigin="anonymous" />
<link rel="stylesheet" href="{{ dashboard_path }}/static/vendor/css/leaflet.min.css" />
<link rel="stylesheet" href="{{ dashboard_path }}/static/vendor/css/MarkerCluster.css" />
<link rel="stylesheet" href="{{ dashboard_path }}/static/vendor/css/MarkerCluster.Default.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.markercluster/1.5.3/leaflet.markercluster.js" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js" defer></script>
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
<script src="{{ dashboard_path }}/static/vendor/js/leaflet.min.js" defer></script>
<script src="{{ dashboard_path }}/static/vendor/js/leaflet.markercluster.js" defer></script>
<script src="{{ dashboard_path }}/static/vendor/js/chart.min.js" defer></script>
<script src="{{ dashboard_path }}/static/vendor/js/htmx.min.js" defer></script>
<script defer src="{{ dashboard_path }}/static/vendor/js/alpine.min.js"></script>
<script>window.__DASHBOARD_PATH__ = '{{ dashboard_path }}';</script>
</head>
<body>

View File

@@ -31,29 +31,45 @@
{# Stats cards - server-rendered #}
{% 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 #}
<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 ==================== #}
<div x-show="tab === 'overview'">
<div x-show="tab === 'overview'" x-init="$nextTick(() => { if (!mapInitialized && typeof initializeAttackerMap === 'function') { initializeAttackerMap(); mapInitialized = true; } })">
{# Suspicious Activity - server-rendered #}
{# Map section #}
{% include "dashboard/partials/map_section.html" %}
{# Suspicious Activity - server-rendered (last 10 requests) #}
{% include "dashboard/partials/suspicious_table.html" %}
{# Honeypot Triggers - HTMX loaded #}
<div class="table-container alert-section">
<h2>Honeypot Triggers by IP</h2>
<div class="htmx-container"
hx-get="{{ dashboard_path }}/htmx/honeypot?page=1"
hx-trigger="load"
hx-swap="innerHTML">
<div class="htmx-indicator">Loading...</div>
</div>
</div>
{# Top IPs + Top User-Agents side by side #}
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
<div class="table-container" style="flex: 1; min-width: 300px;">
@@ -91,9 +107,6 @@
{# ==================== ATTACKS TAB ==================== #}
<div x-show="tab === 'attacks'" x-cloak>
{# Map section #}
{% include "dashboard/partials/map_section.html" %}
{# Attackers table - HTMX loaded #}
<div class="table-container alert-section">
<h2>Attackers by Total Requests</h2>
@@ -116,6 +129,17 @@
</div>
</div>
{# Honeypot Triggers - HTMX loaded #}
<div class="table-container alert-section">
<h2>Honeypot Triggers by IP</h2>
<div class="htmx-container"
hx-get="{{ dashboard_path }}/htmx/honeypot?page=1"
hx-trigger="revealed"
hx-swap="innerHTML">
<div class="htmx-indicator">Loading...</div>
</div>
</div>
{# Attack Types table #}
<div class="table-container alert-section">
<h2>Detected Attack Types</h2>
@@ -147,6 +171,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

@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block content %}
<div class="container" x-data="dashboardApp()" x-init="init()">
{# GitHub logo #}
<a href="https://github.com/BlessedRebuS/Krawl" target="_blank" class="github-logo">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
<span class="github-logo-text">Krawl</span>
</a>
{# 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>
{% set uid = "ip" %}
{% include "dashboard/partials/_ip_detail.html" %}
{# 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 %}

View File

@@ -0,0 +1,295 @@
{# 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 %}
{# Detected Attack Types table only for attackers #}
{% if stats.category and stats.category | lower == 'attacker' %}
<div class="table-container alert-section" style="margin-top: 20px;">
<h2>Detected Attack Types</h2>
<div class="htmx-container"
hx-get="{{ dashboard_path }}/htmx/attacks?page=1&ip_filter={{ ip_address }}"
hx-trigger="revealed"
hx-swap="innerHTML">
<div class="htmx-indicator">Loading...</div>
</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: '&copy; CartoDB | &copy; 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>

View File

@@ -0,0 +1,63 @@
{# HTMX fragment: Detected Access logs by ip table #}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span class="pagination-info">Page {{ pagination.page }}/{{ pagination.total_pages }} &mdash; {{ pagination.total }} total</span>
<div style="display: flex; gap: 8px;">
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/access-logs?page={{ pagination.page - 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}&ip_filter={{ ip_filter }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page <= 1 %}disabled{% endif %}>Prev</button>
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/access-logs?page={{ pagination.page + 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}&ip_filter={{ ip_filter }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page >= pagination.total_pages %}disabled{% endif %}>Next</button>
</div>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>Path</th>
<th>User-Agent</th>
<th class="sortable {% if sort_by == 'timestamp' %}{{ sort_order }}{% endif %}"
hx-get="{{ dashboard_path }}/htmx/access-logs?page=1&sort_by=timestamp&sort_order={% if sort_by == 'timestamp' and sort_order == 'desc' %}asc{% else %}desc{% endif %}&ip_filter={{ ip_filter }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML">
Time
</th>
<th style="width: 100px;"></th>
</tr>
</thead>
<tbody>
{% for log in items %}
<tr class="ip-row" data-ip="{{ log.ip | e }}">
<td class="rank">{{ loop.index + (pagination.page - 1) * pagination.page_size }}</td>
<td>
<div class="path-cell-container">
<span class="path-truncated">{{ log.path | e }}</span>
{% if log.path | length > 30 %}
<div class="path-tooltip">{{ log.path | e }}</div>
{% endif %}
</div>
</td>
<td>{{ (log.user_agent | default(''))[:50] | e }}</td>
<td>{{ log.timestamp | format_ts }}</td>
<td>
{% if log.id %}
<button class="view-btn" @click="viewRawRequest({{ log.id }})">View Request</button>
{% endif %}
</td>
</tr>
<tr class="ip-stats-row" style="display: none;">
<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="5" style="text-align: center;">No logs detected</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -3,12 +3,12 @@
<span class="pagination-info">Page {{ pagination.page }}/{{ pagination.total_pages }} &mdash; {{ pagination.total }} total</span>
<div style="display: flex; gap: 8px;">
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/attacks?page={{ pagination.page - 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
hx-get="{{ dashboard_path }}/htmx/attacks?page={{ pagination.page - 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}{% if ip_filter %}&ip_filter={{ ip_filter }}{% endif %}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page <= 1 %}disabled{% endif %}>Prev</button>
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/attacks?page={{ pagination.page + 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
hx-get="{{ dashboard_path }}/htmx/attacks?page={{ pagination.page + 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}{% if ip_filter %}&ip_filter={{ ip_filter }}{% endif %}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page >= pagination.total_pages %}disabled{% endif %}>Next</button>
@@ -23,12 +23,12 @@
<th>Attack Types</th>
<th>User-Agent</th>
<th class="sortable {% if sort_by == 'timestamp' %}{{ sort_order }}{% endif %}"
hx-get="{{ dashboard_path }}/htmx/attacks?page=1&sort_by=timestamp&sort_order={% if sort_by == 'timestamp' and sort_order == 'desc' %}asc{% else %}desc{% endif %}"
hx-get="{{ dashboard_path }}/htmx/attacks?page=1&sort_by=timestamp&sort_order={% if sort_by == 'timestamp' and sort_order == 'desc' %}asc{% else %}desc{% endif %}{% if ip_filter %}&ip_filter={{ ip_filter }}{% endif %}"
hx-target="closest .htmx-container"
hx-swap="innerHTML">
Time
</th>
<th>Actions</th>
<th style="width: 80px;"></th>
</tr>
</thead>
<tbody>
@@ -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;">
@@ -74,7 +77,7 @@
</td>
</tr>
{% 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 %}
</tbody>
</table>

View File

@@ -36,6 +36,7 @@
hx-swap="innerHTML">
Last Seen</th>
<th>Location</th>
<th style="width: 40px;"></th>
</tr>
</thead>
<tbody>
@@ -53,16 +54,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="6" class="empty-state">No attackers found</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -28,6 +28,7 @@
hx-swap="innerHTML">
Time
</th>
<th style="width: 40px;"></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="6" class="empty-state">No credentials captured</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -25,6 +25,7 @@
hx-swap="innerHTML">
Honeypot Triggers
</th>
<th style="width: 40px;"></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="3" class="empty-state">No data</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -0,0 +1,5 @@
{# HTMX fragment: IP Insight - inline display within dashboard tabs #}
<div class="ip-insight-content" id="ip-insight-content">
{% set uid = "insight" %}
{% include "dashboard/partials/_ip_detail.html" %}
</div>

View File

@@ -37,7 +37,7 @@
<td>{{ pattern.count }}</td>
</tr>
{% 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 %}
</tbody>
</table>

View File

@@ -0,0 +1,164 @@
{# 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 &ldquo;<em>{{ query | e }}</em>&rdquo;
</span>
<button class="search-close-btn" onclick="document.getElementById('search-input').value=''; document.getElementById('search-results-container').innerHTML='';">&times;</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>
<th style="width: 40px;"></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>
<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="8" 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 &ldquo;<em>{{ query | e }}</em>&rdquo;
</div>
{% endif %}
</div>

View File

@@ -8,6 +8,7 @@
<th>Path</th>
<th>User-Agent</th>
<th>Time</th>
<th style="width: 40px;"></th>
</tr>
</thead>
<tbody>
@@ -22,17 +23,22 @@
</td>
<td>{{ activity.path | e }}</td>
<td style="word-break: break-all;">{{ (activity.user_agent | default(''))[:80] | e }}</td>
<td>{{ activity.timestamp | format_ts }}</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="4" class="empty-state">No suspicious activity detected</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -19,12 +19,14 @@
<tr>
<th>#</th>
<th>IP Address</th>
<th>Category</th>
<th class="sortable {% if sort_by == 'count' %}{{ sort_order }}{% endif %}"
hx-get="{{ dashboard_path }}/htmx/top-ips?page=1&sort_by=count&sort_order={% if sort_by == 'count' and sort_order == 'desc' %}asc{% else %}desc{% endif %}"
hx-target="closest .htmx-container"
hx-swap="innerHTML">
Access Count
</th>
<th style="width: 40px;"></th>
</tr>
</thead>
<tbody>
@@ -38,17 +40,27 @@
@click="toggleIpDetail($event)">
{{ item.ip | e }}
</td>
<td>
{% set cat = item.category | default('unknown') %}
{% set cat_colors = {'attacker': '#f85149', 'good_crawler': '#3fb950', 'bad_crawler': '#f0883e', 'regular_user': '#58a6ff', 'unknown': '#8b949e'} %}
<span class="category-dot" style="display: inline-block; width: 12px; height: 12px; border-radius: 50%; background: {{ cat_colors.get(cat, '#8b949e') }};" title="{{ cat | replace('_', ' ') | title }}"></span>
</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="5" 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="3" class="empty-state">No data</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -35,7 +35,7 @@
<td>{{ item.count }}</td>
</tr>
{% 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 %}
</tbody>
</table>

View File

@@ -35,7 +35,7 @@
<td>{{ item.count }}</td>
</tr>
{% 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 %}
</tbody>
</table>

View File

@@ -41,6 +41,8 @@ h1 {
color: #58a6ff;
text-align: center;
margin-bottom: 40px;
font-weight: 900;
font-family: 'Google Sans Flex', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.download-section {
position: absolute;
@@ -74,20 +76,21 @@ h1 {
display: block;
width: 100%;
padding: 8px 14px;
background: #238636;
color: #ffffff;
background: rgba(35, 134, 54, 0.4);
color: rgba(255, 255, 255, 0.7);
text-decoration: none;
border-radius: 6px;
font-weight: 500;
font-size: 13px;
transition: background 0.2s;
border: 1px solid #2ea043;
transition: background 0.2s, color 0.2s;
border: 1px solid rgba(46, 160, 67, 0.4);
cursor: pointer;
text-align: left;
box-sizing: border-box;
}
.banlist-dropdown-btn:hover {
background: #2ea043;
background: rgba(46, 160, 67, 0.6);
color: #ffffff;
}
.banlist-dropdown-menu {
display: none;
@@ -189,8 +192,8 @@ tr:hover {
font-weight: bold;
}
.alert-section {
background: #1c1917;
border-left: 4px solid #f85149;
background: #161b22;
border-left: 6px solid rgba(248, 81, 73, 0.4);
}
th.sortable {
cursor: pointer;
@@ -266,19 +269,20 @@ tbody {
}
.radar-chart {
position: relative;
width: 220px;
height: 220px;
width: 280px;
height: 280px;
overflow: visible;
}
.radar-legend {
margin-top: 10px;
margin-top: 0;
font-size: 11px;
flex-shrink: 0;
}
.radar-legend-item {
display: flex;
align-items: center;
gap: 6px;
margin: 3px 0;
margin: 4px 0;
}
.radar-legend-color {
width: 12px;
@@ -442,6 +446,373 @@ tbody {
.timeline-marker.bad-crawler { background: #f0883e; }
.timeline-marker.regular-user { background: #58a6ff; }
.timeline-marker.unknown { background: #8b949e; }
/* ── IP Insight Page Layout ─────────────────────── */
.ip-insight-content {
animation: fadeIn 0.3s ease-in;
}
.ip-page-header {
margin-bottom: 20px;
}
.ip-page-header h1 {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 4px 0;
}
.ip-address-title {
font-size: 28px;
font-weight: 700;
color: #e6edf3;
font-family: monospace;
}
.ip-location-subtitle {
color: #8b949e;
font-size: 14px;
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 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
align-items: stretch;
}
.ip-page-left,
.ip-page-right {
display: flex;
flex-direction: column;
gap: 20px;
min-height: 0;
}
/* 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-bottom: 16px;
}
/* Remove bottom margin inside grid columns (gap handles spacing) */
.ip-page-left .table-container,
.ip-page-right .table-container {
margin-bottom: 0;
}
/* Definition list for IP info */
.ip-dl {
margin: 0;
display: flex;
flex-direction: column;
gap: 0;
}
.ip-dl-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 8px 0;
border-bottom: 1px solid #21262d;
gap: 16px;
}
.ip-dl-row:last-child {
border-bottom: none;
}
.ip-dl dt {
color: #8b949e;
font-size: 13px;
font-weight: 500;
flex-shrink: 0;
min-width: 100px;
}
.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;
flex-wrap: wrap;
gap: 6px;
}
/* Flags & badges */
.ip-flag {
display: inline-block;
background: #1c2128;
border: 1px solid #f0883e4d;
border-radius: 4px;
padding: 3px 10px;
font-size: 12px;
color: #f0883e;
font-weight: 500;
}
.reputation-score {
font-weight: 700;
}
.reputation-score.bad { color: #f85149; }
.reputation-score.medium { color: #f0883e; }
.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 {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 0;
}
/* ── Behavior Timeline (full-width horizontal) ──── */
.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) {
.ip-page-grid {
grid-template-columns: 1fr;
}
.ip-stats-bar {
flex-direction: column;
}
.ip-stat-chip {
flex: 1 1 auto;
}
.ip-bottom-row {
flex-direction: column;
}
.ip-tl-content {
flex-direction: column;
gap: 2px;
}
.ip-tl-time {
margin-left: 0;
}
}
.tabs-container {
border-bottom: 1px solid #30363d;
margin-bottom: 30px;
@@ -474,6 +845,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;
}
@@ -1210,6 +1590,27 @@ tbody {
background: #30363d;
border-color: #58a6ff;
}
.inspect-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
background: none;
border: none;
color: #8b949e;
cursor: pointer;
border-radius: 4px;
transition: color 0.2s, background 0.2s;
}
.inspect-btn svg {
width: 16px;
height: 16px;
fill: currentColor;
}
.inspect-btn:hover {
color: #58a6ff;
background: rgba(88, 166, 255, 0.1);
}
.pagination-btn {
padding: 6px 14px;
background: #21262d;
@@ -1253,3 +1654,137 @@ tbody {
[x-cloak] {
display: none !important;
}
/* ── Search Bar ────────────────────────────────────── */
.search-bar-container {
max-width: 100%;
margin: 0 0 20px 0;
}
.search-bar {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 14px;
width: 18px;
height: 18px;
color: #8b949e;
pointer-events: none;
}
.search-bar input[type="search"] {
width: 100%;
padding: 12px 40px 12px 42px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #c9d1d9;
font-size: 14px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.search-bar input[type="search"]::placeholder {
color: #6e7681;
}
.search-bar input[type="search"]:focus {
border-color: #58a6ff;
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
}
.search-bar input[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%238b949e'%3E%3Cpath d='M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z'/%3E%3C/svg%3E") center/contain no-repeat;
cursor: pointer;
}
.search-spinner {
position: absolute;
right: 14px;
width: 16px;
height: 16px;
padding: 0;
border: 2px solid #30363d;
border-top-color: #58a6ff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Search Results ───────────────────────────────── */
.search-results {
margin-top: 12px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 16px;
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.search-results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid #30363d;
}
.search-results-summary {
color: #8b949e;
font-size: 13px;
}
.search-results-summary strong {
color: #58a6ff;
}
.search-close-btn {
background: none;
border: none;
color: #8b949e;
font-size: 22px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
transition: color 0.2s;
}
.search-close-btn:hover {
color: #f85149;
}
.search-section {
margin-bottom: 16px;
}
.search-section:last-of-type {
margin-bottom: 0;
}
.search-section-title {
color: #58a6ff;
font-size: 14px;
font-weight: 600;
margin: 0 0 8px 0;
}
.search-pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid #30363d;
}
.search-no-results {
text-align: center;
color: #4a515a;
padding: 24px 0;
font-size: 14px;
}
/* ── Empty State (no data rows) ───────────────────── */
.empty-state {
text-align: center;
color: #4a515a;
padding: 20px 12px;
}

View File

@@ -4,14 +4,25 @@
let attackTypesChart = null;
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__ || '';
try {
const canvas = document.getElementById('attack-types-chart');
const canvas = document.getElementById(canvasId);
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',
headers: {
'Cache-Control': 'no-cache',
@@ -25,7 +36,7 @@ async function loadAttackTypesChart() {
const attackTypes = data.attack_types || [];
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;
}
@@ -63,13 +74,14 @@ async function loadAttackTypesChart() {
const borderColors = labels.map(label => generateColorFromHash(label).border);
const hoverColors = labels.map(label => generateColorFromHash(label).hover);
// Create or update chart
if (attackTypesChart) {
attackTypesChart.destroy();
// Create or update chart (track per canvas)
if (!loadAttackTypesChart._instances) loadAttackTypesChart._instances = {};
if (loadAttackTypesChart._instances[canvasId]) {
loadAttackTypesChart._instances[canvasId].destroy();
}
const ctx = canvas.getContext('2d');
attackTypesChart = new Chart(ctx, {
const chartInstance = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
@@ -88,7 +100,7 @@ async function loadAttackTypesChart() {
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
position: legendPosition,
labels: {
color: '#c9d1d9',
font: {
@@ -160,6 +172,8 @@ async function loadAttackTypesChart() {
}]
});
loadAttackTypesChart._instances[canvasId] = chartInstance;
attackTypesChart = chartInstance;
attackTypesChartLoaded = true;
} catch (err) {
console.error('Error loading attack types chart:', err);

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();
}
}
});
},
@@ -38,15 +45,9 @@ document.addEventListener('alpine:init', () => {
this.tab = 'attacks';
window.location.hash = '#ip-stats';
// Delay initialization to ensure the container is visible and
// the browser has reflowed after x-show removes display:none.
// Leaflet and Chart.js need visible containers with real dimensions.
// Delay chart initialization to ensure the container is visible
this.$nextTick(() => {
setTimeout(() => {
if (!this.mapInitialized && typeof initializeAttackerMap === 'function') {
initializeAttackerMap();
this.mapInitialized = true;
}
if (!this.chartLoaded && typeof loadAttackTypesChart === 'function') {
loadAttackTypesChart();
this.chartLoaded = true;
@@ -60,6 +61,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 +136,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

@@ -36,14 +36,45 @@ function createClusterIcon(cluster) {
gradientStops.push(`${color} ${start.toFixed(1)}deg ${end.toFixed(1)}deg`);
});
const size = Math.max(32, Math.min(50, 32 + Math.log2(total) * 5));
const inner = size - 10;
const offset = 5; // (size - inner) / 2
const size = Math.max(20, Math.min(44, 20 + Math.log2(total) * 4));
const centerSize = size - 8;
const centerOffset = 4;
const ringWidth = 4;
const radius = (size / 2) - (ringWidth / 2);
const cx = size / 2;
const cy = size / 2;
const gapDeg = 8;
// Build SVG arc segments with gaps - glow layer first, then sharp layer
let glowSegments = '';
let segments = '';
let currentAngle = -90;
sorted.forEach(([cat, count], idx) => {
const sliceDeg = (count / total) * 360;
if (sliceDeg < gapDeg) return;
const startAngle = currentAngle + (gapDeg / 2);
const endAngle = currentAngle + sliceDeg - (gapDeg / 2);
const startRad = (startAngle * Math.PI) / 180;
const endRad = (endAngle * Math.PI) / 180;
const x1 = cx + radius * Math.cos(startRad);
const y1 = cy + radius * Math.sin(startRad);
const x2 = cx + radius * Math.cos(endRad);
const y2 = cy + radius * Math.sin(endRad);
const largeArc = (endAngle - startAngle) > 180 ? 1 : 0;
const color = categoryColors[cat] || '#8b949e';
// Glow layer - subtle
glowSegments += `<path d="M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2}" fill="none" stroke="${color}" stroke-width="${ringWidth + 4}" stroke-linecap="round" opacity="0.35" filter="url(#glow)"/>`;
// Sharp layer
segments += `<path d="M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2}" fill="none" stroke="${color}" stroke-width="${ringWidth}" stroke-linecap="round"/>`;
currentAngle += sliceDeg;
});
return L.divIcon({
html: `<div style="position:relative;width:${size}px;height:${size}px;">` +
`<div style="position:absolute;top:0;left:0;width:${size}px;height:${size}px;border-radius:50%;background:conic-gradient(${gradientStops.join(', ')});box-shadow:0 0 6px rgba(0,0,0,0.5);"></div>` +
`<div style="position:absolute;top:${offset}px;left:${offset}px;width:${inner}px;height:${inner}px;border-radius:50%;background:rgba(13,17,23,0.85);color:#e6edf3;font-size:11px;font-weight:700;line-height:${inner}px;text-align:center;">${total}</div>` +
`<svg width="${size}" height="${size}" style="position:absolute;top:0;left:0;overflow:visible;">` +
`<defs><filter id="glow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="2" result="blur"/></filter></defs>` +
`${glowSegments}${segments}</svg>` +
`<div style="position:absolute;top:${centerOffset}px;left:${centerOffset}px;width:${centerSize}px;height:${centerSize}px;border-radius:50%;background:#0d1117;display:flex;align-items:center;justify-content:center;color:#e6edf3;font-family:'SF Mono',Monaco,Consolas,monospace;font-size:${Math.max(9, centerSize * 0.38)}px;font-weight:600;">${total}</div>` +
`</div>`,
className: 'ip-cluster-icon',
iconSize: L.point(size, size)
@@ -180,11 +211,11 @@ function buildMapMarkers(ips) {
// Single cluster group with custom pie-chart icons
clusterGroup = L.markerClusterGroup({
maxClusterRadius: 20,
maxClusterRadius: 35,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
disableClusteringAtZoom: 10,
disableClusteringAtZoom: 8,
iconCreateFunction: createClusterIcon
});
@@ -284,8 +315,13 @@ function buildMapMarkers(ips) {
let popupContent = `
<div style="padding: 12px; min-width: 200px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
<strong style="color: #58a6ff; font-size: 14px;">${ip.ip}</strong>
<button onclick="window.openIpInsight('${ip.ip}')" class="inspect-btn" style="display: inline-flex; align-items: center; padding: 4px; background: none; color: #8b949e; border: none; cursor: pointer; border-radius: 4px;" title="Inspect IP">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" 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 style="margin-bottom: 8px;">
<span style="background: ${categoryColor}1a; color: ${categoryColor}; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600;">
${categoryLabels[category]}
</span>
@@ -315,8 +351,13 @@ function buildMapMarkers(ips) {
console.error('Error fetching IP stats:', err);
const errorPopup = `
<div style="padding: 12px; min-width: 280px; max-width: 320px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
<strong style="color: #58a6ff; font-size: 14px;">${ip.ip}</strong>
<button onclick="window.openIpInsight('${ip.ip}')" class="inspect-btn" style="display: inline-flex; align-items: center; padding: 4px; background: none; color: #8b949e; border: none; cursor: pointer; border-radius: 4px;" title="Inspect IP">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" 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 style="margin-bottom: 8px;">
<span style="background: ${categoryColor}1a; color: ${categoryColor}; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600;">
${categoryLabels[category]}
</span>

View File

@@ -11,11 +11,13 @@
* @param {Object} categoryScores - Object with keys: attacker, good_crawler, bad_crawler, regular_user, unknown
* @param {number} [size=200] - Width/height of the SVG in pixels
* @param {boolean} [showLegend=true] - Whether to show the legend below the chart
* @param {string} [legendPosition='below'] - 'below' or 'side' (side = legend to the right of the chart)
* @returns {string} HTML string containing the SVG radar chart
*/
function generateRadarChart(categoryScores, size, showLegend) {
function generateRadarChart(categoryScores, size, showLegend, legendPosition) {
size = size || 200;
if (showLegend === undefined) showLegend = true;
legendPosition = legendPosition || 'below';
if (!categoryScores || Object.keys(categoryScores).length === 0) {
return '<div style="color: #8b949e; text-align: center; padding: 20px;">No category data available</div>';
@@ -55,7 +57,8 @@ function generateRadarChart(categoryScores, size, showLegend) {
const cx = 100, cy = 100, maxRadius = 75;
let html = '<div style="display: flex; flex-direction: column; align-items: center;">';
const flexDir = legendPosition === 'side' ? 'row' : 'column';
let html = `<div style="display: flex; flex-direction: ${flexDir}; align-items: center; gap: 16px; justify-content: center;">`;
html += `<svg class="radar-chart" viewBox="-30 -30 260 260" preserveAspectRatio="xMidYMid meet" style="width: ${size}px; height: ${size}px;">`;
// Draw concentric circles (grid)

View File

@@ -0,0 +1,60 @@
.marker-cluster-small {
background-color: rgba(181, 226, 140, 0.6);
}
.marker-cluster-small div {
background-color: rgba(110, 204, 57, 0.6);
}
.marker-cluster-medium {
background-color: rgba(241, 211, 87, 0.6);
}
.marker-cluster-medium div {
background-color: rgba(240, 194, 12, 0.6);
}
.marker-cluster-large {
background-color: rgba(253, 156, 115, 0.6);
}
.marker-cluster-large div {
background-color: rgba(241, 128, 23, 0.6);
}
/* IE 6-8 fallback colors */
.leaflet-oldie .marker-cluster-small {
background-color: rgb(181, 226, 140);
}
.leaflet-oldie .marker-cluster-small div {
background-color: rgb(110, 204, 57);
}
.leaflet-oldie .marker-cluster-medium {
background-color: rgb(241, 211, 87);
}
.leaflet-oldie .marker-cluster-medium div {
background-color: rgb(240, 194, 12);
}
.leaflet-oldie .marker-cluster-large {
background-color: rgb(253, 156, 115);
}
.leaflet-oldie .marker-cluster-large div {
background-color: rgb(241, 128, 23);
}
.marker-cluster {
background-clip: padding-box;
border-radius: 20px;
}
.marker-cluster div {
width: 30px;
height: 30px;
margin-left: 5px;
margin-top: 5px;
text-align: center;
border-radius: 15px;
font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif;
}
.marker-cluster span {
line-height: 30px;
}

View File

@@ -0,0 +1,14 @@
.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow {
-webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in;
-moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in;
-o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in;
transition: transform 0.3s ease-out, opacity 0.3s ease-in;
}
.leaflet-cluster-spider-leg {
/* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */
-webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in;
-moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in;
-o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in;
transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -548,12 +548,13 @@ def generate_fake_data(
if __name__ == "__main__":
import sys
# Allow command-line arguments for customization
num_ips = int(sys.argv[1]) if len(sys.argv) > 1 else 20
logs_per_ip = int(sys.argv[2]) if len(sys.argv) > 2 else 15
credentials_per_ip = int(sys.argv[3]) if len(sys.argv) > 3 else 3
# Add --no-cleanup flag to skip database cleanup
cleanup = "--no-cleanup" not in sys.argv
# Filter out flags before parsing positional args
positional = [a for a in sys.argv[1:] if not a.startswith("--")]
num_ips = int(positional[0]) if len(positional) > 0 else 20
logs_per_ip = int(positional[1]) if len(positional) > 1 else 15
credentials_per_ip = int(positional[2]) if len(positional) > 2 else 3
generate_fake_data(
num_ips,