Merge pull request #107 from BlessedRebuS/feat/dashboard-single-ip-page
Feat/dashboard single ip page
This commit is contained in:
2
.github/workflows/security-scan.yml
vendored
2
.github/workflows/security-scan.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
run: safety check --json || true
|
||||
|
||||
- name: Trivy vulnerability scan
|
||||
uses: aquasecurity/trivy-action@master
|
||||
uses: aquasecurity/trivy-action@0.31.0
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: '.'
|
||||
|
||||
@@ -33,6 +33,9 @@ backups:
|
||||
exports:
|
||||
path: "exports"
|
||||
|
||||
logging:
|
||||
level: "DEBUG" # DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
|
||||
database:
|
||||
path: "data/krawl.db"
|
||||
retention_days: 30
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: krawl-chart
|
||||
description: A Helm chart for Krawl honeypot server
|
||||
type: application
|
||||
version: 1.0.9
|
||||
appVersion: 1.0.9
|
||||
version: 1.0.10
|
||||
appVersion: 1.0.10
|
||||
keywords:
|
||||
- honeypot
|
||||
- security
|
||||
|
||||
@@ -28,6 +28,8 @@ data:
|
||||
enabled: {{ .Values.config.backups.enabled }}
|
||||
exports:
|
||||
path: {{ .Values.config.exports.path | quote }}
|
||||
logging:
|
||||
level: {{ .Values.config.logging.level | quote }}
|
||||
database:
|
||||
path: {{ .Values.config.database.path | quote }}
|
||||
retention_days: {{ .Values.config.database.retention_days }}
|
||||
|
||||
@@ -8,6 +8,8 @@ spec:
|
||||
{{- if not .Values.autoscaling.enabled }}
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
{{- end }}
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "krawl.selectorLabels" . | nindent 6 }}
|
||||
@@ -29,7 +31,7 @@ spec:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
- name: krawl
|
||||
{{- with .Values.securityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
|
||||
@@ -86,10 +86,12 @@ config:
|
||||
secret_path: null # Auto-generated if not set, or set to "/my-secret-dashboard"
|
||||
backups:
|
||||
path: "backups"
|
||||
enabled: true
|
||||
enabled: false
|
||||
cron: "*/30 * * * *"
|
||||
exports:
|
||||
path: "exports"
|
||||
logging:
|
||||
level: "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
database:
|
||||
path: "data/krawl.db"
|
||||
retention_days: 30
|
||||
|
||||
@@ -68,6 +68,14 @@ data:
|
||||
token_tries: 10
|
||||
dashboard:
|
||||
secret_path: null
|
||||
backups:
|
||||
path: "backups"
|
||||
cron: "*/30 * * * *"
|
||||
enabled: false
|
||||
exports:
|
||||
path: "exports"
|
||||
logging:
|
||||
level: "INFO"
|
||||
database:
|
||||
path: "data/krawl.db"
|
||||
retention_days: 30
|
||||
@@ -154,6 +162,8 @@ metadata:
|
||||
app.kubernetes.io/version: "1.0.0"
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: krawl
|
||||
|
||||
@@ -26,6 +26,14 @@ data:
|
||||
token_tries: 10
|
||||
dashboard:
|
||||
secret_path: null
|
||||
backups:
|
||||
path: "backups"
|
||||
cron: "*/30 * * * *"
|
||||
enabled: false
|
||||
exports:
|
||||
path: "exports"
|
||||
logging:
|
||||
level: "INFO"
|
||||
database:
|
||||
path: "data/krawl.db"
|
||||
retention_days: 30
|
||||
|
||||
@@ -10,6 +10,8 @@ metadata:
|
||||
app.kubernetes.io/version: "1.0.0"
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: krawl
|
||||
|
||||
@@ -26,7 +26,7 @@ async def lifespan(app: FastAPI):
|
||||
config = get_config()
|
||||
|
||||
# Initialize logging
|
||||
initialize_logging()
|
||||
initialize_logging(log_level=config.log_level)
|
||||
app_logger = get_app_logger()
|
||||
|
||||
# Initialize database and run pending migrations before accepting traffic
|
||||
|
||||
@@ -56,6 +56,8 @@ class Config:
|
||||
user_agents_used_threshold: float = None
|
||||
attack_urls_threshold: float = None
|
||||
|
||||
log_level: str = "INFO"
|
||||
|
||||
_server_ip: Optional[str] = None
|
||||
_server_ip_cache_time: float = 0
|
||||
_ip_cache_ttl: int = 300
|
||||
@@ -163,6 +165,7 @@ class Config:
|
||||
behavior = data.get("behavior", {})
|
||||
analyzer = data.get("analyzer") or {}
|
||||
crawl = data.get("crawl", {})
|
||||
logging_cfg = data.get("logging", {})
|
||||
|
||||
# Handle dashboard_secret_path - auto-generate if null/not set
|
||||
dashboard_path = dashboard.get("secret_path")
|
||||
@@ -217,6 +220,9 @@ class Config:
|
||||
),
|
||||
max_pages_limit=crawl.get("max_pages_limit", 250),
|
||||
ban_duration_seconds=crawl.get("ban_duration_seconds", 600),
|
||||
log_level=os.getenv(
|
||||
"KRAWL_LOG_LEVEL", logging_cfg.get("level", "INFO")
|
||||
).upper(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
507
src/database.py
507
src/database.py
@@ -815,8 +815,8 @@ class DatabaseManager:
|
||||
def flag_stale_ips_for_reevaluation(self) -> int:
|
||||
"""
|
||||
Flag IPs for reevaluation where:
|
||||
- last_seen is between 15 and 30 days ago
|
||||
- last_analysis is more than 10 days ago (or never analyzed)
|
||||
- last_seen is between 5 and 30 days ago
|
||||
- last_analysis is more than 5 days ago
|
||||
|
||||
Returns:
|
||||
Number of IPs flagged for reevaluation
|
||||
@@ -825,18 +825,15 @@ class DatabaseManager:
|
||||
try:
|
||||
now = datetime.now()
|
||||
last_seen_lower = now - timedelta(days=30)
|
||||
last_seen_upper = now - timedelta(days=15)
|
||||
last_analysis_cutoff = now - timedelta(days=10)
|
||||
last_seen_upper = now - timedelta(days=5)
|
||||
last_analysis_cutoff = now - timedelta(days=5)
|
||||
|
||||
count = (
|
||||
session.query(IpStats)
|
||||
.filter(
|
||||
IpStats.last_seen >= last_seen_lower,
|
||||
IpStats.last_seen <= last_seen_upper,
|
||||
or_(
|
||||
IpStats.last_analysis <= last_analysis_cutoff,
|
||||
IpStats.last_analysis.is_(None),
|
||||
),
|
||||
IpStats.last_analysis <= last_analysis_cutoff,
|
||||
IpStats.need_reevaluation == False,
|
||||
IpStats.manual_category == False,
|
||||
)
|
||||
@@ -850,6 +847,99 @@ class DatabaseManager:
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
def flag_all_ips_for_reevaluation(self) -> int:
|
||||
"""
|
||||
Flag ALL IPs for reevaluation, regardless of staleness.
|
||||
Skips IPs that have a manual category set.
|
||||
|
||||
Returns:
|
||||
Number of IPs flagged for reevaluation
|
||||
"""
|
||||
session = self.session
|
||||
try:
|
||||
count = (
|
||||
session.query(IpStats)
|
||||
.filter(
|
||||
IpStats.need_reevaluation == False,
|
||||
IpStats.manual_category == False,
|
||||
)
|
||||
.update(
|
||||
{IpStats.need_reevaluation: True},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return count
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
def get_access_logs_paginated(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
ip_filter: Optional[str] = None,
|
||||
suspicious_only: bool = False,
|
||||
since_minutes: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Retrieve access logs with pagination and optional filtering.
|
||||
|
||||
Args:
|
||||
page: Page to retrieve
|
||||
page_size: Number of records for page
|
||||
ip_filter: Filter by IP address
|
||||
suspicious_only: Only return suspicious requests
|
||||
since_minutes: Only return logs from the last N minutes
|
||||
|
||||
Returns:
|
||||
List of access log dictionaries
|
||||
"""
|
||||
session = self.session
|
||||
try:
|
||||
offset = (page - 1) * page_size
|
||||
query = session.query(AccessLog).order_by(AccessLog.timestamp.desc())
|
||||
|
||||
if ip_filter:
|
||||
query = query.filter(AccessLog.ip == sanitize_ip(ip_filter))
|
||||
if suspicious_only:
|
||||
query = query.filter(AccessLog.is_suspicious == True)
|
||||
if since_minutes is not None:
|
||||
cutoff_time = datetime.now() - timedelta(minutes=since_minutes)
|
||||
query = query.filter(AccessLog.timestamp >= cutoff_time)
|
||||
|
||||
logs = query.offset(offset).limit(page_size).all()
|
||||
# Get total count of attackers
|
||||
total_access_logs = (
|
||||
session.query(AccessLog)
|
||||
.filter(AccessLog.ip == sanitize_ip(ip_filter))
|
||||
.count()
|
||||
)
|
||||
total_pages = (total_access_logs + page_size - 1) // page_size
|
||||
|
||||
return {
|
||||
"access_logs": [
|
||||
{
|
||||
"id": log.id,
|
||||
"ip": log.ip,
|
||||
"path": log.path,
|
||||
"user_agent": log.user_agent,
|
||||
"method": log.method,
|
||||
"is_suspicious": log.is_suspicious,
|
||||
"is_honeypot_trigger": log.is_honeypot_trigger,
|
||||
"timestamp": log.timestamp.isoformat(),
|
||||
"attack_types": [d.attack_type for d in log.attack_detections],
|
||||
}
|
||||
for log in logs
|
||||
],
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_logs": total_access_logs,
|
||||
"total_pages": total_pages,
|
||||
},
|
||||
}
|
||||
finally:
|
||||
self.close_session()
|
||||
|
||||
@@ -1018,6 +1108,8 @@ class DatabaseManager:
|
||||
"region": stat.region,
|
||||
"region_name": stat.region_name,
|
||||
"timezone": stat.timezone,
|
||||
"latitude": stat.latitude,
|
||||
"longitude": stat.longitude,
|
||||
"isp": stat.isp,
|
||||
"reverse": stat.reverse,
|
||||
"asn": stat.asn,
|
||||
@@ -1316,26 +1408,16 @@ class DatabaseManager:
|
||||
"""
|
||||
session = self.session
|
||||
try:
|
||||
# Get server IP to filter it out
|
||||
from config import get_config
|
||||
|
||||
config = get_config()
|
||||
server_ip = config.get_server_ip()
|
||||
|
||||
results = (
|
||||
session.query(AccessLog.ip, func.count(AccessLog.id).label("count"))
|
||||
.group_by(AccessLog.ip)
|
||||
.order_by(func.count(AccessLog.id).desc())
|
||||
.all()
|
||||
)
|
||||
query = session.query(IpStats.ip, IpStats.total_requests)
|
||||
query = self._public_ip_filter(query, IpStats.ip, server_ip)
|
||||
results = query.order_by(IpStats.total_requests.desc()).limit(limit).all()
|
||||
|
||||
# Filter out local/private IPs and server IP, then limit results
|
||||
filtered = [
|
||||
(row.ip, row.count)
|
||||
for row in results
|
||||
if is_valid_public_ip(row.ip, server_ip)
|
||||
]
|
||||
return filtered[:limit]
|
||||
return [(row.ip, row.total_requests) for row in results]
|
||||
finally:
|
||||
self.close_session()
|
||||
|
||||
@@ -1402,23 +1484,18 @@ class DatabaseManager:
|
||||
"""
|
||||
session = self.session
|
||||
try:
|
||||
# Get server IP to filter it out
|
||||
from config import get_config
|
||||
|
||||
config = get_config()
|
||||
server_ip = config.get_server_ip()
|
||||
|
||||
logs = (
|
||||
query = (
|
||||
session.query(AccessLog)
|
||||
.filter(AccessLog.is_suspicious == True)
|
||||
.order_by(AccessLog.timestamp.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# Filter out local/private IPs and server IP
|
||||
filtered_logs = [
|
||||
log for log in logs if is_valid_public_ip(log.ip, server_ip)
|
||||
]
|
||||
query = self._public_ip_filter(query, AccessLog.ip, server_ip)
|
||||
logs = query.limit(limit).all()
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -1427,7 +1504,7 @@ class DatabaseManager:
|
||||
"user_agent": log.user_agent,
|
||||
"timestamp": log.timestamp.isoformat(),
|
||||
}
|
||||
for log in filtered_logs[:limit]
|
||||
for log in logs
|
||||
]
|
||||
finally:
|
||||
self.close_session()
|
||||
@@ -1532,44 +1609,59 @@ class DatabaseManager:
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# Get honeypot triggers grouped by IP
|
||||
results = (
|
||||
session.query(AccessLog.ip, AccessLog.path)
|
||||
.filter(AccessLog.is_honeypot_trigger == True)
|
||||
.all()
|
||||
# Count distinct paths per IP using SQL GROUP BY
|
||||
count_col = func.count(distinct(AccessLog.path)).label("path_count")
|
||||
base_query = session.query(AccessLog.ip, count_col).filter(
|
||||
AccessLog.is_honeypot_trigger == True
|
||||
)
|
||||
base_query = self._public_ip_filter(base_query, AccessLog.ip, server_ip)
|
||||
base_query = base_query.group_by(AccessLog.ip)
|
||||
|
||||
# Get total count of distinct honeypot IPs
|
||||
total_honeypots = base_query.count()
|
||||
|
||||
# Apply sorting
|
||||
if sort_by == "count":
|
||||
order_expr = (
|
||||
count_col.desc() if sort_order == "desc" else count_col.asc()
|
||||
)
|
||||
else:
|
||||
order_expr = (
|
||||
AccessLog.ip.desc() if sort_order == "desc" else AccessLog.ip.asc()
|
||||
)
|
||||
|
||||
ip_rows = (
|
||||
base_query.order_by(order_expr).offset(offset).limit(page_size).all()
|
||||
)
|
||||
|
||||
# Group paths by IP, filtering out invalid IPs
|
||||
ip_paths: Dict[str, List[str]] = {}
|
||||
for row in results:
|
||||
if not is_valid_public_ip(row.ip, server_ip):
|
||||
continue
|
||||
if row.ip not in ip_paths:
|
||||
ip_paths[row.ip] = []
|
||||
if row.path not in ip_paths[row.ip]:
|
||||
ip_paths[row.ip].append(row.path)
|
||||
|
||||
# Create list and sort
|
||||
honeypot_list = [
|
||||
{"ip": ip, "paths": paths, "count": len(paths)}
|
||||
for ip, paths in ip_paths.items()
|
||||
]
|
||||
|
||||
if sort_by == "count":
|
||||
honeypot_list.sort(
|
||||
key=lambda x: x["count"], reverse=(sort_order == "desc")
|
||||
)
|
||||
else: # sort by ip
|
||||
honeypot_list.sort(
|
||||
key=lambda x: x["ip"], reverse=(sort_order == "desc")
|
||||
# Fetch distinct paths only for the paginated IPs
|
||||
paginated_ips = [row.ip for row in ip_rows]
|
||||
honeypot_list = []
|
||||
if paginated_ips:
|
||||
path_rows = (
|
||||
session.query(AccessLog.ip, AccessLog.path)
|
||||
.filter(
|
||||
AccessLog.is_honeypot_trigger == True,
|
||||
AccessLog.ip.in_(paginated_ips),
|
||||
)
|
||||
.distinct(AccessLog.ip, AccessLog.path)
|
||||
.all()
|
||||
)
|
||||
ip_paths: Dict[str, List[str]] = {}
|
||||
for row in path_rows:
|
||||
ip_paths.setdefault(row.ip, []).append(row.path)
|
||||
|
||||
total_honeypots = len(honeypot_list)
|
||||
paginated = honeypot_list[offset : offset + page_size]
|
||||
total_pages = (total_honeypots + page_size - 1) // page_size
|
||||
# Preserve the order from the sorted query
|
||||
for row in ip_rows:
|
||||
paths = ip_paths.get(row.ip, [])
|
||||
honeypot_list.append(
|
||||
{"ip": row.ip, "paths": paths, "count": row.path_count}
|
||||
)
|
||||
|
||||
total_pages = max(1, (total_honeypots + page_size - 1) // page_size)
|
||||
|
||||
return {
|
||||
"honeypots": paginated,
|
||||
"honeypots": honeypot_list,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
@@ -1668,6 +1760,9 @@ class DatabaseManager:
|
||||
"""
|
||||
Retrieve paginated list of top IP addresses by access count.
|
||||
|
||||
Uses the IpStats table (which already stores total_requests per IP)
|
||||
instead of doing a costly GROUP BY on the large access_logs table.
|
||||
|
||||
Args:
|
||||
page: Page number (1-indexed)
|
||||
page_size: Number of results per page
|
||||
@@ -1686,30 +1781,34 @@ class DatabaseManager:
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
results = (
|
||||
session.query(AccessLog.ip, func.count(AccessLog.id).label("count"))
|
||||
.group_by(AccessLog.ip)
|
||||
.all()
|
||||
)
|
||||
base_query = session.query(IpStats)
|
||||
base_query = self._public_ip_filter(base_query, IpStats.ip, server_ip)
|
||||
|
||||
# Filter out local/private IPs and server IP, then sort
|
||||
filtered = [
|
||||
{"ip": row.ip, "count": row.count}
|
||||
for row in results
|
||||
if is_valid_public_ip(row.ip, server_ip)
|
||||
]
|
||||
total_ips = base_query.count()
|
||||
|
||||
if sort_by == "count":
|
||||
filtered.sort(key=lambda x: x["count"], reverse=(sort_order == "desc"))
|
||||
else: # sort by ip
|
||||
filtered.sort(key=lambda x: x["ip"], reverse=(sort_order == "desc"))
|
||||
order_col = IpStats.total_requests
|
||||
else:
|
||||
order_col = IpStats.ip
|
||||
|
||||
total_ips = len(filtered)
|
||||
paginated = filtered[offset : offset + page_size]
|
||||
total_pages = (total_ips + page_size - 1) // page_size
|
||||
if sort_order == "desc":
|
||||
base_query = base_query.order_by(order_col.desc())
|
||||
else:
|
||||
base_query = base_query.order_by(order_col.asc())
|
||||
|
||||
results = base_query.offset(offset).limit(page_size).all()
|
||||
|
||||
total_pages = max(1, (total_ips + page_size - 1) // page_size)
|
||||
|
||||
return {
|
||||
"ips": paginated,
|
||||
"ips": [
|
||||
{
|
||||
"ip": row.ip,
|
||||
"count": row.total_requests,
|
||||
"category": row.category or "unknown",
|
||||
}
|
||||
for row in results
|
||||
],
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
@@ -1743,28 +1842,32 @@ class DatabaseManager:
|
||||
try:
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
results = (
|
||||
session.query(AccessLog.path, func.count(AccessLog.id).label("count"))
|
||||
.group_by(AccessLog.path)
|
||||
.all()
|
||||
count_col = func.count(AccessLog.id).label("count")
|
||||
|
||||
# Get total number of distinct paths
|
||||
total_paths = (
|
||||
session.query(func.count(distinct(AccessLog.path))).scalar() or 0
|
||||
)
|
||||
|
||||
# Create list and sort
|
||||
paths_list = [{"path": row.path, "count": row.count} for row in results]
|
||||
# Build query with SQL-level sorting and pagination
|
||||
query = session.query(AccessLog.path, count_col).group_by(AccessLog.path)
|
||||
|
||||
if sort_by == "count":
|
||||
paths_list.sort(
|
||||
key=lambda x: x["count"], reverse=(sort_order == "desc")
|
||||
order_expr = (
|
||||
count_col.desc() if sort_order == "desc" else count_col.asc()
|
||||
)
|
||||
else:
|
||||
order_expr = (
|
||||
AccessLog.path.desc()
|
||||
if sort_order == "desc"
|
||||
else AccessLog.path.asc()
|
||||
)
|
||||
else: # sort by path
|
||||
paths_list.sort(key=lambda x: x["path"], reverse=(sort_order == "desc"))
|
||||
|
||||
total_paths = len(paths_list)
|
||||
paginated = paths_list[offset : offset + page_size]
|
||||
total_pages = (total_paths + page_size - 1) // page_size
|
||||
results = query.order_by(order_expr).offset(offset).limit(page_size).all()
|
||||
total_pages = max(1, (total_paths + page_size - 1) // page_size)
|
||||
|
||||
return {
|
||||
"paths": paginated,
|
||||
"paths": [{"path": row.path, "count": row.count} for row in results],
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
@@ -1798,33 +1901,44 @@ class DatabaseManager:
|
||||
try:
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
results = (
|
||||
session.query(
|
||||
AccessLog.user_agent, func.count(AccessLog.id).label("count")
|
||||
)
|
||||
.filter(AccessLog.user_agent.isnot(None), AccessLog.user_agent != "")
|
||||
.group_by(AccessLog.user_agent)
|
||||
.all()
|
||||
count_col = func.count(AccessLog.id).label("count")
|
||||
|
||||
base_filter = [AccessLog.user_agent.isnot(None), AccessLog.user_agent != ""]
|
||||
|
||||
# Get total number of distinct user agents
|
||||
total_uas = (
|
||||
session.query(func.count(distinct(AccessLog.user_agent)))
|
||||
.filter(*base_filter)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Create list and sort
|
||||
ua_list = [
|
||||
{"user_agent": row.user_agent, "count": row.count} for row in results
|
||||
]
|
||||
# Build query with SQL-level sorting and pagination
|
||||
query = (
|
||||
session.query(AccessLog.user_agent, count_col)
|
||||
.filter(*base_filter)
|
||||
.group_by(AccessLog.user_agent)
|
||||
)
|
||||
|
||||
if sort_by == "count":
|
||||
ua_list.sort(key=lambda x: x["count"], reverse=(sort_order == "desc"))
|
||||
else: # sort by user_agent
|
||||
ua_list.sort(
|
||||
key=lambda x: x["user_agent"], reverse=(sort_order == "desc")
|
||||
order_expr = (
|
||||
count_col.desc() if sort_order == "desc" else count_col.asc()
|
||||
)
|
||||
else:
|
||||
order_expr = (
|
||||
AccessLog.user_agent.desc()
|
||||
if sort_order == "desc"
|
||||
else AccessLog.user_agent.asc()
|
||||
)
|
||||
|
||||
total_uas = len(ua_list)
|
||||
paginated = ua_list[offset : offset + page_size]
|
||||
total_pages = (total_uas + page_size - 1) // page_size
|
||||
results = query.order_by(order_expr).offset(offset).limit(page_size).all()
|
||||
total_pages = max(1, (total_uas + page_size - 1) // page_size)
|
||||
|
||||
return {
|
||||
"user_agents": paginated,
|
||||
"user_agents": [
|
||||
{"user_agent": row.user_agent, "count": row.count}
|
||||
for row in results
|
||||
],
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
@@ -1841,6 +1955,7 @@ class DatabaseManager:
|
||||
page_size: int = 5,
|
||||
sort_by: str = "timestamp",
|
||||
sort_order: str = "desc",
|
||||
ip_filter: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Retrieve paginated list of detected attack types with access logs.
|
||||
@@ -1850,6 +1965,7 @@ class DatabaseManager:
|
||||
page_size: Number of results per page
|
||||
sort_by: Field to sort by (timestamp, ip, attack_type)
|
||||
sort_order: Sort order (asc or desc)
|
||||
ip_filter: Optional IP address to filter results
|
||||
|
||||
Returns:
|
||||
Dictionary with attacks list and pagination info
|
||||
@@ -1865,18 +1981,22 @@ class DatabaseManager:
|
||||
sort_order.lower() if sort_order.lower() in {"asc", "desc"} else "desc"
|
||||
)
|
||||
|
||||
# Base query filter
|
||||
base_filters = []
|
||||
if ip_filter:
|
||||
base_filters.append(AccessLog.ip == ip_filter)
|
||||
|
||||
# Count total unique access logs with attack detections
|
||||
total_attacks = (
|
||||
session.query(AccessLog)
|
||||
.join(AttackDetection)
|
||||
.distinct(AccessLog.id)
|
||||
.count()
|
||||
)
|
||||
count_query = session.query(AccessLog).join(AttackDetection)
|
||||
if base_filters:
|
||||
count_query = count_query.filter(*base_filters)
|
||||
total_attacks = count_query.distinct(AccessLog.id).count()
|
||||
|
||||
# Get paginated access logs with attack detections
|
||||
query = (
|
||||
session.query(AccessLog).join(AttackDetection).distinct(AccessLog.id)
|
||||
)
|
||||
query = session.query(AccessLog).join(AttackDetection)
|
||||
if base_filters:
|
||||
query = query.filter(*base_filters)
|
||||
query = query.distinct(AccessLog.id)
|
||||
|
||||
if sort_by == "timestamp":
|
||||
query = query.order_by(
|
||||
@@ -1939,12 +2059,15 @@ class DatabaseManager:
|
||||
finally:
|
||||
self.close_session()
|
||||
|
||||
def get_attack_types_stats(self, limit: int = 20) -> Dict[str, Any]:
|
||||
def get_attack_types_stats(
|
||||
self, limit: int = 20, ip_filter: str | None = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get aggregated statistics for attack types (efficient for large datasets).
|
||||
|
||||
Args:
|
||||
limit: Maximum number of attack types to return
|
||||
ip_filter: Optional IP address to filter results for
|
||||
|
||||
Returns:
|
||||
Dictionary with attack type counts
|
||||
@@ -1954,12 +2077,18 @@ class DatabaseManager:
|
||||
from sqlalchemy import func
|
||||
|
||||
# Aggregate attack types with count
|
||||
query = session.query(
|
||||
AttackDetection.attack_type,
|
||||
func.count(AttackDetection.id).label("count"),
|
||||
)
|
||||
|
||||
if ip_filter:
|
||||
query = query.join(
|
||||
AccessLog, AttackDetection.access_log_id == AccessLog.id
|
||||
).filter(AccessLog.ip == ip_filter)
|
||||
|
||||
results = (
|
||||
session.query(
|
||||
AttackDetection.attack_type,
|
||||
func.count(AttackDetection.id).label("count"),
|
||||
)
|
||||
.group_by(AttackDetection.attack_type)
|
||||
query.group_by(AttackDetection.attack_type)
|
||||
.order_by(func.count(AttackDetection.id).desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
@@ -1973,6 +2102,126 @@ class DatabaseManager:
|
||||
finally:
|
||||
self.close_session()
|
||||
|
||||
def search_attacks_and_ips(
|
||||
self,
|
||||
query: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Search attacks and IPs matching a query string.
|
||||
|
||||
Searches across AttackDetection (attack_type, matched_pattern),
|
||||
AccessLog (ip, path), and IpStats (ip, city, country, isp, asn_org).
|
||||
|
||||
Args:
|
||||
query: Search term (partial match)
|
||||
page: Page number (1-indexed)
|
||||
page_size: Results per page
|
||||
|
||||
Returns:
|
||||
Dictionary with matching attacks, ips, and pagination info
|
||||
"""
|
||||
session = self.session
|
||||
try:
|
||||
offset = (page - 1) * page_size
|
||||
like_q = f"%{query}%"
|
||||
|
||||
# --- Search attacks (AccessLog + AttackDetection) ---
|
||||
attack_query = (
|
||||
session.query(AccessLog)
|
||||
.join(AttackDetection)
|
||||
.filter(
|
||||
or_(
|
||||
AccessLog.ip.ilike(like_q),
|
||||
AccessLog.path.ilike(like_q),
|
||||
AttackDetection.attack_type.ilike(like_q),
|
||||
AttackDetection.matched_pattern.ilike(like_q),
|
||||
)
|
||||
)
|
||||
.distinct(AccessLog.id)
|
||||
)
|
||||
|
||||
total_attacks = attack_query.count()
|
||||
attack_logs = (
|
||||
attack_query.order_by(AccessLog.timestamp.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
|
||||
attacks = [
|
||||
{
|
||||
"id": log.id,
|
||||
"ip": log.ip,
|
||||
"path": log.path,
|
||||
"user_agent": log.user_agent,
|
||||
"timestamp": log.timestamp.isoformat() if log.timestamp else None,
|
||||
"attack_types": [d.attack_type for d in log.attack_detections],
|
||||
"log_id": log.id,
|
||||
}
|
||||
for log in attack_logs
|
||||
]
|
||||
|
||||
# --- Search IPs (IpStats) ---
|
||||
ip_query = session.query(IpStats).filter(
|
||||
or_(
|
||||
IpStats.ip.ilike(like_q),
|
||||
IpStats.city.ilike(like_q),
|
||||
IpStats.country.ilike(like_q),
|
||||
IpStats.country_code.ilike(like_q),
|
||||
IpStats.isp.ilike(like_q),
|
||||
IpStats.asn_org.ilike(like_q),
|
||||
IpStats.reverse.ilike(like_q),
|
||||
)
|
||||
)
|
||||
|
||||
total_ips = ip_query.count()
|
||||
ips = (
|
||||
ip_query.order_by(IpStats.total_requests.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
|
||||
ip_results = [
|
||||
{
|
||||
"ip": stat.ip,
|
||||
"total_requests": stat.total_requests,
|
||||
"first_seen": (
|
||||
stat.first_seen.isoformat() if stat.first_seen else None
|
||||
),
|
||||
"last_seen": stat.last_seen.isoformat() if stat.last_seen else None,
|
||||
"country_code": stat.country_code,
|
||||
"city": stat.city,
|
||||
"category": stat.category,
|
||||
"isp": stat.isp,
|
||||
"asn_org": stat.asn_org,
|
||||
}
|
||||
for stat in ips
|
||||
]
|
||||
|
||||
total = total_attacks + total_ips
|
||||
total_pages = max(
|
||||
1, (max(total_attacks, total_ips) + page_size - 1) // page_size
|
||||
)
|
||||
|
||||
return {
|
||||
"attacks": attacks,
|
||||
"ips": ip_results,
|
||||
"query": query,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_attacks": total_attacks,
|
||||
"total_ips": total_ips,
|
||||
"total": total,
|
||||
"total_pages": total_pages,
|
||||
},
|
||||
}
|
||||
finally:
|
||||
self.close_session()
|
||||
|
||||
|
||||
# Module-level singleton instance
|
||||
_db_manager = DatabaseManager()
|
||||
|
||||
@@ -30,7 +30,7 @@ def get_templates() -> Jinja2Templates:
|
||||
return _templates
|
||||
|
||||
|
||||
def _format_ts(value):
|
||||
def _format_ts(value, time_only=False):
|
||||
"""Custom Jinja2 filter for formatting ISO timestamps."""
|
||||
if not value:
|
||||
return "N/A"
|
||||
@@ -39,6 +39,8 @@ def _format_ts(value):
|
||||
value = datetime.fromisoformat(value)
|
||||
except (ValueError, TypeError):
|
||||
return value
|
||||
if time_only:
|
||||
return value.strftime("%H:%M:%S")
|
||||
if value.date() == datetime.now().date():
|
||||
return value.strftime("%H:%M:%S")
|
||||
return value.strftime("%m/%d/%Y %H:%M:%S")
|
||||
|
||||
@@ -36,12 +36,13 @@ class LoggerManager:
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def initialize(self, log_dir: str = "logs") -> None:
|
||||
def initialize(self, log_dir: str = "logs", log_level: str = "INFO") -> None:
|
||||
"""
|
||||
Initialize the logging system with rotating file handlers.loggers
|
||||
|
||||
Args:
|
||||
log_dir: Directory for log files (created if not exists)
|
||||
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
"""
|
||||
if self._initialized:
|
||||
return
|
||||
@@ -59,9 +60,11 @@ class LoggerManager:
|
||||
max_bytes = 1048576 # 1MB
|
||||
backup_count = 5
|
||||
|
||||
level = getattr(logging, log_level.upper(), logging.INFO)
|
||||
|
||||
# Setup application logger
|
||||
self._app_logger = logging.getLogger("krawl.app")
|
||||
self._app_logger.setLevel(logging.INFO)
|
||||
self._app_logger.setLevel(level)
|
||||
self._app_logger.handlers.clear()
|
||||
|
||||
app_file_handler = RotatingFileHandler(
|
||||
@@ -78,7 +81,7 @@ class LoggerManager:
|
||||
|
||||
# Setup access logger
|
||||
self._access_logger = logging.getLogger("krawl.access")
|
||||
self._access_logger.setLevel(logging.INFO)
|
||||
self._access_logger.setLevel(level)
|
||||
self._access_logger.handlers.clear()
|
||||
|
||||
access_file_handler = RotatingFileHandler(
|
||||
@@ -95,7 +98,7 @@ class LoggerManager:
|
||||
|
||||
# Setup credential logger (special format, no stream handler)
|
||||
self._credential_logger = logging.getLogger("krawl.credentials")
|
||||
self._credential_logger.setLevel(logging.INFO)
|
||||
self._credential_logger.setLevel(level)
|
||||
self._credential_logger.handlers.clear()
|
||||
|
||||
# Credential logger uses a simple format: timestamp|ip|username|password|path
|
||||
@@ -152,6 +155,6 @@ def get_credential_logger() -> logging.Logger:
|
||||
return _logger_manager.credentials
|
||||
|
||||
|
||||
def initialize_logging(log_dir: str = "logs") -> None:
|
||||
def initialize_logging(log_dir: str = "logs", log_level: str = "INFO") -> None:
|
||||
"""Initialize the logging system."""
|
||||
_logger_manager.initialize(log_dir)
|
||||
_logger_manager.initialize(log_dir, log_level)
|
||||
|
||||
@@ -7,7 +7,6 @@ All endpoints are prefixed with the secret dashboard path.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Request, Response, Query
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
@@ -215,12 +214,13 @@ async def top_user_agents(
|
||||
async def attack_types_stats(
|
||||
request: Request,
|
||||
limit: int = Query(20),
|
||||
ip_filter: str = Query(None),
|
||||
):
|
||||
db = get_db()
|
||||
limit = min(max(1, limit), 100)
|
||||
|
||||
try:
|
||||
result = db.get_attack_types_stats(limit=limit)
|
||||
result = db.get_attack_types_stats(limit=limit, ip_filter=ip_filter)
|
||||
return JSONResponse(content=result, headers=_no_cache_headers())
|
||||
except Exception as e:
|
||||
get_app_logger().error(f"Error fetching attack types stats: {e}")
|
||||
|
||||
@@ -6,6 +6,8 @@ Renders the main dashboard page with server-side data for initial load.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from logger import get_app_logger
|
||||
|
||||
from dependencies import get_db, get_templates
|
||||
|
||||
@@ -21,7 +23,7 @@ async def dashboard_page(request: Request):
|
||||
|
||||
# Get initial data for server-rendered sections
|
||||
stats = db.get_dashboard_counts()
|
||||
suspicious = db.get_recent_suspicious(limit=20)
|
||||
suspicious = db.get_recent_suspicious(limit=10)
|
||||
|
||||
# Get credential count for the stats card
|
||||
cred_result = db.get_credentials_paginated(page=1, page_size=1)
|
||||
@@ -37,3 +39,36 @@ async def dashboard_page(request: Request):
|
||||
"suspicious_activities": suspicious,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ip/{ip_address:path}")
|
||||
async def ip_page(ip_address: str, request: Request):
|
||||
db = get_db()
|
||||
try:
|
||||
stats = db.get_ip_stats_by_ip(ip_address)
|
||||
config = request.app.state.config
|
||||
dashboard_path = "/" + config.dashboard_secret_path.lstrip("/")
|
||||
|
||||
if stats:
|
||||
# Transform fields for template compatibility
|
||||
list_on = stats.get("list_on") or {}
|
||||
stats["blocklist_memberships"] = list(list_on.keys()) if list_on else []
|
||||
stats["reverse_dns"] = stats.get("reverse")
|
||||
|
||||
templates = get_templates()
|
||||
return templates.TemplateResponse(
|
||||
"dashboard/ip.html",
|
||||
{
|
||||
"request": request,
|
||||
"dashboard_path": dashboard_path,
|
||||
"stats": stats,
|
||||
"ip_address": ip_address,
|
||||
},
|
||||
)
|
||||
else:
|
||||
return JSONResponse(
|
||||
content={"error": "IP not found"},
|
||||
)
|
||||
except Exception as e:
|
||||
get_app_logger().error(f"Error fetching IP stats: {e}")
|
||||
return JSONResponse(content={"error": str(e)})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
"""
|
||||
HTMX fragment endpoints.
|
||||
Server-rendered HTML partials for table pagination, sorting, and IP details.
|
||||
Server-rendered HTML partials for table pagination, sorting, IP details, and search.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Response, Query
|
||||
@@ -58,7 +58,7 @@ async def htmx_top_ips(
|
||||
):
|
||||
db = get_db()
|
||||
result = db.get_top_ips_paginated(
|
||||
page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order
|
||||
page=max(1, page), page_size=8, sort_by=sort_by, sort_order=sort_order
|
||||
)
|
||||
|
||||
templates = get_templates()
|
||||
@@ -167,6 +167,42 @@ async def htmx_attackers(
|
||||
)
|
||||
|
||||
|
||||
# ── Access logs by ip ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/htmx/access-logs")
|
||||
async def htmx_access_logs_by_ip(
|
||||
request: Request,
|
||||
page: int = Query(1),
|
||||
sort_by: str = Query("total_requests"),
|
||||
sort_order: str = Query("desc"),
|
||||
ip_filter: str = Query("ip_filter"),
|
||||
):
|
||||
db = get_db()
|
||||
result = db.get_access_logs_paginated(
|
||||
page=max(1, page), page_size=25, ip_filter=ip_filter
|
||||
)
|
||||
|
||||
# Normalize pagination key (DB returns total_attackers, template expects total)
|
||||
pagination = result["pagination"]
|
||||
if "total_access_logs" in pagination and "total" not in pagination:
|
||||
pagination["total"] = pagination["total_access_logs"]
|
||||
|
||||
templates = get_templates()
|
||||
return templates.TemplateResponse(
|
||||
"dashboard/partials/access_by_ip_table.html",
|
||||
{
|
||||
"request": request,
|
||||
"dashboard_path": _dashboard_path(request),
|
||||
"items": result["access_logs"],
|
||||
"pagination": pagination,
|
||||
"sort_by": sort_by,
|
||||
"sort_order": sort_order,
|
||||
"ip_filter": ip_filter,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── Credentials ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -205,10 +241,15 @@ async def htmx_attacks(
|
||||
page: int = Query(1),
|
||||
sort_by: str = Query("timestamp"),
|
||||
sort_order: str = Query("desc"),
|
||||
ip_filter: str = Query(None),
|
||||
):
|
||||
db = get_db()
|
||||
result = db.get_attack_types_paginated(
|
||||
page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order
|
||||
page=max(1, page),
|
||||
page_size=5,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
ip_filter=ip_filter,
|
||||
)
|
||||
|
||||
# Transform attack data for template (join attack_types list, map id to log_id)
|
||||
@@ -235,6 +276,7 @@ async def htmx_attacks(
|
||||
"pagination": result["pagination"],
|
||||
"sort_by": sort_by,
|
||||
"sort_order": sort_order,
|
||||
"ip_filter": ip_filter or "",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -280,6 +322,34 @@ async def htmx_patterns(
|
||||
)
|
||||
|
||||
|
||||
# ── IP Insight (full IP page as partial) ─────────────────────────────
|
||||
|
||||
|
||||
@router.get("/htmx/ip-insight/{ip_address:path}")
|
||||
async def htmx_ip_insight(ip_address: str, request: Request):
|
||||
db = get_db()
|
||||
stats = db.get_ip_stats_by_ip(ip_address)
|
||||
|
||||
if not stats:
|
||||
stats = {"ip": ip_address, "total_requests": "N/A"}
|
||||
|
||||
# Transform fields for template compatibility
|
||||
list_on = stats.get("list_on") or {}
|
||||
stats["blocklist_memberships"] = list(list_on.keys()) if list_on else []
|
||||
stats["reverse_dns"] = stats.get("reverse")
|
||||
|
||||
templates = get_templates()
|
||||
return templates.TemplateResponse(
|
||||
"dashboard/partials/ip_insight.html",
|
||||
{
|
||||
"request": request,
|
||||
"dashboard_path": _dashboard_path(request),
|
||||
"stats": stats,
|
||||
"ip_address": ip_address,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── IP Detail ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -305,3 +375,33 @@ async def htmx_ip_detail(ip_address: str, request: Request):
|
||||
"stats": stats,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── Search ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/htmx/search")
|
||||
async def htmx_search(
|
||||
request: Request,
|
||||
q: str = Query(""),
|
||||
page: int = Query(1),
|
||||
):
|
||||
q = q.strip()
|
||||
if not q:
|
||||
return Response(content="", media_type="text/html")
|
||||
|
||||
db = get_db()
|
||||
result = db.search_attacks_and_ips(query=q, page=max(1, page), page_size=20)
|
||||
|
||||
templates = get_templates()
|
||||
return templates.TemplateResponse(
|
||||
"dashboard/partials/search_results.html",
|
||||
{
|
||||
"request": request,
|
||||
"dashboard_path": _dashboard_path(request),
|
||||
"attacks": result["attacks"],
|
||||
"ips": result["ips"],
|
||||
"query": q,
|
||||
"pagination": result["pagination"],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -70,7 +70,7 @@ def main():
|
||||
"risky_http_methods": 6,
|
||||
"robots_violations": 4,
|
||||
"uneven_request_timing": 3,
|
||||
"different_user_agents": 8,
|
||||
"different_user_agents": 2,
|
||||
"attack_url": 15,
|
||||
},
|
||||
"good_crawler": {
|
||||
@@ -84,7 +84,7 @@ def main():
|
||||
"risky_http_methods": 2,
|
||||
"robots_violations": 7,
|
||||
"uneven_request_timing": 0,
|
||||
"different_user_agents": 5,
|
||||
"different_user_agents": 7,
|
||||
"attack_url": 5,
|
||||
},
|
||||
"regular_user": {
|
||||
|
||||
@@ -9,24 +9,37 @@ TASK_CONFIG = {
|
||||
"name": "flag-stale-ips",
|
||||
"cron": "0 2 * * *", # Run daily at 2 AM
|
||||
"enabled": True,
|
||||
"run_when_loaded": False,
|
||||
"run_when_loaded": True,
|
||||
}
|
||||
|
||||
# Set to True to force all IPs to be flagged for reevaluation on next run.
|
||||
# Resets to False automatically after execution.
|
||||
FORCE_IP_RESCAN = False
|
||||
|
||||
|
||||
def main():
|
||||
global FORCE_IP_RESCAN
|
||||
|
||||
app_logger = get_app_logger()
|
||||
db = get_database()
|
||||
|
||||
try:
|
||||
count = db.flag_stale_ips_for_reevaluation()
|
||||
if count > 0:
|
||||
if FORCE_IP_RESCAN:
|
||||
count = db.flag_all_ips_for_reevaluation()
|
||||
FORCE_IP_RESCAN = False
|
||||
app_logger.info(
|
||||
f"[Background Task] flag-stale-ips: Flagged {count} stale IPs for reevaluation"
|
||||
f"[Background Task] flag-stale-ips: FORCE RESCAN - Flagged {count} IPs for reevaluation"
|
||||
)
|
||||
else:
|
||||
app_logger.debug(
|
||||
"[Background Task] flag-stale-ips: No stale IPs found to flag"
|
||||
)
|
||||
count = db.flag_stale_ips_for_reevaluation()
|
||||
if count > 0:
|
||||
app_logger.info(
|
||||
f"[Background Task] flag-stale-ips: Flagged {count} stale IPs for reevaluation"
|
||||
)
|
||||
else:
|
||||
app_logger.debug(
|
||||
"[Background Task] flag-stale-ips: No stale IPs found to flag"
|
||||
)
|
||||
except Exception as e:
|
||||
app_logger.error(
|
||||
f"[Background Task] flag-stale-ips: Error flagging stale IPs: {e}"
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Krawl Dashboard</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ dashboard_path }}/static/krawl-svg.svg" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" crossorigin="anonymous" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/MarkerCluster.css" crossorigin="anonymous" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/MarkerCluster.Default.css" crossorigin="anonymous" />
|
||||
<link rel="stylesheet" href="{{ dashboard_path }}/static/vendor/css/leaflet.min.css" />
|
||||
<link rel="stylesheet" href="{{ dashboard_path }}/static/vendor/css/MarkerCluster.css" />
|
||||
<link rel="stylesheet" href="{{ dashboard_path }}/static/vendor/css/MarkerCluster.Default.css" />
|
||||
<link rel="stylesheet" href="{{ dashboard_path }}/static/css/dashboard.css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js" crossorigin="anonymous" defer></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/leaflet.markercluster.js" crossorigin="anonymous" defer></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js" defer></script>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
|
||||
<script src="{{ dashboard_path }}/static/vendor/js/leaflet.min.js" defer></script>
|
||||
<script src="{{ dashboard_path }}/static/vendor/js/leaflet.markercluster.js" defer></script>
|
||||
<script src="{{ dashboard_path }}/static/vendor/js/chart.min.js" defer></script>
|
||||
<script src="{{ dashboard_path }}/static/vendor/js/htmx.min.js" defer></script>
|
||||
<script defer src="{{ dashboard_path }}/static/vendor/js/alpine.min.js"></script>
|
||||
<script>window.__DASHBOARD_PATH__ = '{{ dashboard_path }}';</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -31,29 +31,45 @@
|
||||
{# Stats cards - server-rendered #}
|
||||
{% include "dashboard/partials/stats_cards.html" %}
|
||||
|
||||
{# Search bar #}
|
||||
<div class="search-bar-container">
|
||||
<div class="search-bar">
|
||||
<svg class="search-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<input id="search-input"
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder="Search attacks, IPs, patterns, locations..."
|
||||
autocomplete="off"
|
||||
hx-get="{{ dashboard_path }}/htmx/search"
|
||||
hx-trigger="input changed delay:300ms, search"
|
||||
hx-target="#search-results-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#search-spinner" />
|
||||
<span id="search-spinner" class="htmx-indicator search-spinner"></span>
|
||||
</div>
|
||||
<div id="search-results-container"></div>
|
||||
</div>
|
||||
|
||||
{# Tab navigation - Alpine.js #}
|
||||
<div class="tabs-container">
|
||||
<a class="tab-button" :class="{ active: tab === 'overview' }" @click.prevent="switchToOverview()" href="#overview">Overview</a>
|
||||
<a class="tab-button" :class="{ active: tab === 'attacks' }" @click.prevent="switchToAttacks()" href="#ip-stats">Attacks</a>
|
||||
<a class="tab-button" :class="{ active: tab === 'ip-insight', disabled: !insightIp }" @click.prevent="insightIp && switchToIpInsight()" href="#ip-insight">
|
||||
IP Insight<span x-show="insightIp" x-text="' (' + insightIp + ')'"></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# ==================== OVERVIEW TAB ==================== #}
|
||||
<div x-show="tab === 'overview'">
|
||||
<div x-show="tab === 'overview'" x-init="$nextTick(() => { if (!mapInitialized && typeof initializeAttackerMap === 'function') { initializeAttackerMap(); mapInitialized = true; } })">
|
||||
|
||||
{# Suspicious Activity - server-rendered #}
|
||||
{# Map section #}
|
||||
{% include "dashboard/partials/map_section.html" %}
|
||||
|
||||
{# Suspicious Activity - server-rendered (last 10 requests) #}
|
||||
{% include "dashboard/partials/suspicious_table.html" %}
|
||||
|
||||
{# Honeypot Triggers - HTMX loaded #}
|
||||
<div class="table-container alert-section">
|
||||
<h2>Honeypot Triggers by IP</h2>
|
||||
<div class="htmx-container"
|
||||
hx-get="{{ dashboard_path }}/htmx/honeypot?page=1"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="htmx-indicator">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Top IPs + Top User-Agents side by side #}
|
||||
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
|
||||
<div class="table-container" style="flex: 1; min-width: 300px;">
|
||||
@@ -91,9 +107,6 @@
|
||||
{# ==================== ATTACKS TAB ==================== #}
|
||||
<div x-show="tab === 'attacks'" x-cloak>
|
||||
|
||||
{# Map section #}
|
||||
{% include "dashboard/partials/map_section.html" %}
|
||||
|
||||
{# Attackers table - HTMX loaded #}
|
||||
<div class="table-container alert-section">
|
||||
<h2>Attackers by Total Requests</h2>
|
||||
@@ -116,6 +129,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Honeypot Triggers - HTMX loaded #}
|
||||
<div class="table-container alert-section">
|
||||
<h2>Honeypot Triggers by IP</h2>
|
||||
<div class="htmx-container"
|
||||
hx-get="{{ dashboard_path }}/htmx/honeypot?page=1"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="innerHTML">
|
||||
<div class="htmx-indicator">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Attack Types table #}
|
||||
<div class="table-container alert-section">
|
||||
<h2>Detected Attack Types</h2>
|
||||
@@ -147,6 +171,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ==================== IP INSIGHT TAB ==================== #}
|
||||
<div x-show="tab === 'ip-insight'" x-cloak>
|
||||
{# IP Insight content - loaded via HTMX when IP is selected #}
|
||||
<div id="ip-insight-container">
|
||||
<template x-if="!insightIp">
|
||||
<div class="table-container" style="text-align: center; padding: 60px 20px;">
|
||||
<p style="color: #8b949e; font-size: 16px;">Select an IP address from any table to view detailed insights.</p>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="insightIp" id="ip-insight-htmx-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Raw request modal - Alpine.js #}
|
||||
{% include "dashboard/partials/raw_request_modal.html" %}
|
||||
|
||||
|
||||
38
src/templates/jinja2/dashboard/ip.html
Normal file
38
src/templates/jinja2/dashboard/ip.html
Normal 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;">
|
||||
← 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()">×</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 %}
|
||||
295
src/templates/jinja2/dashboard/partials/_ip_detail.html
Normal file
295
src/templates/jinja2/dashboard/partials/_ip_detail.html
Normal 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: '© CartoDB | © OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
subdomains: 'abcd'
|
||||
}).addTo(map);
|
||||
|
||||
var color = categoryColors[category] || '#8b949e';
|
||||
var markerHtml = '<div style="width:24px;height:24px;background:' + color +
|
||||
';border:3px solid #fff;border-radius:50%;box-shadow:0 0 12px ' + color +
|
||||
',0 0 24px ' + color + '80;"></div>';
|
||||
|
||||
var icon = L.divIcon({
|
||||
html: markerHtml,
|
||||
iconSize: [24, 24],
|
||||
className: 'single-ip-marker'
|
||||
});
|
||||
|
||||
L.marker([lat, lng], { icon: icon }).addTo(map);
|
||||
}
|
||||
setTimeout(initMap, 100);
|
||||
{% else %}
|
||||
var mapContainer = document.getElementById(UID + '-ip-map');
|
||||
if (mapContainer) {
|
||||
mapContainer.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#8b949e;">Location data not available</div>';
|
||||
}
|
||||
{% endif %}
|
||||
})();
|
||||
</script>
|
||||
@@ -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 }} — {{ 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>
|
||||
@@ -3,12 +3,12 @@
|
||||
<span class="pagination-info">Page {{ pagination.page }}/{{ pagination.total_pages }} — {{ pagination.total }} total</span>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="pagination-btn"
|
||||
hx-get="{{ dashboard_path }}/htmx/attacks?page={{ pagination.page - 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
|
||||
hx-get="{{ dashboard_path }}/htmx/attacks?page={{ pagination.page - 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}{% if ip_filter %}&ip_filter={{ ip_filter }}{% endif %}"
|
||||
hx-target="closest .htmx-container"
|
||||
hx-swap="innerHTML"
|
||||
{% if pagination.page <= 1 %}disabled{% endif %}>Prev</button>
|
||||
<button class="pagination-btn"
|
||||
hx-get="{{ dashboard_path }}/htmx/attacks?page={{ pagination.page + 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
|
||||
hx-get="{{ dashboard_path }}/htmx/attacks?page={{ pagination.page + 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}{% if ip_filter %}&ip_filter={{ ip_filter }}{% endif %}"
|
||||
hx-target="closest .htmx-container"
|
||||
hx-swap="innerHTML"
|
||||
{% if pagination.page >= pagination.total_pages %}disabled{% endif %}>Next</button>
|
||||
@@ -23,12 +23,12 @@
|
||||
<th>Attack Types</th>
|
||||
<th>User-Agent</th>
|
||||
<th class="sortable {% if sort_by == 'timestamp' %}{{ sort_order }}{% endif %}"
|
||||
hx-get="{{ dashboard_path }}/htmx/attacks?page=1&sort_by=timestamp&sort_order={% if sort_by == 'timestamp' and sort_order == 'desc' %}asc{% else %}desc{% endif %}"
|
||||
hx-get="{{ dashboard_path }}/htmx/attacks?page=1&sort_by=timestamp&sort_order={% if sort_by == 'timestamp' and sort_order == 'desc' %}asc{% else %}desc{% endif %}{% if ip_filter %}&ip_filter={{ ip_filter }}{% endif %}"
|
||||
hx-target="closest .htmx-container"
|
||||
hx-swap="innerHTML">
|
||||
Time
|
||||
</th>
|
||||
<th>Actions</th>
|
||||
<th style="width: 80px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -60,10 +60,13 @@
|
||||
</td>
|
||||
<td>{{ (attack.user_agent | default(''))[:50] | e }}</td>
|
||||
<td>{{ attack.timestamp | format_ts }}</td>
|
||||
<td>
|
||||
<td style="display: flex; gap: 6px; flex-wrap: wrap;">
|
||||
{% if attack.log_id %}
|
||||
<button class="view-btn" @click="viewRawRequest({{ attack.log_id }})">View Request</button>
|
||||
{% endif %}
|
||||
<button class="inspect-btn" @click="openIpInsight('{{ attack.ip | e }}')" title="Inspect IP">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="ip-stats-row" style="display: none;">
|
||||
@@ -74,7 +77,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" style="text-align: center;">No attacks detected</td></tr>
|
||||
<tr><td colspan="7" class="empty-state">No attacks detected</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
hx-swap="innerHTML">
|
||||
Last Seen</th>
|
||||
<th>Location</th>
|
||||
<th style="width: 40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -53,16 +54,21 @@
|
||||
<td>{{ ip.first_seen | format_ts }}</td>
|
||||
<td>{{ ip.last_seen | format_ts }}</td>
|
||||
<td>{{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }}</td>
|
||||
<td>
|
||||
<button class="inspect-btn" @click="openIpInsight('{{ ip.ip | e }}')" title="Inspect IP">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="ip-stats-row" style="display: none;">
|
||||
<td colspan="6" class="ip-stats-cell">
|
||||
<td colspan="7" class="ip-stats-cell">
|
||||
<div class="ip-stats-dropdown">
|
||||
<div class="loading">Loading stats...</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" style="text-align: center;">No attackers found</td></tr>
|
||||
<tr><td colspan="6" class="empty-state">No attackers found</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
hx-swap="innerHTML">
|
||||
Time
|
||||
</th>
|
||||
<th style="width: 40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -45,16 +46,21 @@
|
||||
<td>{{ cred.password | default('N/A') | e }}</td>
|
||||
<td>{{ cred.path | default('') | e }}</td>
|
||||
<td>{{ cred.timestamp | format_ts }}</td>
|
||||
<td>
|
||||
<button class="inspect-btn" @click="openIpInsight('{{ cred.ip | e }}')" title="Inspect IP">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="ip-stats-row" style="display: none;">
|
||||
<td colspan="6" class="ip-stats-cell">
|
||||
<td colspan="7" class="ip-stats-cell">
|
||||
<div class="ip-stats-dropdown">
|
||||
<div class="loading">Loading stats...</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" style="text-align: center;">No credentials captured</td></tr>
|
||||
<tr><td colspan="6" class="empty-state">No credentials captured</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
hx-swap="innerHTML">
|
||||
Honeypot Triggers
|
||||
</th>
|
||||
<th style="width: 40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -39,16 +40,21 @@
|
||||
{{ item.ip | e }}
|
||||
</td>
|
||||
<td>{{ item.count }}</td>
|
||||
<td>
|
||||
<button class="inspect-btn" @click="openIpInsight('{{ item.ip | e }}')" title="Inspect IP">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="ip-stats-row" style="display: none;">
|
||||
<td colspan="3" class="ip-stats-cell">
|
||||
<td colspan="4" class="ip-stats-cell">
|
||||
<div class="ip-stats-dropdown">
|
||||
<div class="loading">Loading stats...</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" style="text-align: center;">No data</td></tr>
|
||||
<tr><td colspan="3" class="empty-state">No data</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
5
src/templates/jinja2/dashboard/partials/ip_insight.html
Normal file
5
src/templates/jinja2/dashboard/partials/ip_insight.html
Normal 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>
|
||||
@@ -37,7 +37,7 @@
|
||||
<td>{{ pattern.count }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" style="text-align: center;">No patterns found</td></tr>
|
||||
<tr><td colspan="3" class="empty-state">No patterns found</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
164
src/templates/jinja2/dashboard/partials/search_results.html
Normal file
164
src/templates/jinja2/dashboard/partials/search_results.html
Normal 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 “<em>{{ query | e }}</em>”
|
||||
</span>
|
||||
<button class="search-close-btn" onclick="document.getElementById('search-input').value=''; document.getElementById('search-results-container').innerHTML='';">×</button>
|
||||
</div>
|
||||
|
||||
{# ── Matching IPs ─────────────────────────────────── #}
|
||||
{% if ips %}
|
||||
<div class="search-section">
|
||||
<h3 class="search-section-title">Matching IPs</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>IP Address</th>
|
||||
<th>Requests</th>
|
||||
<th>Category</th>
|
||||
<th>Location</th>
|
||||
<th>ISP / ASN</th>
|
||||
<th>Last Seen</th>
|
||||
<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 “<em>{{ query | e }}</em>”
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
@@ -8,6 +8,7 @@
|
||||
<th>Path</th>
|
||||
<th>User-Agent</th>
|
||||
<th>Time</th>
|
||||
<th style="width: 40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -22,17 +23,22 @@
|
||||
</td>
|
||||
<td>{{ activity.path | e }}</td>
|
||||
<td style="word-break: break-all;">{{ (activity.user_agent | default(''))[:80] | e }}</td>
|
||||
<td>{{ activity.timestamp | format_ts }}</td>
|
||||
<td>{{ activity.timestamp | format_ts(time_only=True) }}</td>
|
||||
<td>
|
||||
<button class="inspect-btn" @click="openIpInsight('{{ activity.ip | e }}')" title="Inspect IP">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="ip-stats-row" style="display: none;">
|
||||
<td colspan="4" class="ip-stats-cell">
|
||||
<td colspan="5" class="ip-stats-cell">
|
||||
<div class="ip-stats-dropdown">
|
||||
<div class="loading">Loading stats...</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" style="text-align:center;">No suspicious activity detected</td></tr>
|
||||
<tr><td colspan="4" class="empty-state">No suspicious activity detected</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -19,12 +19,14 @@
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>IP Address</th>
|
||||
<th>Category</th>
|
||||
<th class="sortable {% if sort_by == 'count' %}{{ sort_order }}{% endif %}"
|
||||
hx-get="{{ dashboard_path }}/htmx/top-ips?page=1&sort_by=count&sort_order={% if sort_by == 'count' and sort_order == 'desc' %}asc{% else %}desc{% endif %}"
|
||||
hx-target="closest .htmx-container"
|
||||
hx-swap="innerHTML">
|
||||
Access Count
|
||||
</th>
|
||||
<th style="width: 40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -38,17 +40,27 @@
|
||||
@click="toggleIpDetail($event)">
|
||||
{{ item.ip | e }}
|
||||
</td>
|
||||
<td>
|
||||
{% set cat = item.category | default('unknown') %}
|
||||
{% set cat_colors = {'attacker': '#f85149', 'good_crawler': '#3fb950', 'bad_crawler': '#f0883e', 'regular_user': '#58a6ff', 'unknown': '#8b949e'} %}
|
||||
<span class="category-dot" style="display: inline-block; width: 12px; height: 12px; border-radius: 50%; background: {{ cat_colors.get(cat, '#8b949e') }};" title="{{ cat | replace('_', ' ') | title }}"></span>
|
||||
</td>
|
||||
<td>{{ item.count }}</td>
|
||||
<td>
|
||||
<button class="inspect-btn" @click="openIpInsight('{{ item.ip | e }}')" title="Inspect IP">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="ip-stats-row" style="display: none;">
|
||||
<td colspan="3" class="ip-stats-cell">
|
||||
<td colspan="5" class="ip-stats-cell">
|
||||
<div class="ip-stats-dropdown">
|
||||
<div class="loading">Loading stats...</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" style="text-align: center;">No data</td></tr>
|
||||
<tr><td colspan="3" class="empty-state">No data</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<td>{{ item.count }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" style="text-align: center;">No data</td></tr>
|
||||
<tr><td colspan="3" class="empty-state">No data</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<td>{{ item.count }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" style="text-align: center;">No data</td></tr>
|
||||
<tr><td colspan="3" class="empty-state">No data</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -41,6 +41,8 @@ h1 {
|
||||
color: #58a6ff;
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
font-weight: 900;
|
||||
font-family: 'Google Sans Flex', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
.download-section {
|
||||
position: absolute;
|
||||
@@ -74,20 +76,21 @@ h1 {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 14px;
|
||||
background: #238636;
|
||||
color: #ffffff;
|
||||
background: rgba(35, 134, 54, 0.4);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
transition: background 0.2s;
|
||||
border: 1px solid #2ea043;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
border: 1px solid rgba(46, 160, 67, 0.4);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.banlist-dropdown-btn:hover {
|
||||
background: #2ea043;
|
||||
background: rgba(46, 160, 67, 0.6);
|
||||
color: #ffffff;
|
||||
}
|
||||
.banlist-dropdown-menu {
|
||||
display: none;
|
||||
@@ -189,8 +192,8 @@ tr:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
.alert-section {
|
||||
background: #1c1917;
|
||||
border-left: 4px solid #f85149;
|
||||
background: #161b22;
|
||||
border-left: 6px solid rgba(248, 81, 73, 0.4);
|
||||
}
|
||||
th.sortable {
|
||||
cursor: pointer;
|
||||
@@ -266,19 +269,20 @@ tbody {
|
||||
}
|
||||
.radar-chart {
|
||||
position: relative;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
overflow: visible;
|
||||
}
|
||||
.radar-legend {
|
||||
margin-top: 10px;
|
||||
margin-top: 0;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.radar-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 3px 0;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.radar-legend-color {
|
||||
width: 12px;
|
||||
@@ -442,6 +446,373 @@ tbody {
|
||||
.timeline-marker.bad-crawler { background: #f0883e; }
|
||||
.timeline-marker.regular-user { background: #58a6ff; }
|
||||
.timeline-marker.unknown { background: #8b949e; }
|
||||
|
||||
/* ── IP Insight Page Layout ─────────────────────── */
|
||||
.ip-insight-content {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
.ip-page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.ip-page-header h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
.ip-address-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #e6edf3;
|
||||
font-family: monospace;
|
||||
}
|
||||
.ip-location-subtitle {
|
||||
color: #8b949e;
|
||||
font-size: 14px;
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
/* Quick stats bar */
|
||||
.ip-stats-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ip-stat-chip {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
.ip-stat-chip-value {
|
||||
color: #e6edf3;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ip-stat-chip-label {
|
||||
color: #8b949e;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Two-column grid */
|
||||
.ip-page-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.ip-page-left,
|
||||
.ip-page-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-height: 0;
|
||||
}
|
||||
/* Left card fills column height */
|
||||
.ip-info-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* Timeline card grows to fill remaining space */
|
||||
.ip-timeline-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Detail cards */
|
||||
.ip-detail-card h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
/* Remove bottom margin inside grid columns (gap handles spacing) */
|
||||
.ip-page-left .table-container,
|
||||
.ip-page-right .table-container {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Definition list for IP info */
|
||||
.ip-dl {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
.ip-dl-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #21262d;
|
||||
gap: 16px;
|
||||
}
|
||||
.ip-dl-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.ip-dl dt {
|
||||
color: #8b949e;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
min-width: 100px;
|
||||
}
|
||||
.ip-dl dd {
|
||||
margin: 0;
|
||||
color: #e6edf3;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
.ip-dl-mono {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Section headings inside IP info card */
|
||||
.ip-section-heading {
|
||||
color: #e6edf3;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin: 18px 0 8px 0;
|
||||
padding: 0;
|
||||
}
|
||||
.ip-section-heading:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
/* Highlighted date values */
|
||||
.ip-dl-highlight {
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
/* Scrollable reputation container */
|
||||
.ip-rep-scroll {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #30363d #161b22;
|
||||
}
|
||||
.ip-rep-scroll::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.ip-rep-scroll::-webkit-scrollbar-track {
|
||||
background: #161b22;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.ip-rep-scroll::-webkit-scrollbar-thumb {
|
||||
background: #30363d;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.ip-rep-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: #484f58;
|
||||
}
|
||||
|
||||
/* Scrollable behavior timeline – show ~5 entries max */
|
||||
.ip-timeline-scroll {
|
||||
max-height: 230px;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #30363d #161b22;
|
||||
}
|
||||
.ip-timeline-scroll::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.ip-timeline-scroll::-webkit-scrollbar-track {
|
||||
background: #161b22;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.ip-timeline-scroll::-webkit-scrollbar-thumb {
|
||||
background: #30363d;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.ip-timeline-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: #484f58;
|
||||
}
|
||||
|
||||
/* Reputation section */
|
||||
.ip-rep-row {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #21262d;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.ip-rep-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.ip-rep-label {
|
||||
color: #8b949e;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
min-width: 80px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.ip-rep-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Flags & badges */
|
||||
.ip-flag {
|
||||
display: inline-block;
|
||||
background: #1c2128;
|
||||
border: 1px solid #f0883e4d;
|
||||
border-radius: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
color: #f0883e;
|
||||
font-weight: 500;
|
||||
}
|
||||
.reputation-score {
|
||||
font-weight: 700;
|
||||
}
|
||||
.reputation-score.bad { color: #f85149; }
|
||||
.reputation-score.medium { color: #f0883e; }
|
||||
.reputation-score.good { color: #3fb950; }
|
||||
.blocklist-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Bottom row: Timeline + Attack Types side by side */
|
||||
.ip-bottom-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.ip-bottom-row .ip-timeline-card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ip-attack-types-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.ip-attack-chart-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
/* Radar chart */
|
||||
.radar-chart-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
/* ── Behavior Timeline (full-width horizontal) ──── */
|
||||
.ip-timeline-hz {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
}
|
||||
.ip-timeline-hz::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 2px;
|
||||
background: #30363d;
|
||||
}
|
||||
.ip-tl-entry {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
position: relative;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.ip-tl-entry:not(:last-child) {
|
||||
border-bottom: 1px solid #161b22;
|
||||
}
|
||||
.ip-tl-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid #0d1117;
|
||||
position: absolute;
|
||||
left: -24px;
|
||||
top: 12px;
|
||||
z-index: 1;
|
||||
}
|
||||
.ip-tl-dot.attacker { background: #f85149; box-shadow: 0 0 6px #f8514980; }
|
||||
.ip-tl-dot.good-crawler { background: #3fb950; box-shadow: 0 0 6px #3fb95080; }
|
||||
.ip-tl-dot.bad-crawler { background: #f0883e; box-shadow: 0 0 6px #f0883e80; }
|
||||
.ip-tl-dot.regular-user { background: #58a6ff; box-shadow: 0 0 6px #58a6ff80; }
|
||||
.ip-tl-dot.unknown { background: #8b949e; }
|
||||
.ip-tl-content {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.ip-tl-cat {
|
||||
color: #e6edf3;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.ip-tl-from {
|
||||
color: #8b949e;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ip-tl-time {
|
||||
color: #484f58;
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Legacy compat (unused) */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ip-page-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.ip-stats-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
.ip-stat-chip {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.ip-bottom-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
.ip-tl-content {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.ip-tl-time {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
border-bottom: 1px solid #30363d;
|
||||
margin-bottom: 30px;
|
||||
@@ -474,6 +845,15 @@ tbody {
|
||||
color: #58a6ff;
|
||||
border-bottom-color: #58a6ff;
|
||||
}
|
||||
.tab-button.disabled {
|
||||
color: #484f58;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.tab-button.disabled:hover {
|
||||
color: #484f58;
|
||||
background: transparent;
|
||||
}
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
@@ -1210,6 +1590,27 @@ tbody {
|
||||
background: #30363d;
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
.inspect-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8b949e;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
.inspect-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
}
|
||||
.inspect-btn:hover {
|
||||
color: #58a6ff;
|
||||
background: rgba(88, 166, 255, 0.1);
|
||||
}
|
||||
.pagination-btn {
|
||||
padding: 6px 14px;
|
||||
background: #21262d;
|
||||
@@ -1253,3 +1654,137 @@ tbody {
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── Search Bar ────────────────────────────────────── */
|
||||
.search-bar-container {
|
||||
max-width: 100%;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
.search-bar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #8b949e;
|
||||
pointer-events: none;
|
||||
}
|
||||
.search-bar input[type="search"] {
|
||||
width: 100%;
|
||||
padding: 12px 40px 12px 42px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #c9d1d9;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.search-bar input[type="search"]::placeholder {
|
||||
color: #6e7681;
|
||||
}
|
||||
.search-bar input[type="search"]:focus {
|
||||
border-color: #58a6ff;
|
||||
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
|
||||
}
|
||||
.search-bar input[type="search"]::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%238b949e'%3E%3Cpath d='M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z'/%3E%3C/svg%3E") center/contain no-repeat;
|
||||
cursor: pointer;
|
||||
}
|
||||
.search-spinner {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 2px solid #30363d;
|
||||
border-top-color: #58a6ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Search Results ───────────────────────────────── */
|
||||
.search-results {
|
||||
margin-top: 12px;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.search-results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #30363d;
|
||||
}
|
||||
.search-results-summary {
|
||||
color: #8b949e;
|
||||
font-size: 13px;
|
||||
}
|
||||
.search-results-summary strong {
|
||||
color: #58a6ff;
|
||||
}
|
||||
.search-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8b949e;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.search-close-btn:hover {
|
||||
color: #f85149;
|
||||
}
|
||||
.search-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.search-section:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.search-section-title {
|
||||
color: #58a6ff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.search-pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #30363d;
|
||||
}
|
||||
.search-no-results {
|
||||
text-align: center;
|
||||
color: #4a515a;
|
||||
padding: 24px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── Empty State (no data rows) ───────────────────── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #4a515a;
|
||||
padding: 20px 12px;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,25 @@
|
||||
let attackTypesChart = null;
|
||||
let attackTypesChartLoaded = false;
|
||||
|
||||
async function loadAttackTypesChart() {
|
||||
/**
|
||||
* Load an attack types doughnut chart into a canvas element.
|
||||
* @param {string} [canvasId='attack-types-chart'] - Canvas element ID
|
||||
* @param {string} [ipFilter] - Optional IP address to scope results
|
||||
* @param {string} [legendPosition='right'] - Legend position
|
||||
*/
|
||||
async function loadAttackTypesChart(canvasId, ipFilter, legendPosition) {
|
||||
canvasId = canvasId || 'attack-types-chart';
|
||||
legendPosition = legendPosition || 'right';
|
||||
const DASHBOARD_PATH = window.__DASHBOARD_PATH__ || '';
|
||||
|
||||
try {
|
||||
const canvas = document.getElementById('attack-types-chart');
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return;
|
||||
|
||||
const response = await fetch(DASHBOARD_PATH + '/api/attack-types-stats?limit=10', {
|
||||
let url = DASHBOARD_PATH + '/api/attack-types-stats?limit=10';
|
||||
if (ipFilter) url += '&ip_filter=' + encodeURIComponent(ipFilter);
|
||||
|
||||
const response = await fetch(url, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
@@ -25,7 +36,7 @@ async function loadAttackTypesChart() {
|
||||
const attackTypes = data.attack_types || [];
|
||||
|
||||
if (attackTypes.length === 0) {
|
||||
canvas.style.display = 'none';
|
||||
canvas.parentElement.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#8b949e;font-size:13px;">No attack data</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -63,13 +74,14 @@ async function loadAttackTypesChart() {
|
||||
const borderColors = labels.map(label => generateColorFromHash(label).border);
|
||||
const hoverColors = labels.map(label => generateColorFromHash(label).hover);
|
||||
|
||||
// Create or update chart
|
||||
if (attackTypesChart) {
|
||||
attackTypesChart.destroy();
|
||||
// Create or update chart (track per canvas)
|
||||
if (!loadAttackTypesChart._instances) loadAttackTypesChart._instances = {};
|
||||
if (loadAttackTypesChart._instances[canvasId]) {
|
||||
loadAttackTypesChart._instances[canvasId].destroy();
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
attackTypesChart = new Chart(ctx, {
|
||||
const chartInstance = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
@@ -88,7 +100,7 @@ async function loadAttackTypesChart() {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
position: legendPosition,
|
||||
labels: {
|
||||
color: '#c9d1d9',
|
||||
font: {
|
||||
@@ -160,6 +172,8 @@ async function loadAttackTypesChart() {
|
||||
}]
|
||||
});
|
||||
|
||||
loadAttackTypesChart._instances[canvasId] = chartInstance;
|
||||
attackTypesChart = chartInstance;
|
||||
attackTypesChartLoaded = true;
|
||||
} catch (err) {
|
||||
console.error('Error loading attack types chart:', err);
|
||||
|
||||
@@ -17,19 +17,26 @@ document.addEventListener('alpine:init', () => {
|
||||
// Chart state
|
||||
chartLoaded: false,
|
||||
|
||||
// IP Insight state
|
||||
insightIp: null,
|
||||
|
||||
init() {
|
||||
// Handle hash-based tab routing
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash === 'ip-stats' || hash === 'attacks') {
|
||||
this.switchToAttacks();
|
||||
}
|
||||
// ip-insight tab is only accessible via lens buttons, not direct hash navigation
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
const h = window.location.hash.slice(1);
|
||||
if (h === 'ip-stats' || h === 'attacks') {
|
||||
this.switchToAttacks();
|
||||
} else {
|
||||
this.switchToOverview();
|
||||
} else if (h !== 'ip-insight') {
|
||||
// Don't switch away from ip-insight via hash if already there
|
||||
if (this.tab !== 'ip-insight') {
|
||||
this.switchToOverview();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -38,15 +45,9 @@ document.addEventListener('alpine:init', () => {
|
||||
this.tab = 'attacks';
|
||||
window.location.hash = '#ip-stats';
|
||||
|
||||
// Delay initialization to ensure the container is visible and
|
||||
// the browser has reflowed after x-show removes display:none.
|
||||
// Leaflet and Chart.js need visible containers with real dimensions.
|
||||
// Delay chart initialization to ensure the container is visible
|
||||
this.$nextTick(() => {
|
||||
setTimeout(() => {
|
||||
if (!this.mapInitialized && typeof initializeAttackerMap === 'function') {
|
||||
initializeAttackerMap();
|
||||
this.mapInitialized = true;
|
||||
}
|
||||
if (!this.chartLoaded && typeof loadAttackTypesChart === 'function') {
|
||||
loadAttackTypesChart();
|
||||
this.chartLoaded = true;
|
||||
@@ -60,6 +61,31 @@ document.addEventListener('alpine:init', () => {
|
||||
window.location.hash = '#overview';
|
||||
},
|
||||
|
||||
switchToIpInsight() {
|
||||
// Only allow switching if an IP is selected
|
||||
if (!this.insightIp) return;
|
||||
this.tab = 'ip-insight';
|
||||
window.location.hash = '#ip-insight';
|
||||
},
|
||||
|
||||
openIpInsight(ip) {
|
||||
// Set the IP and load the insight content
|
||||
this.insightIp = ip;
|
||||
this.tab = 'ip-insight';
|
||||
window.location.hash = '#ip-insight';
|
||||
|
||||
// Load IP insight content via HTMX
|
||||
this.$nextTick(() => {
|
||||
const container = document.getElementById('ip-insight-htmx-container');
|
||||
if (container && typeof htmx !== 'undefined') {
|
||||
htmx.ajax('GET', `${this.dashboardPath}/htmx/ip-insight/${encodeURIComponent(ip)}`, {
|
||||
target: '#ip-insight-htmx-container',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async viewRawRequest(logId) {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
@@ -110,6 +136,19 @@ document.addEventListener('alpine:init', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
// Global function for opening IP Insight (used by map popups)
|
||||
window.openIpInsight = function(ip) {
|
||||
// Find the Alpine component and call openIpInsight
|
||||
const container = document.querySelector('[x-data="dashboardApp()"]');
|
||||
if (container) {
|
||||
// Try Alpine 3.x API first, then fall back to older API
|
||||
const data = Alpine.$data ? Alpine.$data(container) : (container._x_dataStack && container._x_dataStack[0]);
|
||||
if (data && typeof data.openIpInsight === 'function') {
|
||||
data.openIpInsight(ip);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Utility function for formatting timestamps (used by map popups)
|
||||
function formatTimestamp(isoTimestamp) {
|
||||
if (!isoTimestamp) return 'N/A';
|
||||
|
||||
@@ -36,14 +36,45 @@ function createClusterIcon(cluster) {
|
||||
gradientStops.push(`${color} ${start.toFixed(1)}deg ${end.toFixed(1)}deg`);
|
||||
});
|
||||
|
||||
const size = Math.max(32, Math.min(50, 32 + Math.log2(total) * 5));
|
||||
const inner = size - 10;
|
||||
const offset = 5; // (size - inner) / 2
|
||||
const size = Math.max(20, Math.min(44, 20 + Math.log2(total) * 4));
|
||||
const centerSize = size - 8;
|
||||
const centerOffset = 4;
|
||||
const ringWidth = 4;
|
||||
const radius = (size / 2) - (ringWidth / 2);
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const gapDeg = 8;
|
||||
|
||||
// Build SVG arc segments with gaps - glow layer first, then sharp layer
|
||||
let glowSegments = '';
|
||||
let segments = '';
|
||||
let currentAngle = -90;
|
||||
sorted.forEach(([cat, count], idx) => {
|
||||
const sliceDeg = (count / total) * 360;
|
||||
if (sliceDeg < gapDeg) return;
|
||||
const startAngle = currentAngle + (gapDeg / 2);
|
||||
const endAngle = currentAngle + sliceDeg - (gapDeg / 2);
|
||||
const startRad = (startAngle * Math.PI) / 180;
|
||||
const endRad = (endAngle * Math.PI) / 180;
|
||||
const x1 = cx + radius * Math.cos(startRad);
|
||||
const y1 = cy + radius * Math.sin(startRad);
|
||||
const x2 = cx + radius * Math.cos(endRad);
|
||||
const y2 = cy + radius * Math.sin(endRad);
|
||||
const largeArc = (endAngle - startAngle) > 180 ? 1 : 0;
|
||||
const color = categoryColors[cat] || '#8b949e';
|
||||
// Glow layer - subtle
|
||||
glowSegments += `<path d="M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2}" fill="none" stroke="${color}" stroke-width="${ringWidth + 4}" stroke-linecap="round" opacity="0.35" filter="url(#glow)"/>`;
|
||||
// Sharp layer
|
||||
segments += `<path d="M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2}" fill="none" stroke="${color}" stroke-width="${ringWidth}" stroke-linecap="round"/>`;
|
||||
currentAngle += sliceDeg;
|
||||
});
|
||||
|
||||
return L.divIcon({
|
||||
html: `<div style="position:relative;width:${size}px;height:${size}px;">` +
|
||||
`<div style="position:absolute;top:0;left:0;width:${size}px;height:${size}px;border-radius:50%;background:conic-gradient(${gradientStops.join(', ')});box-shadow:0 0 6px rgba(0,0,0,0.5);"></div>` +
|
||||
`<div style="position:absolute;top:${offset}px;left:${offset}px;width:${inner}px;height:${inner}px;border-radius:50%;background:rgba(13,17,23,0.85);color:#e6edf3;font-size:11px;font-weight:700;line-height:${inner}px;text-align:center;">${total}</div>` +
|
||||
`<svg width="${size}" height="${size}" style="position:absolute;top:0;left:0;overflow:visible;">` +
|
||||
`<defs><filter id="glow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="2" result="blur"/></filter></defs>` +
|
||||
`${glowSegments}${segments}</svg>` +
|
||||
`<div style="position:absolute;top:${centerOffset}px;left:${centerOffset}px;width:${centerSize}px;height:${centerSize}px;border-radius:50%;background:#0d1117;display:flex;align-items:center;justify-content:center;color:#e6edf3;font-family:'SF Mono',Monaco,Consolas,monospace;font-size:${Math.max(9, centerSize * 0.38)}px;font-weight:600;">${total}</div>` +
|
||||
`</div>`,
|
||||
className: 'ip-cluster-icon',
|
||||
iconSize: L.point(size, size)
|
||||
@@ -180,11 +211,11 @@ function buildMapMarkers(ips) {
|
||||
|
||||
// Single cluster group with custom pie-chart icons
|
||||
clusterGroup = L.markerClusterGroup({
|
||||
maxClusterRadius: 20,
|
||||
maxClusterRadius: 35,
|
||||
spiderfyOnMaxZoom: true,
|
||||
showCoverageOnHover: false,
|
||||
zoomToBoundsOnClick: true,
|
||||
disableClusteringAtZoom: 10,
|
||||
disableClusteringAtZoom: 8,
|
||||
iconCreateFunction: createClusterIcon
|
||||
});
|
||||
|
||||
@@ -284,8 +315,13 @@ function buildMapMarkers(ips) {
|
||||
|
||||
let popupContent = `
|
||||
<div style="padding: 12px; min-width: 200px;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
||||
<strong style="color: #58a6ff; font-size: 14px;">${ip.ip}</strong>
|
||||
<button onclick="window.openIpInsight('${ip.ip}')" class="inspect-btn" style="display: inline-flex; align-items: center; padding: 4px; background: none; color: #8b949e; border: none; cursor: pointer; border-radius: 4px;" title="Inspect IP">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div style="margin-bottom: 8px;">
|
||||
<span style="background: ${categoryColor}1a; color: ${categoryColor}; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600;">
|
||||
${categoryLabels[category]}
|
||||
</span>
|
||||
@@ -315,8 +351,13 @@ function buildMapMarkers(ips) {
|
||||
console.error('Error fetching IP stats:', err);
|
||||
const errorPopup = `
|
||||
<div style="padding: 12px; min-width: 280px; max-width: 320px;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
||||
<strong style="color: #58a6ff; font-size: 14px;">${ip.ip}</strong>
|
||||
<button onclick="window.openIpInsight('${ip.ip}')" class="inspect-btn" style="display: inline-flex; align-items: center; padding: 4px; background: none; color: #8b949e; border: none; cursor: pointer; border-radius: 4px;" title="Inspect IP">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div style="margin-bottom: 8px;">
|
||||
<span style="background: ${categoryColor}1a; color: ${categoryColor}; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600;">
|
||||
${categoryLabels[category]}
|
||||
</span>
|
||||
|
||||
@@ -11,11 +11,13 @@
|
||||
* @param {Object} categoryScores - Object with keys: attacker, good_crawler, bad_crawler, regular_user, unknown
|
||||
* @param {number} [size=200] - Width/height of the SVG in pixels
|
||||
* @param {boolean} [showLegend=true] - Whether to show the legend below the chart
|
||||
* @param {string} [legendPosition='below'] - 'below' or 'side' (side = legend to the right of the chart)
|
||||
* @returns {string} HTML string containing the SVG radar chart
|
||||
*/
|
||||
function generateRadarChart(categoryScores, size, showLegend) {
|
||||
function generateRadarChart(categoryScores, size, showLegend, legendPosition) {
|
||||
size = size || 200;
|
||||
if (showLegend === undefined) showLegend = true;
|
||||
legendPosition = legendPosition || 'below';
|
||||
|
||||
if (!categoryScores || Object.keys(categoryScores).length === 0) {
|
||||
return '<div style="color: #8b949e; text-align: center; padding: 20px;">No category data available</div>';
|
||||
@@ -55,7 +57,8 @@ function generateRadarChart(categoryScores, size, showLegend) {
|
||||
|
||||
const cx = 100, cy = 100, maxRadius = 75;
|
||||
|
||||
let html = '<div style="display: flex; flex-direction: column; align-items: center;">';
|
||||
const flexDir = legendPosition === 'side' ? 'row' : 'column';
|
||||
let html = `<div style="display: flex; flex-direction: ${flexDir}; align-items: center; gap: 16px; justify-content: center;">`;
|
||||
html += `<svg class="radar-chart" viewBox="-30 -30 260 260" preserveAspectRatio="xMidYMid meet" style="width: ${size}px; height: ${size}px;">`;
|
||||
|
||||
// Draw concentric circles (grid)
|
||||
|
||||
60
src/templates/static/vendor/css/MarkerCluster.Default.css
vendored
Normal file
60
src/templates/static/vendor/css/MarkerCluster.Default.css
vendored
Normal 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;
|
||||
}
|
||||
14
src/templates/static/vendor/css/MarkerCluster.css
vendored
Normal file
14
src/templates/static/vendor/css/MarkerCluster.css
vendored
Normal 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;
|
||||
}
|
||||
BIN
src/templates/static/vendor/css/images/layers-2x.png
vendored
Normal file
BIN
src/templates/static/vendor/css/images/layers-2x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/templates/static/vendor/css/images/layers.png
vendored
Normal file
BIN
src/templates/static/vendor/css/images/layers.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 696 B |
BIN
src/templates/static/vendor/css/images/marker-icon-2x.png
vendored
Normal file
BIN
src/templates/static/vendor/css/images/marker-icon-2x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src/templates/static/vendor/css/images/marker-icon.png
vendored
Normal file
BIN
src/templates/static/vendor/css/images/marker-icon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/templates/static/vendor/css/images/marker-shadow.png
vendored
Normal file
BIN
src/templates/static/vendor/css/images/marker-shadow.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
1
src/templates/static/vendor/css/leaflet.min.css
vendored
Normal file
1
src/templates/static/vendor/css/leaflet.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
5
src/templates/static/vendor/js/alpine.min.js
vendored
Normal file
5
src/templates/static/vendor/js/alpine.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
src/templates/static/vendor/js/chart.min.js
vendored
Normal file
13
src/templates/static/vendor/js/chart.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/templates/static/vendor/js/htmx.min.js
vendored
Normal file
1
src/templates/static/vendor/js/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
src/templates/static/vendor/js/leaflet.markercluster.js
vendored
Normal file
2
src/templates/static/vendor/js/leaflet.markercluster.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/templates/static/vendor/js/leaflet.min.js
vendored
Normal file
1
src/templates/static/vendor/js/leaflet.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -548,12 +548,13 @@ def generate_fake_data(
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
# Allow command-line arguments for customization
|
||||
num_ips = int(sys.argv[1]) if len(sys.argv) > 1 else 20
|
||||
logs_per_ip = int(sys.argv[2]) if len(sys.argv) > 2 else 15
|
||||
credentials_per_ip = int(sys.argv[3]) if len(sys.argv) > 3 else 3
|
||||
# Add --no-cleanup flag to skip database cleanup
|
||||
cleanup = "--no-cleanup" not in sys.argv
|
||||
# Filter out flags before parsing positional args
|
||||
positional = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||
num_ips = int(positional[0]) if len(positional) > 0 else 20
|
||||
logs_per_ip = int(positional[1]) if len(positional) > 1 else 15
|
||||
credentials_per_ip = int(positional[2]) if len(positional) > 2 else 3
|
||||
|
||||
generate_fake_data(
|
||||
num_ips,
|
||||
|
||||
Reference in New Issue
Block a user