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 run: safety check --json || true
- name: Trivy vulnerability scan - name: Trivy vulnerability scan
uses: aquasecurity/trivy-action@master uses: aquasecurity/trivy-action@0.31.0
with: with:
scan-type: 'fs' scan-type: 'fs'
scan-ref: '.' scan-ref: '.'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -56,6 +56,8 @@ class Config:
user_agents_used_threshold: float = None user_agents_used_threshold: float = None
attack_urls_threshold: float = None attack_urls_threshold: float = None
log_level: str = "INFO"
_server_ip: Optional[str] = None _server_ip: Optional[str] = None
_server_ip_cache_time: float = 0 _server_ip_cache_time: float = 0
_ip_cache_ttl: int = 300 _ip_cache_ttl: int = 300
@@ -163,6 +165,7 @@ class Config:
behavior = data.get("behavior", {}) behavior = data.get("behavior", {})
analyzer = data.get("analyzer") or {} analyzer = data.get("analyzer") or {}
crawl = data.get("crawl", {}) crawl = data.get("crawl", {})
logging_cfg = data.get("logging", {})
# Handle dashboard_secret_path - auto-generate if null/not set # Handle dashboard_secret_path - auto-generate if null/not set
dashboard_path = dashboard.get("secret_path") dashboard_path = dashboard.get("secret_path")
@@ -217,6 +220,9 @@ class Config:
), ),
max_pages_limit=crawl.get("max_pages_limit", 250), max_pages_limit=crawl.get("max_pages_limit", 250),
ban_duration_seconds=crawl.get("ban_duration_seconds", 600), 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: def flag_stale_ips_for_reevaluation(self) -> int:
""" """
Flag IPs for reevaluation where: Flag IPs for reevaluation where:
- last_seen is between 15 and 30 days ago - last_seen is between 5 and 30 days ago
- last_analysis is more than 10 days ago (or never analyzed) - last_analysis is more than 5 days ago
Returns: Returns:
Number of IPs flagged for reevaluation Number of IPs flagged for reevaluation
@@ -825,18 +825,15 @@ class DatabaseManager:
try: try:
now = datetime.now() now = datetime.now()
last_seen_lower = now - timedelta(days=30) last_seen_lower = now - timedelta(days=30)
last_seen_upper = now - timedelta(days=15) last_seen_upper = now - timedelta(days=5)
last_analysis_cutoff = now - timedelta(days=10) last_analysis_cutoff = now - timedelta(days=5)
count = ( count = (
session.query(IpStats) session.query(IpStats)
.filter( .filter(
IpStats.last_seen >= last_seen_lower, IpStats.last_seen >= last_seen_lower,
IpStats.last_seen <= last_seen_upper, IpStats.last_seen <= last_seen_upper,
or_( IpStats.last_analysis <= last_analysis_cutoff,
IpStats.last_analysis <= last_analysis_cutoff,
IpStats.last_analysis.is_(None),
),
IpStats.need_reevaluation == False, IpStats.need_reevaluation == False,
IpStats.manual_category == False, IpStats.manual_category == False,
) )
@@ -850,6 +847,99 @@ class DatabaseManager:
except Exception as e: except Exception as e:
session.rollback() session.rollback()
raise 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: finally:
self.close_session() self.close_session()
@@ -1018,6 +1108,8 @@ class DatabaseManager:
"region": stat.region, "region": stat.region,
"region_name": stat.region_name, "region_name": stat.region_name,
"timezone": stat.timezone, "timezone": stat.timezone,
"latitude": stat.latitude,
"longitude": stat.longitude,
"isp": stat.isp, "isp": stat.isp,
"reverse": stat.reverse, "reverse": stat.reverse,
"asn": stat.asn, "asn": stat.asn,
@@ -1316,26 +1408,16 @@ class DatabaseManager:
""" """
session = self.session session = self.session
try: try:
# Get server IP to filter it out
from config import get_config from config import get_config
config = get_config() config = get_config()
server_ip = config.get_server_ip() server_ip = config.get_server_ip()
results = ( query = session.query(IpStats.ip, IpStats.total_requests)
session.query(AccessLog.ip, func.count(AccessLog.id).label("count")) query = self._public_ip_filter(query, IpStats.ip, server_ip)
.group_by(AccessLog.ip) results = query.order_by(IpStats.total_requests.desc()).limit(limit).all()
.order_by(func.count(AccessLog.id).desc())
.all()
)
# Filter out local/private IPs and server IP, then limit results return [(row.ip, row.total_requests) for row in results]
filtered = [
(row.ip, row.count)
for row in results
if is_valid_public_ip(row.ip, server_ip)
]
return filtered[:limit]
finally: finally:
self.close_session() self.close_session()
@@ -1402,23 +1484,18 @@ class DatabaseManager:
""" """
session = self.session session = self.session
try: try:
# Get server IP to filter it out
from config import get_config from config import get_config
config = get_config() config = get_config()
server_ip = config.get_server_ip() server_ip = config.get_server_ip()
logs = ( query = (
session.query(AccessLog) session.query(AccessLog)
.filter(AccessLog.is_suspicious == True) .filter(AccessLog.is_suspicious == True)
.order_by(AccessLog.timestamp.desc()) .order_by(AccessLog.timestamp.desc())
.all()
) )
query = self._public_ip_filter(query, AccessLog.ip, server_ip)
# Filter out local/private IPs and server IP logs = query.limit(limit).all()
filtered_logs = [
log for log in logs if is_valid_public_ip(log.ip, server_ip)
]
return [ return [
{ {
@@ -1427,7 +1504,7 @@ class DatabaseManager:
"user_agent": log.user_agent, "user_agent": log.user_agent,
"timestamp": log.timestamp.isoformat(), "timestamp": log.timestamp.isoformat(),
} }
for log in filtered_logs[:limit] for log in logs
] ]
finally: finally:
self.close_session() self.close_session()
@@ -1532,44 +1609,59 @@ class DatabaseManager:
offset = (page - 1) * page_size offset = (page - 1) * page_size
# Get honeypot triggers grouped by IP # Count distinct paths per IP using SQL GROUP BY
results = ( count_col = func.count(distinct(AccessLog.path)).label("path_count")
session.query(AccessLog.ip, AccessLog.path) base_query = session.query(AccessLog.ip, count_col).filter(
.filter(AccessLog.is_honeypot_trigger == True) AccessLog.is_honeypot_trigger == True
.all() )
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 # Fetch distinct paths only for the paginated IPs
ip_paths: Dict[str, List[str]] = {} paginated_ips = [row.ip for row in ip_rows]
for row in results: honeypot_list = []
if not is_valid_public_ip(row.ip, server_ip): if paginated_ips:
continue path_rows = (
if row.ip not in ip_paths: session.query(AccessLog.ip, AccessLog.path)
ip_paths[row.ip] = [] .filter(
if row.path not in ip_paths[row.ip]: AccessLog.is_honeypot_trigger == True,
ip_paths[row.ip].append(row.path) AccessLog.ip.in_(paginated_ips),
)
# Create list and sort .distinct(AccessLog.ip, AccessLog.path)
honeypot_list = [ .all()
{"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")
) )
ip_paths: Dict[str, List[str]] = {}
for row in path_rows:
ip_paths.setdefault(row.ip, []).append(row.path)
total_honeypots = len(honeypot_list) # Preserve the order from the sorted query
paginated = honeypot_list[offset : offset + page_size] for row in ip_rows:
total_pages = (total_honeypots + page_size - 1) // page_size 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 { return {
"honeypots": paginated, "honeypots": honeypot_list,
"pagination": { "pagination": {
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
@@ -1668,6 +1760,9 @@ class DatabaseManager:
""" """
Retrieve paginated list of top IP addresses by access count. 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: Args:
page: Page number (1-indexed) page: Page number (1-indexed)
page_size: Number of results per page page_size: Number of results per page
@@ -1686,30 +1781,34 @@ class DatabaseManager:
offset = (page - 1) * page_size offset = (page - 1) * page_size
results = ( base_query = session.query(IpStats)
session.query(AccessLog.ip, func.count(AccessLog.id).label("count")) base_query = self._public_ip_filter(base_query, IpStats.ip, server_ip)
.group_by(AccessLog.ip)
.all()
)
# Filter out local/private IPs and server IP, then sort total_ips = base_query.count()
filtered = [
{"ip": row.ip, "count": row.count}
for row in results
if is_valid_public_ip(row.ip, server_ip)
]
if sort_by == "count": if sort_by == "count":
filtered.sort(key=lambda x: x["count"], reverse=(sort_order == "desc")) order_col = IpStats.total_requests
else: # sort by ip else:
filtered.sort(key=lambda x: x["ip"], reverse=(sort_order == "desc")) order_col = IpStats.ip
total_ips = len(filtered) if sort_order == "desc":
paginated = filtered[offset : offset + page_size] base_query = base_query.order_by(order_col.desc())
total_pages = (total_ips + page_size - 1) // page_size 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 { return {
"ips": paginated, "ips": [
{
"ip": row.ip,
"count": row.total_requests,
"category": row.category or "unknown",
}
for row in results
],
"pagination": { "pagination": {
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
@@ -1743,28 +1842,32 @@ class DatabaseManager:
try: try:
offset = (page - 1) * page_size offset = (page - 1) * page_size
results = ( count_col = func.count(AccessLog.id).label("count")
session.query(AccessLog.path, func.count(AccessLog.id).label("count"))
.group_by(AccessLog.path) # Get total number of distinct paths
.all() total_paths = (
session.query(func.count(distinct(AccessLog.path))).scalar() or 0
) )
# Create list and sort # Build query with SQL-level sorting and pagination
paths_list = [{"path": row.path, "count": row.count} for row in results] query = session.query(AccessLog.path, count_col).group_by(AccessLog.path)
if sort_by == "count": if sort_by == "count":
paths_list.sort( order_expr = (
key=lambda x: x["count"], reverse=(sort_order == "desc") 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) results = query.order_by(order_expr).offset(offset).limit(page_size).all()
paginated = paths_list[offset : offset + page_size] total_pages = max(1, (total_paths + page_size - 1) // page_size)
total_pages = (total_paths + page_size - 1) // page_size
return { return {
"paths": paginated, "paths": [{"path": row.path, "count": row.count} for row in results],
"pagination": { "pagination": {
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
@@ -1798,33 +1901,44 @@ class DatabaseManager:
try: try:
offset = (page - 1) * page_size offset = (page - 1) * page_size
results = ( count_col = func.count(AccessLog.id).label("count")
session.query(
AccessLog.user_agent, func.count(AccessLog.id).label("count") base_filter = [AccessLog.user_agent.isnot(None), AccessLog.user_agent != ""]
)
.filter(AccessLog.user_agent.isnot(None), AccessLog.user_agent != "") # Get total number of distinct user agents
.group_by(AccessLog.user_agent) total_uas = (
.all() session.query(func.count(distinct(AccessLog.user_agent)))
.filter(*base_filter)
.scalar()
or 0
) )
# Create list and sort # Build query with SQL-level sorting and pagination
ua_list = [ query = (
{"user_agent": row.user_agent, "count": row.count} for row in results session.query(AccessLog.user_agent, count_col)
] .filter(*base_filter)
.group_by(AccessLog.user_agent)
)
if sort_by == "count": if sort_by == "count":
ua_list.sort(key=lambda x: x["count"], reverse=(sort_order == "desc")) order_expr = (
else: # sort by user_agent count_col.desc() if sort_order == "desc" else count_col.asc()
ua_list.sort( )
key=lambda x: x["user_agent"], reverse=(sort_order == "desc") else:
order_expr = (
AccessLog.user_agent.desc()
if sort_order == "desc"
else AccessLog.user_agent.asc()
) )
total_uas = len(ua_list) results = query.order_by(order_expr).offset(offset).limit(page_size).all()
paginated = ua_list[offset : offset + page_size] total_pages = max(1, (total_uas + page_size - 1) // page_size)
total_pages = (total_uas + page_size - 1) // page_size
return { return {
"user_agents": paginated, "user_agents": [
{"user_agent": row.user_agent, "count": row.count}
for row in results
],
"pagination": { "pagination": {
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
@@ -1841,6 +1955,7 @@ class DatabaseManager:
page_size: int = 5, page_size: int = 5,
sort_by: str = "timestamp", sort_by: str = "timestamp",
sort_order: str = "desc", sort_order: str = "desc",
ip_filter: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Retrieve paginated list of detected attack types with access logs. Retrieve paginated list of detected attack types with access logs.
@@ -1850,6 +1965,7 @@ class DatabaseManager:
page_size: Number of results per page page_size: Number of results per page
sort_by: Field to sort by (timestamp, ip, attack_type) sort_by: Field to sort by (timestamp, ip, attack_type)
sort_order: Sort order (asc or desc) sort_order: Sort order (asc or desc)
ip_filter: Optional IP address to filter results
Returns: Returns:
Dictionary with attacks list and pagination info 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" 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 # Count total unique access logs with attack detections
total_attacks = ( count_query = session.query(AccessLog).join(AttackDetection)
session.query(AccessLog) if base_filters:
.join(AttackDetection) count_query = count_query.filter(*base_filters)
.distinct(AccessLog.id) total_attacks = count_query.distinct(AccessLog.id).count()
.count()
)
# Get paginated access logs with attack detections # Get paginated access logs with attack detections
query = ( query = session.query(AccessLog).join(AttackDetection)
session.query(AccessLog).join(AttackDetection).distinct(AccessLog.id) if base_filters:
) query = query.filter(*base_filters)
query = query.distinct(AccessLog.id)
if sort_by == "timestamp": if sort_by == "timestamp":
query = query.order_by( query = query.order_by(
@@ -1939,12 +2059,15 @@ class DatabaseManager:
finally: finally:
self.close_session() self.close_session()
def get_attack_types_stats(self, limit: int = 20) -> Dict[str, Any]: def get_attack_types_stats(
self, limit: int = 20, ip_filter: str | None = None
) -> Dict[str, Any]:
""" """
Get aggregated statistics for attack types (efficient for large datasets). Get aggregated statistics for attack types (efficient for large datasets).
Args: Args:
limit: Maximum number of attack types to return limit: Maximum number of attack types to return
ip_filter: Optional IP address to filter results for
Returns: Returns:
Dictionary with attack type counts Dictionary with attack type counts
@@ -1954,12 +2077,18 @@ class DatabaseManager:
from sqlalchemy import func from sqlalchemy import func
# Aggregate attack types with count # Aggregate attack types with count
query = session.query(
AttackDetection.attack_type,
func.count(AttackDetection.id).label("count"),
)
if ip_filter:
query = query.join(
AccessLog, AttackDetection.access_log_id == AccessLog.id
).filter(AccessLog.ip == ip_filter)
results = ( results = (
session.query( query.group_by(AttackDetection.attack_type)
AttackDetection.attack_type,
func.count(AttackDetection.id).label("count"),
)
.group_by(AttackDetection.attack_type)
.order_by(func.count(AttackDetection.id).desc()) .order_by(func.count(AttackDetection.id).desc())
.limit(limit) .limit(limit)
.all() .all()
@@ -1973,6 +2102,126 @@ class DatabaseManager:
finally: finally:
self.close_session() self.close_session()
def search_attacks_and_ips(
self,
query: str,
page: int = 1,
page_size: int = 20,
) -> Dict[str, Any]:
"""
Search attacks and IPs matching a query string.
Searches across AttackDetection (attack_type, matched_pattern),
AccessLog (ip, path), and IpStats (ip, city, country, isp, asn_org).
Args:
query: Search term (partial match)
page: Page number (1-indexed)
page_size: Results per page
Returns:
Dictionary with matching attacks, ips, and pagination info
"""
session = self.session
try:
offset = (page - 1) * page_size
like_q = f"%{query}%"
# --- Search attacks (AccessLog + AttackDetection) ---
attack_query = (
session.query(AccessLog)
.join(AttackDetection)
.filter(
or_(
AccessLog.ip.ilike(like_q),
AccessLog.path.ilike(like_q),
AttackDetection.attack_type.ilike(like_q),
AttackDetection.matched_pattern.ilike(like_q),
)
)
.distinct(AccessLog.id)
)
total_attacks = attack_query.count()
attack_logs = (
attack_query.order_by(AccessLog.timestamp.desc())
.offset(offset)
.limit(page_size)
.all()
)
attacks = [
{
"id": log.id,
"ip": log.ip,
"path": log.path,
"user_agent": log.user_agent,
"timestamp": log.timestamp.isoformat() if log.timestamp else None,
"attack_types": [d.attack_type for d in log.attack_detections],
"log_id": log.id,
}
for log in attack_logs
]
# --- Search IPs (IpStats) ---
ip_query = session.query(IpStats).filter(
or_(
IpStats.ip.ilike(like_q),
IpStats.city.ilike(like_q),
IpStats.country.ilike(like_q),
IpStats.country_code.ilike(like_q),
IpStats.isp.ilike(like_q),
IpStats.asn_org.ilike(like_q),
IpStats.reverse.ilike(like_q),
)
)
total_ips = ip_query.count()
ips = (
ip_query.order_by(IpStats.total_requests.desc())
.offset(offset)
.limit(page_size)
.all()
)
ip_results = [
{
"ip": stat.ip,
"total_requests": stat.total_requests,
"first_seen": (
stat.first_seen.isoformat() if stat.first_seen else None
),
"last_seen": stat.last_seen.isoformat() if stat.last_seen else None,
"country_code": stat.country_code,
"city": stat.city,
"category": stat.category,
"isp": stat.isp,
"asn_org": stat.asn_org,
}
for stat in ips
]
total = total_attacks + total_ips
total_pages = max(
1, (max(total_attacks, total_ips) + page_size - 1) // page_size
)
return {
"attacks": attacks,
"ips": ip_results,
"query": query,
"pagination": {
"page": page,
"page_size": page_size,
"total_attacks": total_attacks,
"total_ips": total_ips,
"total": total,
"total_pages": total_pages,
},
}
finally:
self.close_session()
# Module-level singleton instance # Module-level singleton instance
_db_manager = DatabaseManager() _db_manager = DatabaseManager()

View File

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

View File

@@ -36,12 +36,13 @@ class LoggerManager:
cls._instance._initialized = False cls._instance._initialized = False
return cls._instance 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 Initialize the logging system with rotating file handlers.loggers
Args: Args:
log_dir: Directory for log files (created if not exists) log_dir: Directory for log files (created if not exists)
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
""" """
if self._initialized: if self._initialized:
return return
@@ -59,9 +60,11 @@ class LoggerManager:
max_bytes = 1048576 # 1MB max_bytes = 1048576 # 1MB
backup_count = 5 backup_count = 5
level = getattr(logging, log_level.upper(), logging.INFO)
# Setup application logger # Setup application logger
self._app_logger = logging.getLogger("krawl.app") self._app_logger = logging.getLogger("krawl.app")
self._app_logger.setLevel(logging.INFO) self._app_logger.setLevel(level)
self._app_logger.handlers.clear() self._app_logger.handlers.clear()
app_file_handler = RotatingFileHandler( app_file_handler = RotatingFileHandler(
@@ -78,7 +81,7 @@ class LoggerManager:
# Setup access logger # Setup access logger
self._access_logger = logging.getLogger("krawl.access") self._access_logger = logging.getLogger("krawl.access")
self._access_logger.setLevel(logging.INFO) self._access_logger.setLevel(level)
self._access_logger.handlers.clear() self._access_logger.handlers.clear()
access_file_handler = RotatingFileHandler( access_file_handler = RotatingFileHandler(
@@ -95,7 +98,7 @@ class LoggerManager:
# Setup credential logger (special format, no stream handler) # Setup credential logger (special format, no stream handler)
self._credential_logger = logging.getLogger("krawl.credentials") self._credential_logger = logging.getLogger("krawl.credentials")
self._credential_logger.setLevel(logging.INFO) self._credential_logger.setLevel(level)
self._credential_logger.handlers.clear() self._credential_logger.handlers.clear()
# Credential logger uses a simple format: timestamp|ip|username|password|path # 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 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.""" """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 os
import json
from fastapi import APIRouter, Request, Response, Query from fastapi import APIRouter, Request, Response, Query
from fastapi.responses import JSONResponse, PlainTextResponse from fastapi.responses import JSONResponse, PlainTextResponse
@@ -215,12 +214,13 @@ async def top_user_agents(
async def attack_types_stats( async def attack_types_stats(
request: Request, request: Request,
limit: int = Query(20), limit: int = Query(20),
ip_filter: str = Query(None),
): ):
db = get_db() db = get_db()
limit = min(max(1, limit), 100) limit = min(max(1, limit), 100)
try: try:
result = db.get_attack_types_stats(limit=limit) result = db.get_attack_types_stats(limit=limit, ip_filter=ip_filter)
return JSONResponse(content=result, headers=_no_cache_headers()) return JSONResponse(content=result, headers=_no_cache_headers())
except Exception as e: except Exception as e:
get_app_logger().error(f"Error fetching attack types stats: {e}") get_app_logger().error(f"Error fetching attack types stats: {e}")

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 import APIRouter, Request
from fastapi.responses import JSONResponse
from logger import get_app_logger
from dependencies import get_db, get_templates from dependencies import get_db, get_templates
@@ -21,7 +23,7 @@ async def dashboard_page(request: Request):
# Get initial data for server-rendered sections # Get initial data for server-rendered sections
stats = db.get_dashboard_counts() 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 # Get credential count for the stats card
cred_result = db.get_credentials_paginated(page=1, page_size=1) cred_result = db.get_credentials_paginated(page=1, page_size=1)
@@ -37,3 +39,36 @@ async def dashboard_page(request: Request):
"suspicious_activities": suspicious, "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. HTMX fragment endpoints.
Server-rendered HTML partials for table pagination, sorting, and IP details. Server-rendered HTML partials for table pagination, sorting, IP details, and search.
""" """
from fastapi import APIRouter, Request, Response, Query from fastapi import APIRouter, Request, Response, Query
@@ -58,7 +58,7 @@ async def htmx_top_ips(
): ):
db = get_db() db = get_db()
result = db.get_top_ips_paginated( 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() 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 ────────────────────────────────────────────────────── # ── Credentials ──────────────────────────────────────────────────────
@@ -205,10 +241,15 @@ async def htmx_attacks(
page: int = Query(1), page: int = Query(1),
sort_by: str = Query("timestamp"), sort_by: str = Query("timestamp"),
sort_order: str = Query("desc"), sort_order: str = Query("desc"),
ip_filter: str = Query(None),
): ):
db = get_db() db = get_db()
result = db.get_attack_types_paginated( 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) # 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"], "pagination": result["pagination"],
"sort_by": sort_by, "sort_by": sort_by,
"sort_order": sort_order, "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 ──────────────────────────────────────────────────────── # ── IP Detail ────────────────────────────────────────────────────────
@@ -305,3 +375,33 @@ async def htmx_ip_detail(ip_address: str, request: Request):
"stats": stats, "stats": stats,
}, },
) )
# ── Search ───────────────────────────────────────────────────────────
@router.get("/htmx/search")
async def htmx_search(
request: Request,
q: str = Query(""),
page: int = Query(1),
):
q = q.strip()
if not q:
return Response(content="", media_type="text/html")
db = get_db()
result = db.search_attacks_and_ips(query=q, page=max(1, page), page_size=20)
templates = get_templates()
return templates.TemplateResponse(
"dashboard/partials/search_results.html",
{
"request": request,
"dashboard_path": _dashboard_path(request),
"attacks": result["attacks"],
"ips": result["ips"],
"query": q,
"pagination": result["pagination"],
},
)

View File

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

View File

@@ -9,24 +9,37 @@ TASK_CONFIG = {
"name": "flag-stale-ips", "name": "flag-stale-ips",
"cron": "0 2 * * *", # Run daily at 2 AM "cron": "0 2 * * *", # Run daily at 2 AM
"enabled": True, "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(): def main():
global FORCE_IP_RESCAN
app_logger = get_app_logger() app_logger = get_app_logger()
db = get_database() db = get_database()
try: try:
count = db.flag_stale_ips_for_reevaluation() if FORCE_IP_RESCAN:
if count > 0: count = db.flag_all_ips_for_reevaluation()
FORCE_IP_RESCAN = False
app_logger.info( 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: else:
app_logger.debug( count = db.flag_stale_ips_for_reevaluation()
"[Background Task] flag-stale-ips: No stale IPs found to flag" 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: except Exception as e:
app_logger.error( app_logger.error(
f"[Background Task] flag-stale-ips: Error flagging stale IPs: {e}" 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"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Krawl Dashboard</title> <title>Krawl Dashboard</title>
<link rel="icon" type="image/svg+xml" href="{{ dashboard_path }}/static/krawl-svg.svg" /> <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="{{ dashboard_path }}/static/vendor/css/leaflet.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/MarkerCluster.css" crossorigin="anonymous" /> <link rel="stylesheet" href="{{ dashboard_path }}/static/vendor/css/MarkerCluster.css" />
<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/MarkerCluster.Default.css" />
<link rel="stylesheet" href="{{ dashboard_path }}/static/css/dashboard.css" /> <link rel="stylesheet" href="{{ dashboard_path }}/static/css/dashboard.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js" crossorigin="anonymous" defer></script> <script src="{{ dashboard_path }}/static/vendor/js/leaflet.min.js" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/leaflet.markercluster.js" crossorigin="anonymous" defer></script> <script src="{{ dashboard_path }}/static/vendor/js/leaflet.markercluster.js" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js" defer></script> <script src="{{ dashboard_path }}/static/vendor/js/chart.min.js" defer></script>
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script> <script src="{{ dashboard_path }}/static/vendor/js/htmx.min.js" defer></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script> <script defer src="{{ dashboard_path }}/static/vendor/js/alpine.min.js"></script>
<script>window.__DASHBOARD_PATH__ = '{{ dashboard_path }}';</script> <script>window.__DASHBOARD_PATH__ = '{{ dashboard_path }}';</script>
</head> </head>
<body> <body>

View File

@@ -31,29 +31,45 @@
{# Stats cards - server-rendered #} {# Stats cards - server-rendered #}
{% include "dashboard/partials/stats_cards.html" %} {% include "dashboard/partials/stats_cards.html" %}
{# Search bar #}
<div class="search-bar-container">
<div class="search-bar">
<svg class="search-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd"/>
</svg>
<input id="search-input"
type="search"
name="q"
placeholder="Search attacks, IPs, patterns, locations..."
autocomplete="off"
hx-get="{{ dashboard_path }}/htmx/search"
hx-trigger="input changed delay:300ms, search"
hx-target="#search-results-container"
hx-swap="innerHTML"
hx-indicator="#search-spinner" />
<span id="search-spinner" class="htmx-indicator search-spinner"></span>
</div>
<div id="search-results-container"></div>
</div>
{# Tab navigation - Alpine.js #} {# Tab navigation - Alpine.js #}
<div class="tabs-container"> <div class="tabs-container">
<a class="tab-button" :class="{ active: tab === 'overview' }" @click.prevent="switchToOverview()" href="#overview">Overview</a> <a class="tab-button" :class="{ active: tab === 'overview' }" @click.prevent="switchToOverview()" href="#overview">Overview</a>
<a class="tab-button" :class="{ active: tab === 'attacks' }" @click.prevent="switchToAttacks()" href="#ip-stats">Attacks</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> </div>
{# ==================== OVERVIEW TAB ==================== #} {# ==================== 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" %} {% 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 #} {# Top IPs + Top User-Agents side by side #}
<div style="display: flex; gap: 20px; flex-wrap: wrap;"> <div style="display: flex; gap: 20px; flex-wrap: wrap;">
<div class="table-container" style="flex: 1; min-width: 300px;"> <div class="table-container" style="flex: 1; min-width: 300px;">
@@ -91,9 +107,6 @@
{# ==================== ATTACKS TAB ==================== #} {# ==================== ATTACKS TAB ==================== #}
<div x-show="tab === 'attacks'" x-cloak> <div x-show="tab === 'attacks'" x-cloak>
{# Map section #}
{% include "dashboard/partials/map_section.html" %}
{# Attackers table - HTMX loaded #} {# Attackers table - HTMX loaded #}
<div class="table-container alert-section"> <div class="table-container alert-section">
<h2>Attackers by Total Requests</h2> <h2>Attackers by Total Requests</h2>
@@ -116,6 +129,17 @@
</div> </div>
</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 #} {# Attack Types table #}
<div class="table-container alert-section"> <div class="table-container alert-section">
<h2>Detected Attack Types</h2> <h2>Detected Attack Types</h2>
@@ -147,6 +171,19 @@
</div> </div>
</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 #} {# Raw request modal - Alpine.js #}
{% include "dashboard/partials/raw_request_modal.html" %} {% 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> <span class="pagination-info">Page {{ pagination.page }}/{{ pagination.total_pages }} &mdash; {{ pagination.total }} total</span>
<div style="display: flex; gap: 8px;"> <div style="display: flex; gap: 8px;">
<button class="pagination-btn" <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-target="closest .htmx-container"
hx-swap="innerHTML" hx-swap="innerHTML"
{% if pagination.page <= 1 %}disabled{% endif %}>Prev</button> {% if pagination.page <= 1 %}disabled{% endif %}>Prev</button>
<button class="pagination-btn" <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-target="closest .htmx-container"
hx-swap="innerHTML" hx-swap="innerHTML"
{% if pagination.page >= pagination.total_pages %}disabled{% endif %}>Next</button> {% if pagination.page >= pagination.total_pages %}disabled{% endif %}>Next</button>
@@ -23,12 +23,12 @@
<th>Attack Types</th> <th>Attack Types</th>
<th>User-Agent</th> <th>User-Agent</th>
<th class="sortable {% if sort_by == 'timestamp' %}{{ sort_order }}{% endif %}" <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-target="closest .htmx-container"
hx-swap="innerHTML"> hx-swap="innerHTML">
Time Time
</th> </th>
<th>Actions</th> <th style="width: 80px;"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -60,10 +60,13 @@
</td> </td>
<td>{{ (attack.user_agent | default(''))[:50] | e }}</td> <td>{{ (attack.user_agent | default(''))[:50] | e }}</td>
<td>{{ attack.timestamp | format_ts }}</td> <td>{{ attack.timestamp | format_ts }}</td>
<td> <td style="display: flex; gap: 6px; flex-wrap: wrap;">
{% if attack.log_id %} {% if attack.log_id %}
<button class="view-btn" @click="viewRawRequest({{ attack.log_id }})">View Request</button> <button class="view-btn" @click="viewRawRequest({{ attack.log_id }})">View Request</button>
{% endif %} {% 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> </td>
</tr> </tr>
<tr class="ip-stats-row" style="display: none;"> <tr class="ip-stats-row" style="display: none;">
@@ -74,7 +77,7 @@
</td> </td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="7" style="text-align: center;">No attacks detected</td></tr> <tr><td colspan="7" class="empty-state">No attacks detected</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@@ -36,6 +36,7 @@
hx-swap="innerHTML"> hx-swap="innerHTML">
Last Seen</th> Last Seen</th>
<th>Location</th> <th>Location</th>
<th style="width: 40px;"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -53,16 +54,21 @@
<td>{{ ip.first_seen | format_ts }}</td> <td>{{ ip.first_seen | format_ts }}</td>
<td>{{ ip.last_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>{{ 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>
<tr class="ip-stats-row" style="display: none;"> <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="ip-stats-dropdown">
<div class="loading">Loading stats...</div> <div class="loading">Loading stats...</div>
</div> </div>
</td> </td>
</tr> </tr>
{% else %} {% 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 %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@@ -28,6 +28,7 @@
hx-swap="innerHTML"> hx-swap="innerHTML">
Time Time
</th> </th>
<th style="width: 40px;"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -45,16 +46,21 @@
<td>{{ cred.password | default('N/A') | e }}</td> <td>{{ cred.password | default('N/A') | e }}</td>
<td>{{ cred.path | default('') | e }}</td> <td>{{ cred.path | default('') | e }}</td>
<td>{{ cred.timestamp | format_ts }}</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>
<tr class="ip-stats-row" style="display: none;"> <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="ip-stats-dropdown">
<div class="loading">Loading stats...</div> <div class="loading">Loading stats...</div>
</div> </div>
</td> </td>
</tr> </tr>
{% else %} {% 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 %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@@ -25,6 +25,7 @@
hx-swap="innerHTML"> hx-swap="innerHTML">
Honeypot Triggers Honeypot Triggers
</th> </th>
<th style="width: 40px;"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -39,16 +40,21 @@
{{ item.ip | e }} {{ item.ip | e }}
</td> </td>
<td>{{ item.count }}</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>
<tr class="ip-stats-row" style="display: none;"> <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="ip-stats-dropdown">
<div class="loading">Loading stats...</div> <div class="loading">Loading stats...</div>
</div> </div>
</td> </td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="3" style="text-align: center;">No data</td></tr> <tr><td colspan="3" class="empty-state">No data</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

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> <td>{{ pattern.count }}</td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="3" style="text-align: center;">No patterns found</td></tr> <tr><td colspan="3" class="empty-state">No patterns found</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

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>Path</th>
<th>User-Agent</th> <th>User-Agent</th>
<th>Time</th> <th>Time</th>
<th style="width: 40px;"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -22,17 +23,22 @@
</td> </td>
<td>{{ activity.path | e }}</td> <td>{{ activity.path | e }}</td>
<td style="word-break: break-all;">{{ (activity.user_agent | default(''))[:80] | 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>
<tr class="ip-stats-row" style="display: none;"> <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="ip-stats-dropdown">
<div class="loading">Loading stats...</div> <div class="loading">Loading stats...</div>
</div> </div>
</td> </td>
</tr> </tr>
{% else %} {% 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 %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@@ -19,12 +19,14 @@
<tr> <tr>
<th>#</th> <th>#</th>
<th>IP Address</th> <th>IP Address</th>
<th>Category</th>
<th class="sortable {% if sort_by == 'count' %}{{ sort_order }}{% endif %}" <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-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-target="closest .htmx-container"
hx-swap="innerHTML"> hx-swap="innerHTML">
Access Count Access Count
</th> </th>
<th style="width: 40px;"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -38,17 +40,27 @@
@click="toggleIpDetail($event)"> @click="toggleIpDetail($event)">
{{ item.ip | e }} {{ item.ip | e }}
</td> </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>{{ 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>
<tr class="ip-stats-row" style="display: none;"> <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="ip-stats-dropdown">
<div class="loading">Loading stats...</div> <div class="loading">Loading stats...</div>
</div> </div>
</td> </td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="3" style="text-align: center;">No data</td></tr> <tr><td colspan="3" class="empty-state">No data</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@@ -35,7 +35,7 @@
<td>{{ item.count }}</td> <td>{{ item.count }}</td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="3" style="text-align: center;">No data</td></tr> <tr><td colspan="3" class="empty-state">No data</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@@ -35,7 +35,7 @@
<td>{{ item.count }}</td> <td>{{ item.count }}</td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="3" style="text-align: center;">No data</td></tr> <tr><td colspan="3" class="empty-state">No data</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@@ -41,6 +41,8 @@ h1 {
color: #58a6ff; color: #58a6ff;
text-align: center; text-align: center;
margin-bottom: 40px; margin-bottom: 40px;
font-weight: 900;
font-family: 'Google Sans Flex', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
} }
.download-section { .download-section {
position: absolute; position: absolute;
@@ -74,20 +76,21 @@ h1 {
display: block; display: block;
width: 100%; width: 100%;
padding: 8px 14px; padding: 8px 14px;
background: #238636; background: rgba(35, 134, 54, 0.4);
color: #ffffff; color: rgba(255, 255, 255, 0.7);
text-decoration: none; text-decoration: none;
border-radius: 6px; border-radius: 6px;
font-weight: 500; font-weight: 500;
font-size: 13px; font-size: 13px;
transition: background 0.2s; transition: background 0.2s, color 0.2s;
border: 1px solid #2ea043; border: 1px solid rgba(46, 160, 67, 0.4);
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
box-sizing: border-box; box-sizing: border-box;
} }
.banlist-dropdown-btn:hover { .banlist-dropdown-btn:hover {
background: #2ea043; background: rgba(46, 160, 67, 0.6);
color: #ffffff;
} }
.banlist-dropdown-menu { .banlist-dropdown-menu {
display: none; display: none;
@@ -189,8 +192,8 @@ tr:hover {
font-weight: bold; font-weight: bold;
} }
.alert-section { .alert-section {
background: #1c1917; background: #161b22;
border-left: 4px solid #f85149; border-left: 6px solid rgba(248, 81, 73, 0.4);
} }
th.sortable { th.sortable {
cursor: pointer; cursor: pointer;
@@ -266,19 +269,20 @@ tbody {
} }
.radar-chart { .radar-chart {
position: relative; position: relative;
width: 220px; width: 280px;
height: 220px; height: 280px;
overflow: visible; overflow: visible;
} }
.radar-legend { .radar-legend {
margin-top: 10px; margin-top: 0;
font-size: 11px; font-size: 11px;
flex-shrink: 0;
} }
.radar-legend-item { .radar-legend-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
margin: 3px 0; margin: 4px 0;
} }
.radar-legend-color { .radar-legend-color {
width: 12px; width: 12px;
@@ -442,6 +446,373 @@ tbody {
.timeline-marker.bad-crawler { background: #f0883e; } .timeline-marker.bad-crawler { background: #f0883e; }
.timeline-marker.regular-user { background: #58a6ff; } .timeline-marker.regular-user { background: #58a6ff; }
.timeline-marker.unknown { background: #8b949e; } .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 { .tabs-container {
border-bottom: 1px solid #30363d; border-bottom: 1px solid #30363d;
margin-bottom: 30px; margin-bottom: 30px;
@@ -474,6 +845,15 @@ tbody {
color: #58a6ff; color: #58a6ff;
border-bottom-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 { .tab-content {
display: none; display: none;
} }
@@ -1210,6 +1590,27 @@ tbody {
background: #30363d; background: #30363d;
border-color: #58a6ff; 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 { .pagination-btn {
padding: 6px 14px; padding: 6px 14px;
background: #21262d; background: #21262d;
@@ -1253,3 +1654,137 @@ tbody {
[x-cloak] { [x-cloak] {
display: none !important; 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 attackTypesChart = null;
let attackTypesChartLoaded = false; let attackTypesChartLoaded = false;
async function loadAttackTypesChart() { /**
* Load an attack types doughnut chart into a canvas element.
* @param {string} [canvasId='attack-types-chart'] - Canvas element ID
* @param {string} [ipFilter] - Optional IP address to scope results
* @param {string} [legendPosition='right'] - Legend position
*/
async function loadAttackTypesChart(canvasId, ipFilter, legendPosition) {
canvasId = canvasId || 'attack-types-chart';
legendPosition = legendPosition || 'right';
const DASHBOARD_PATH = window.__DASHBOARD_PATH__ || ''; const DASHBOARD_PATH = window.__DASHBOARD_PATH__ || '';
try { try {
const canvas = document.getElementById('attack-types-chart'); const canvas = document.getElementById(canvasId);
if (!canvas) return; if (!canvas) return;
const response = await fetch(DASHBOARD_PATH + '/api/attack-types-stats?limit=10', { let url = DASHBOARD_PATH + '/api/attack-types-stats?limit=10';
if (ipFilter) url += '&ip_filter=' + encodeURIComponent(ipFilter);
const response = await fetch(url, {
cache: 'no-store', cache: 'no-store',
headers: { headers: {
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
@@ -25,7 +36,7 @@ async function loadAttackTypesChart() {
const attackTypes = data.attack_types || []; const attackTypes = data.attack_types || [];
if (attackTypes.length === 0) { if (attackTypes.length === 0) {
canvas.style.display = 'none'; canvas.parentElement.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#8b949e;font-size:13px;">No attack data</div>';
return; return;
} }
@@ -63,13 +74,14 @@ async function loadAttackTypesChart() {
const borderColors = labels.map(label => generateColorFromHash(label).border); const borderColors = labels.map(label => generateColorFromHash(label).border);
const hoverColors = labels.map(label => generateColorFromHash(label).hover); const hoverColors = labels.map(label => generateColorFromHash(label).hover);
// Create or update chart // Create or update chart (track per canvas)
if (attackTypesChart) { if (!loadAttackTypesChart._instances) loadAttackTypesChart._instances = {};
attackTypesChart.destroy(); if (loadAttackTypesChart._instances[canvasId]) {
loadAttackTypesChart._instances[canvasId].destroy();
} }
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
attackTypesChart = new Chart(ctx, { const chartInstance = new Chart(ctx, {
type: 'doughnut', type: 'doughnut',
data: { data: {
labels: labels, labels: labels,
@@ -88,7 +100,7 @@ async function loadAttackTypesChart() {
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { plugins: {
legend: { legend: {
position: 'right', position: legendPosition,
labels: { labels: {
color: '#c9d1d9', color: '#c9d1d9',
font: { font: {
@@ -160,6 +172,8 @@ async function loadAttackTypesChart() {
}] }]
}); });
loadAttackTypesChart._instances[canvasId] = chartInstance;
attackTypesChart = chartInstance;
attackTypesChartLoaded = true; attackTypesChartLoaded = true;
} catch (err) { } catch (err) {
console.error('Error loading attack types chart:', err); console.error('Error loading attack types chart:', err);

View File

@@ -17,19 +17,26 @@ document.addEventListener('alpine:init', () => {
// Chart state // Chart state
chartLoaded: false, chartLoaded: false,
// IP Insight state
insightIp: null,
init() { init() {
// Handle hash-based tab routing // Handle hash-based tab routing
const hash = window.location.hash.slice(1); const hash = window.location.hash.slice(1);
if (hash === 'ip-stats' || hash === 'attacks') { if (hash === 'ip-stats' || hash === 'attacks') {
this.switchToAttacks(); this.switchToAttacks();
} }
// ip-insight tab is only accessible via lens buttons, not direct hash navigation
window.addEventListener('hashchange', () => { window.addEventListener('hashchange', () => {
const h = window.location.hash.slice(1); const h = window.location.hash.slice(1);
if (h === 'ip-stats' || h === 'attacks') { if (h === 'ip-stats' || h === 'attacks') {
this.switchToAttacks(); this.switchToAttacks();
} else { } else if (h !== 'ip-insight') {
this.switchToOverview(); // 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'; this.tab = 'attacks';
window.location.hash = '#ip-stats'; window.location.hash = '#ip-stats';
// Delay initialization to ensure the container is visible and // Delay chart initialization to ensure the container is visible
// the browser has reflowed after x-show removes display:none.
// Leaflet and Chart.js need visible containers with real dimensions.
this.$nextTick(() => { this.$nextTick(() => {
setTimeout(() => { setTimeout(() => {
if (!this.mapInitialized && typeof initializeAttackerMap === 'function') {
initializeAttackerMap();
this.mapInitialized = true;
}
if (!this.chartLoaded && typeof loadAttackTypesChart === 'function') { if (!this.chartLoaded && typeof loadAttackTypesChart === 'function') {
loadAttackTypesChart(); loadAttackTypesChart();
this.chartLoaded = true; this.chartLoaded = true;
@@ -60,6 +61,31 @@ document.addEventListener('alpine:init', () => {
window.location.hash = '#overview'; 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) { async viewRawRequest(logId) {
try { try {
const resp = await fetch( 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) // Utility function for formatting timestamps (used by map popups)
function formatTimestamp(isoTimestamp) { function formatTimestamp(isoTimestamp) {
if (!isoTimestamp) return 'N/A'; 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`); 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 size = Math.max(20, Math.min(44, 20 + Math.log2(total) * 4));
const inner = size - 10; const centerSize = size - 8;
const offset = 5; // (size - inner) / 2 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({ return L.divIcon({
html: `<div style="position:relative;width:${size}px;height:${size}px;">` + 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>` + `<svg width="${size}" height="${size}" style="position:absolute;top:0;left:0;overflow:visible;">` +
`<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>` + `<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>`, `</div>`,
className: 'ip-cluster-icon', className: 'ip-cluster-icon',
iconSize: L.point(size, size) iconSize: L.point(size, size)
@@ -180,11 +211,11 @@ function buildMapMarkers(ips) {
// Single cluster group with custom pie-chart icons // Single cluster group with custom pie-chart icons
clusterGroup = L.markerClusterGroup({ clusterGroup = L.markerClusterGroup({
maxClusterRadius: 20, maxClusterRadius: 35,
spiderfyOnMaxZoom: true, spiderfyOnMaxZoom: true,
showCoverageOnHover: false, showCoverageOnHover: false,
zoomToBoundsOnClick: true, zoomToBoundsOnClick: true,
disableClusteringAtZoom: 10, disableClusteringAtZoom: 8,
iconCreateFunction: createClusterIcon iconCreateFunction: createClusterIcon
}); });
@@ -284,8 +315,13 @@ function buildMapMarkers(ips) {
let popupContent = ` let popupContent = `
<div style="padding: 12px; min-width: 200px;"> <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> <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;"> <span style="background: ${categoryColor}1a; color: ${categoryColor}; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600;">
${categoryLabels[category]} ${categoryLabels[category]}
</span> </span>
@@ -315,8 +351,13 @@ function buildMapMarkers(ips) {
console.error('Error fetching IP stats:', err); console.error('Error fetching IP stats:', err);
const errorPopup = ` const errorPopup = `
<div style="padding: 12px; min-width: 280px; max-width: 320px;"> <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> <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;"> <span style="background: ${categoryColor}1a; color: ${categoryColor}; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600;">
${categoryLabels[category]} ${categoryLabels[category]}
</span> </span>

View File

@@ -11,11 +11,13 @@
* @param {Object} categoryScores - Object with keys: attacker, good_crawler, bad_crawler, regular_user, unknown * @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 {number} [size=200] - Width/height of the SVG in pixels
* @param {boolean} [showLegend=true] - Whether to show the legend below the chart * @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 * @returns {string} HTML string containing the SVG radar chart
*/ */
function generateRadarChart(categoryScores, size, showLegend) { function generateRadarChart(categoryScores, size, showLegend, legendPosition) {
size = size || 200; size = size || 200;
if (showLegend === undefined) showLegend = true; if (showLegend === undefined) showLegend = true;
legendPosition = legendPosition || 'below';
if (!categoryScores || Object.keys(categoryScores).length === 0) { if (!categoryScores || Object.keys(categoryScores).length === 0) {
return '<div style="color: #8b949e; text-align: center; padding: 20px;">No category data available</div>'; 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; 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;">`; html += `<svg class="radar-chart" viewBox="-30 -30 260 260" preserveAspectRatio="xMidYMid meet" style="width: ${size}px; height: ${size}px;">`;
// Draw concentric circles (grid) // 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__": if __name__ == "__main__":
import sys 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 # Add --no-cleanup flag to skip database cleanup
cleanup = "--no-cleanup" not in sys.argv 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( generate_fake_data(
num_ips, num_ips,