added drop down menu and scoring graph to the dashboard
This commit is contained in:
@@ -256,7 +256,7 @@ class DatabaseManager:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
session = self.session
|
session = self.session
|
||||||
|
sanitized_ip = sanitize_ip(ip)
|
||||||
ip_stats = session.query(IpStats).filter(IpStats.ip == sanitized_ip).first()
|
ip_stats = session.query(IpStats).filter(IpStats.ip == sanitized_ip).first()
|
||||||
|
|
||||||
ip_stats.category = category
|
ip_stats.category = category
|
||||||
@@ -439,6 +439,43 @@ class DatabaseManager:
|
|||||||
finally:
|
finally:
|
||||||
self.close_session()
|
self.close_session()
|
||||||
|
|
||||||
|
def get_ip_stats_by_ip(self, ip: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Retrieve IP statistics for a specific IP address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ip: The IP address to look up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with IP stats or None if not found
|
||||||
|
"""
|
||||||
|
session = self.session
|
||||||
|
try:
|
||||||
|
stat = session.query(IpStats).filter(IpStats.ip == ip).first()
|
||||||
|
|
||||||
|
if not stat:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'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,
|
||||||
|
'asn': stat.asn,
|
||||||
|
'asn_org': stat.asn_org,
|
||||||
|
'reputation_score': stat.reputation_score,
|
||||||
|
'reputation_source': stat.reputation_source,
|
||||||
|
'analyzed_metrics': stat.analyzed_metrics or {},
|
||||||
|
'category': stat.category,
|
||||||
|
'category_scores': stat.category_scores or {},
|
||||||
|
'manual_category': stat.manual_category,
|
||||||
|
'last_analysis': stat.last_analysis.isoformat() if stat.last_analysis else None
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
self.close_session()
|
||||||
|
|
||||||
def get_dashboard_counts(self) -> Dict[str, int]:
|
def get_dashboard_counts(self) -> Dict[str, int]:
|
||||||
"""
|
"""
|
||||||
Get aggregate statistics for the dashboard.
|
Get aggregate statistics for the dashboard.
|
||||||
|
|||||||
@@ -414,6 +414,33 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.app_logger.error(f"Error generating dashboard: {e}")
|
self.app_logger.error(f"Error generating dashboard: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# API endpoint for fetching IP stats
|
||||||
|
if self.config.dashboard_secret_path and self.path.startswith(f"{self.config.dashboard_secret_path}/api/ip-stats/"):
|
||||||
|
ip_address = self.path.replace(f"{self.config.dashboard_secret_path}/api/ip-stats/", "")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-type', 'application/json')
|
||||||
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
|
# Prevent browser caching - force fresh data from database every time
|
||||||
|
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
|
||||||
|
self.send_header('Pragma', 'no-cache')
|
||||||
|
self.send_header('Expires', '0')
|
||||||
|
self.end_headers()
|
||||||
|
try:
|
||||||
|
from database import get_database
|
||||||
|
import json
|
||||||
|
db = get_database()
|
||||||
|
ip_stats = db.get_ip_stats_by_ip(ip_address)
|
||||||
|
if ip_stats:
|
||||||
|
self.wfile.write(json.dumps(ip_stats).encode())
|
||||||
|
else:
|
||||||
|
self.wfile.write(json.dumps({'error': 'IP not found'}).encode())
|
||||||
|
except BrokenPipeError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
self.app_logger.error(f"Error fetching IP stats: {e}")
|
||||||
|
self.wfile.write(json.dumps({'error': str(e)}).encode())
|
||||||
|
return
|
||||||
|
|
||||||
self.tracker.record_access(client_ip, self.path, user_agent, method='GET')
|
self.tracker.record_access(client_ip, self.path, user_agent, method='GET')
|
||||||
|
|
||||||
self.analyzer.infer_user_category(client_ip)
|
self.analyzer.infer_user_category(client_ip)
|
||||||
|
|||||||
@@ -27,9 +27,20 @@ def format_timestamp(iso_timestamp: str) -> str:
|
|||||||
def generate_dashboard(stats: dict) -> str:
|
def generate_dashboard(stats: dict) -> str:
|
||||||
"""Generate dashboard HTML with access statistics"""
|
"""Generate dashboard HTML with access statistics"""
|
||||||
|
|
||||||
# Generate IP rows (IPs are generally safe but escape for consistency)
|
# Generate IP rows with clickable functionality for dropdown stats
|
||||||
top_ips_rows = '\n'.join([
|
top_ips_rows = '\n'.join([
|
||||||
f'<tr><td class="rank">{i+1}</td><td>{_escape(ip)}</td><td>{count}</td></tr>'
|
f'''<tr class="ip-row" data-ip="{_escape(ip)}">
|
||||||
|
<td class="rank">{i+1}</td>
|
||||||
|
<td class="ip-clickable">{_escape(ip)}</td>
|
||||||
|
<td>{count}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="ip-stats-row" id="stats-row-{_escape(ip).replace(".", "-")}" style="display: none;">
|
||||||
|
<td colspan="3" class="ip-stats-cell">
|
||||||
|
<div class="ip-stats-dropdown">
|
||||||
|
<div class="loading">Loading stats...</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>'''
|
||||||
for i, (ip, count) in enumerate(stats['top_ips'])
|
for i, (ip, count) in enumerate(stats['top_ips'])
|
||||||
]) or '<tr><td colspan="3" style="text-align:center;">No data</td></tr>'
|
]) or '<tr><td colspan="3" style="text-align:center;">No data</td></tr>'
|
||||||
|
|
||||||
@@ -45,27 +56,76 @@ def generate_dashboard(stats: dict) -> str:
|
|||||||
for i, (ua, count) in enumerate(stats['top_user_agents'])
|
for i, (ua, count) in enumerate(stats['top_user_agents'])
|
||||||
]) or '<tr><td colspan="3" style="text-align:center;">No data</td></tr>'
|
]) or '<tr><td colspan="3" style="text-align:center;">No data</td></tr>'
|
||||||
|
|
||||||
# Generate suspicious accesses rows (CRITICAL: multiple user-controlled fields)
|
# Generate suspicious accesses rows with clickable IPs
|
||||||
suspicious_rows = '\n'.join([
|
suspicious_rows = '\n'.join([
|
||||||
f'<tr><td>{_escape(log["ip"])}</td><td>{_escape(log["path"])}</td><td style="word-break: break-all;">{_escape(log["user_agent"][:60])}</td><td>{_escape(log["timestamp"].split("T")[1][:8])}</td></tr>'
|
f'''<tr class="ip-row" data-ip="{_escape(log["ip"])}">
|
||||||
|
<td class="ip-clickable">{_escape(log["ip"])}</td>
|
||||||
|
<td>{_escape(log["path"])}</td>
|
||||||
|
<td style="word-break: break-all;">{_escape(log["user_agent"][:60])}</td>
|
||||||
|
<td>{_escape(log["timestamp"].split("T")[1][:8])}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="ip-stats-row" id="stats-row-suspicious-{_escape(log["ip"]).replace(".", "-")}" style="display: none;">
|
||||||
|
<td colspan="4" class="ip-stats-cell">
|
||||||
|
<div class="ip-stats-dropdown">
|
||||||
|
<div class="loading">Loading stats...</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>'''
|
||||||
for log in stats['recent_suspicious'][-10:]
|
for log in stats['recent_suspicious'][-10:]
|
||||||
]) or '<tr><td colspan="4" style="text-align:center;">No suspicious activity detected</td></tr>'
|
]) or '<tr><td colspan="4" style="text-align:center;">No suspicious activity detected</td></tr>'
|
||||||
|
|
||||||
# Generate honeypot triggered IPs rows
|
# Generate honeypot triggered IPs rows with clickable IPs
|
||||||
honeypot_rows = '\n'.join([
|
honeypot_rows = '\n'.join([
|
||||||
f'<tr><td>{_escape(ip)}</td><td style="word-break: break-all;">{_escape(", ".join(paths))}</td><td>{len(paths)}</td></tr>'
|
f'''<tr class="ip-row" data-ip="{_escape(ip)}">
|
||||||
|
<td class="ip-clickable">{_escape(ip)}</td>
|
||||||
|
<td style="word-break: break-all;">{_escape(", ".join(paths))}</td>
|
||||||
|
<td>{len(paths)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="ip-stats-row" id="stats-row-honeypot-{_escape(ip).replace(".", "-")}" style="display: none;">
|
||||||
|
<td colspan="3" class="ip-stats-cell">
|
||||||
|
<div class="ip-stats-dropdown">
|
||||||
|
<div class="loading">Loading stats...</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>'''
|
||||||
for ip, paths in stats.get('honeypot_triggered_ips', [])
|
for ip, paths in stats.get('honeypot_triggered_ips', [])
|
||||||
]) or '<tr><td colspan="3" style="text-align:center;">No honeypot triggers yet</td></tr>'
|
]) or '<tr><td colspan="3" style="text-align:center;">No honeypot triggers yet</td></tr>'
|
||||||
|
|
||||||
# Generate attack types rows (CRITICAL: paths and user agents are user-controlled)
|
# Generate attack types rows with clickable IPs
|
||||||
attack_type_rows = '\n'.join([
|
attack_type_rows = '\n'.join([
|
||||||
f'<tr><td>{_escape(log["ip"])}</td><td>{_escape(log["path"])}</td><td>{_escape(", ".join(log["attack_types"]))}</td><td style="word-break: break-all;">{_escape(log["user_agent"][:60])}</td><td>{_escape(log["timestamp"].split("T")[1][:8])}</td></tr>'
|
f'''<tr class="ip-row" data-ip="{_escape(log["ip"])}">
|
||||||
|
<td class="ip-clickable">{_escape(log["ip"])}</td>
|
||||||
|
<td>{_escape(log["path"])}</td>
|
||||||
|
<td>{_escape(", ".join(log["attack_types"]))}</td>
|
||||||
|
<td style="word-break: break-all;">{_escape(log["user_agent"][:60])}</td>
|
||||||
|
<td>{_escape(log["timestamp"].split("T")[1][:8])}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="ip-stats-row" id="stats-row-attack-{_escape(log["ip"]).replace(".", "-")}" style="display: none;">
|
||||||
|
<td colspan="5" class="ip-stats-cell">
|
||||||
|
<div class="ip-stats-dropdown">
|
||||||
|
<div class="loading">Loading stats...</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>'''
|
||||||
for log in stats.get('attack_types', [])[-10:]
|
for log in stats.get('attack_types', [])[-10:]
|
||||||
]) or '<tr><td colspan="4" style="text-align:center;">No attacks detected</td></tr>'
|
]) or '<tr><td colspan="4" style="text-align:center;">No attacks detected</td></tr>'
|
||||||
|
|
||||||
# Generate credential attempts rows (CRITICAL: usernames and passwords are user-controlled)
|
# Generate credential attempts rows with clickable IPs
|
||||||
credential_rows = '\n'.join([
|
credential_rows = '\n'.join([
|
||||||
f'<tr><td>{_escape(log["ip"])}</td><td>{_escape(log["username"])}</td><td>{_escape(log["password"])}</td><td>{_escape(log["path"])}</td><td>{_escape(log["timestamp"].split("T")[1][:8])}</td></tr>'
|
f'''<tr class="ip-row" data-ip="{_escape(log["ip"])}">
|
||||||
|
<td class="ip-clickable">{_escape(log["ip"])}</td>
|
||||||
|
<td>{_escape(log["username"])}</td>
|
||||||
|
<td>{_escape(log["password"])}</td>
|
||||||
|
<td>{_escape(log["path"])}</td>
|
||||||
|
<td>{_escape(log["timestamp"].split("T")[1][:8])}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="ip-stats-row" id="stats-row-cred-{_escape(log["ip"]).replace(".", "-")}" style="display: none;">
|
||||||
|
<td colspan="5" class="ip-stats-cell">
|
||||||
|
<div class="ip-stats-dropdown">
|
||||||
|
<div class="loading">Loading stats...</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>'''
|
||||||
for log in stats.get('credential_attempts', [])[-20:]
|
for log in stats.get('credential_attempts', [])[-20:]
|
||||||
]) or '<tr><td colspan="5" style="text-align:center;">No credentials captured yet</td></tr>'
|
]) or '<tr><td colspan="5" style="text-align:center;">No credentials captured yet</td></tr>'
|
||||||
|
|
||||||
@@ -180,6 +240,119 @@ def generate_dashboard(stats: dict) -> str:
|
|||||||
content: '▼';
|
content: '▼';
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}}
|
}}
|
||||||
|
.ip-row {{
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}}
|
||||||
|
.ip-clickable {{
|
||||||
|
cursor: pointer;
|
||||||
|
color: #58a6ff !important;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-style: dotted;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}}
|
||||||
|
.ip-clickable:hover {{
|
||||||
|
color: #79c0ff !important;
|
||||||
|
text-decoration-style: solid;
|
||||||
|
background: #1c2128;
|
||||||
|
}}
|
||||||
|
.ip-stats-row {{
|
||||||
|
background: #0d1117;
|
||||||
|
}}
|
||||||
|
.ip-stats-cell {{
|
||||||
|
padding: 0 !important;
|
||||||
|
}}
|
||||||
|
.ip-stats-dropdown {{
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #0d1117;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
}}
|
||||||
|
.stats-left {{
|
||||||
|
flex: 1;
|
||||||
|
}}
|
||||||
|
.stats-right {{
|
||||||
|
flex: 0 0 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}}
|
||||||
|
.radar-chart {{
|
||||||
|
position: relative;
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
overflow: visible;
|
||||||
|
}}
|
||||||
|
.radar-legend {{
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}}
|
||||||
|
.radar-legend-item {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 3px 0;
|
||||||
|
}}
|
||||||
|
.radar-legend-color {{
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}}
|
||||||
|
.ip-stats-dropdown .loading {{
|
||||||
|
color: #8b949e;
|
||||||
|
font-style: italic;
|
||||||
|
}}
|
||||||
|
.stat-row {{
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 5px 0;
|
||||||
|
border-bottom: 1px solid #21262d;
|
||||||
|
}}
|
||||||
|
.stat-row:last-child {{
|
||||||
|
border-bottom: none;
|
||||||
|
}}
|
||||||
|
.stat-label-sm {{
|
||||||
|
color: #8b949e;
|
||||||
|
font-weight: 500;
|
||||||
|
}}
|
||||||
|
.stat-value-sm {{
|
||||||
|
color: #58a6ff;
|
||||||
|
font-weight: 600;
|
||||||
|
}}
|
||||||
|
.category-badge {{
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}}
|
||||||
|
.category-attacker {{
|
||||||
|
background: #f851491a;
|
||||||
|
color: #f85149;
|
||||||
|
border: 1px solid #f85149;
|
||||||
|
}}
|
||||||
|
.category-good-crawler {{
|
||||||
|
background: #3fb9501a;
|
||||||
|
color: #3fb950;
|
||||||
|
border: 1px solid #3fb950;
|
||||||
|
}}
|
||||||
|
.category-bad-crawler {{
|
||||||
|
background: #f0883e1a;
|
||||||
|
color: #f0883e;
|
||||||
|
border: 1px solid #f0883e;
|
||||||
|
}}
|
||||||
|
.category-regular-user {{
|
||||||
|
background: #58a6ff1a;
|
||||||
|
color: #58a6ff;
|
||||||
|
border: 1px solid #58a6ff;
|
||||||
|
}}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -387,6 +560,211 @@ def generate_dashboard(stats: dict) -> str:
|
|||||||
rows.forEach(row => tbody.appendChild(row));
|
rows.forEach(row => tbody.appendChild(row));
|
||||||
}});
|
}});
|
||||||
}});
|
}});
|
||||||
|
|
||||||
|
// IP stats dropdown functionality
|
||||||
|
document.querySelectorAll('.ip-clickable').forEach(cell => {{
|
||||||
|
cell.addEventListener('click', async function(e) {{
|
||||||
|
const row = e.currentTarget.closest('.ip-row');
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
const ip = row.getAttribute('data-ip');
|
||||||
|
const statsRow = row.nextElementSibling;
|
||||||
|
if (!statsRow || !statsRow.classList.contains('ip-stats-row')) return;
|
||||||
|
|
||||||
|
const isVisible = getComputedStyle(statsRow).display !== 'none';
|
||||||
|
|
||||||
|
document.querySelectorAll('.ip-stats-row').forEach(r => {{
|
||||||
|
r.style.display = 'none';
|
||||||
|
}});
|
||||||
|
|
||||||
|
if (isVisible) return;
|
||||||
|
|
||||||
|
statsRow.style.display = 'table-row';
|
||||||
|
|
||||||
|
const dropdown = statsRow.querySelector('.ip-stats-dropdown');
|
||||||
|
|
||||||
|
// Always fetch fresh data from database
|
||||||
|
if (dropdown) {{
|
||||||
|
dropdown.innerHTML = '<div class="loading">Loading stats...</div>';
|
||||||
|
try {{
|
||||||
|
const response = await fetch(`${{window.location.pathname}}/api/ip-stats/${{ip}}`, {{
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: {{
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Pragma': 'no-cache'
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${{response.status}}`);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
dropdown.innerHTML = data.error
|
||||||
|
? `<div style="color:#f85149;">Error: ${{data.error}}</div>`
|
||||||
|
: formatIpStats(data);
|
||||||
|
}} catch (err) {{
|
||||||
|
dropdown.innerHTML = `<div style="color:#f85149;">Failed to load stats: ${{err.message}}</div>`;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
}});
|
||||||
|
|
||||||
|
function formatIpStats(stats) {{
|
||||||
|
let html = '<div class="stats-left">';
|
||||||
|
|
||||||
|
// Basic info
|
||||||
|
html += '<div class="stat-row">';
|
||||||
|
html += '<span class="stat-label-sm">Total Requests:</span>';
|
||||||
|
html += `<span class="stat-value-sm">${{stats.total_requests || 0}}</span>`;
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
html += '<div class="stat-row">';
|
||||||
|
html += '<span class="stat-label-sm">First Seen:</span>';
|
||||||
|
html += `<span class="stat-value-sm">${{stats.first_seen ? new Date(stats.first_seen).toLocaleString() : 'N/A'}}</span>`;
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
html += '<div class="stat-row">';
|
||||||
|
html += '<span class="stat-label-sm">Last Seen:</span>';
|
||||||
|
html += `<span class="stat-value-sm">${{stats.last_seen ? new Date(stats.last_seen).toLocaleString() : 'N/A'}}</span>`;
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Category
|
||||||
|
if (stats.category) {{
|
||||||
|
html += '<div class="stat-row">';
|
||||||
|
html += '<span class="stat-label-sm">Category:</span>';
|
||||||
|
const categoryClass = 'category-' + stats.category.toLowerCase().replace('_', '-');
|
||||||
|
html += `<span class="category-badge ${{categoryClass}}">${{stats.category}}</span>`;
|
||||||
|
html += '</div>';
|
||||||
|
}}
|
||||||
|
|
||||||
|
// GeoIP info if available
|
||||||
|
if (stats.country_code || stats.city) {{
|
||||||
|
html += '<div class="stat-row">';
|
||||||
|
html += '<span class="stat-label-sm">Location:</span>';
|
||||||
|
html += `<span class="stat-value-sm">${{stats.city || ''}}${{stats.city && stats.country_code ? ', ' : ''}}${{stats.country_code || 'Unknown'}}</span>`;
|
||||||
|
html += '</div>';
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (stats.asn_org) {{
|
||||||
|
html += '<div class="stat-row">';
|
||||||
|
html += '<span class="stat-label-sm">ASN Org:</span>';
|
||||||
|
html += `<span class="stat-value-sm">${{stats.asn_org}}</span>`;
|
||||||
|
html += '</div>';
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Reputation score if available
|
||||||
|
if (stats.reputation_score !== null && stats.reputation_score !== undefined) {{
|
||||||
|
html += '<div class="stat-row">';
|
||||||
|
html += '<span class="stat-label-sm">Reputation Score:</span>';
|
||||||
|
html += `<span class="stat-value-sm">${{stats.reputation_score}} ${{stats.reputation_source ? '(' + stats.reputation_source + ')' : ''}}</span>`;
|
||||||
|
html += '</div>';
|
||||||
|
}}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Radar chart on the right
|
||||||
|
if (stats.category_scores && Object.keys(stats.category_scores).length > 0) {{
|
||||||
|
html += '<div class="stats-right">';
|
||||||
|
html += '<svg class="radar-chart" viewBox="-30 -30 260 260" preserveAspectRatio="xMidYMid meet">';
|
||||||
|
|
||||||
|
const scores = {{
|
||||||
|
attacker: stats.category_scores.attacker || 0,
|
||||||
|
good_crawler: stats.category_scores.good_crawler || 0,
|
||||||
|
bad_crawler: stats.category_scores.bad_crawler || 0,
|
||||||
|
regular_user: stats.category_scores.regular_user || 0
|
||||||
|
}};
|
||||||
|
|
||||||
|
// Normalize scores for better visualization
|
||||||
|
const maxScore = Math.max(...Object.values(scores), 1);
|
||||||
|
const minVisibleRadius = 0.15; // Minimum 15% visibility even for 0 values
|
||||||
|
const normalizedScores = {{}};
|
||||||
|
|
||||||
|
Object.keys(scores).forEach(key => {{
|
||||||
|
// Scale values: ensure minimum visibility + proportional to max
|
||||||
|
normalizedScores[key] = minVisibleRadius + (scores[key] / maxScore) * (1 - minVisibleRadius);
|
||||||
|
}});
|
||||||
|
|
||||||
|
const colors = {{
|
||||||
|
attacker: '#f85149',
|
||||||
|
good_crawler: '#3fb950',
|
||||||
|
bad_crawler: '#f0883e',
|
||||||
|
regular_user: '#58a6ff'
|
||||||
|
}};
|
||||||
|
|
||||||
|
const labels = {{
|
||||||
|
attacker: 'Attacker',
|
||||||
|
good_crawler: 'Good Bot',
|
||||||
|
bad_crawler: 'Bad Bot',
|
||||||
|
regular_user: 'User'
|
||||||
|
}};
|
||||||
|
|
||||||
|
// Draw radar background grid
|
||||||
|
const cx = 100, cy = 100, maxRadius = 75;
|
||||||
|
for (let i = 1; i <= 5; i++) {{
|
||||||
|
const r = (maxRadius / 5) * i;
|
||||||
|
html += `<circle cx="${{cx}}" cy="${{cy}}" r="${{r}}" fill="none" stroke="#30363d" stroke-width="0.5"/>`;
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Draw axes
|
||||||
|
const angles = [0, 90, 180, 270];
|
||||||
|
const keys = ['attacker', 'good_crawler', 'bad_crawler', 'regular_user'];
|
||||||
|
|
||||||
|
angles.forEach((angle, i) => {{
|
||||||
|
const rad = (angle - 90) * Math.PI / 180;
|
||||||
|
const x2 = cx + maxRadius * Math.cos(rad);
|
||||||
|
const y2 = cy + maxRadius * Math.sin(rad);
|
||||||
|
html += `<line x1="${{cx}}" y1="${{cy}}" x2="${{x2}}" y2="${{y2}}" stroke="#30363d" stroke-width="0.5"/>`;
|
||||||
|
|
||||||
|
// Add labels
|
||||||
|
const labelDist = maxRadius + 30;
|
||||||
|
const lx = cx + labelDist * Math.cos(rad);
|
||||||
|
const ly = cy + labelDist * Math.sin(rad);
|
||||||
|
html += `<text x="${{lx}}" y="${{ly}}" fill="#8b949e" font-size="12" text-anchor="middle" dominant-baseline="middle">${{labels[keys[i]]}}</text>`;
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Draw filled polygon for scores
|
||||||
|
let points = [];
|
||||||
|
angles.forEach((angle, i) => {{
|
||||||
|
const normalizedScore = normalizedScores[keys[i]];
|
||||||
|
const rad = (angle - 90) * Math.PI / 180;
|
||||||
|
const r = normalizedScore * maxRadius;
|
||||||
|
const x = cx + r * Math.cos(rad);
|
||||||
|
const y = cy + r * Math.sin(rad);
|
||||||
|
points.push(`${{x}},${{y}}`);
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Determine dominant category color
|
||||||
|
const dominantKey = Object.keys(scores).reduce((a, b) => scores[a] > scores[b] ? a : b);
|
||||||
|
const dominantColor = colors[dominantKey];
|
||||||
|
|
||||||
|
// Draw single colored area
|
||||||
|
html += `<polygon points="${{points.join(' ')}}" fill="${{dominantColor}}" fill-opacity="0.4" stroke="${{dominantColor}}" stroke-width="2.5"/>`;
|
||||||
|
|
||||||
|
// Draw points
|
||||||
|
angles.forEach((angle, i) => {{
|
||||||
|
const normalizedScore = normalizedScores[keys[i]];
|
||||||
|
const rad = (angle - 90) * Math.PI / 180;
|
||||||
|
const r = normalizedScore * maxRadius;
|
||||||
|
const x = cx + r * Math.cos(rad);
|
||||||
|
const y = cy + r * Math.sin(rad);
|
||||||
|
html += `<circle cx="${{x}}" cy="${{y}}" r="4.5" fill="${{colors[keys[i]]}}" stroke="#0d1117" stroke-width="2"/>`;
|
||||||
|
}});
|
||||||
|
|
||||||
|
html += '</svg>';
|
||||||
|
|
||||||
|
// Legend
|
||||||
|
html += '<div class="radar-legend">';
|
||||||
|
keys.forEach(key => {{
|
||||||
|
html += '<div class="radar-legend-item">';
|
||||||
|
html += `<div class="radar-legend-color" style="background: ${{colors[key]}};"></div>`;
|
||||||
|
html += `<span style="color: #8b949e;">${{labels[key]}}: ${{scores[key]}}%</span>`;
|
||||||
|
html += '</div>';
|
||||||
|
}});
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
}}
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -46,21 +46,12 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
}}
|
}}
|
||||||
.links-container::-webkit-scrollbar {{
|
.links-container::-webkit-scrollbar {{
|
||||||
width: 8px;
|
width: 0px;
|
||||||
}}
|
|
||||||
.links-container::-webkit-scrollbar-track {{
|
|
||||||
background: #0d1117;
|
|
||||||
}}
|
|
||||||
.links-container::-webkit-scrollbar-thumb {{
|
|
||||||
background: #30363d;
|
|
||||||
border-radius: 4px;
|
|
||||||
}}
|
|
||||||
.links-container::-webkit-scrollbar-thumb:hover {{
|
|
||||||
background: #484f58;
|
|
||||||
}}
|
}}
|
||||||
.link-box {{
|
.link-box {{
|
||||||
background: #161b22;
|
background: #161b22;
|
||||||
|
|||||||
Reference in New Issue
Block a user