added drop down menu and scoring graph to the dashboard
This commit is contained in:
@@ -256,7 +256,7 @@ class DatabaseManager:
|
||||
|
||||
"""
|
||||
session = self.session
|
||||
|
||||
sanitized_ip = sanitize_ip(ip)
|
||||
ip_stats = session.query(IpStats).filter(IpStats.ip == sanitized_ip).first()
|
||||
|
||||
ip_stats.category = category
|
||||
@@ -439,6 +439,43 @@ class DatabaseManager:
|
||||
finally:
|
||||
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]:
|
||||
"""
|
||||
Get aggregate statistics for the dashboard.
|
||||
|
||||
@@ -413,6 +413,33 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception as e:
|
||||
self.app_logger.error(f"Error generating dashboard: {e}")
|
||||
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')
|
||||
|
||||
|
||||
@@ -27,9 +27,20 @@ def format_timestamp(iso_timestamp: str) -> str:
|
||||
def generate_dashboard(stats: dict) -> str:
|
||||
"""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([
|
||||
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'])
|
||||
]) 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'])
|
||||
]) 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([
|
||||
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:]
|
||||
]) 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([
|
||||
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', [])
|
||||
]) 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([
|
||||
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:]
|
||||
]) 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([
|
||||
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:]
|
||||
]) 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: '▼';
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -387,6 +560,211 @@ def generate_dashboard(stats: dict) -> str:
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -46,21 +46,12 @@
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1;
|
||||
padding-top: 10px;
|
||||
}}
|
||||
.links-container::-webkit-scrollbar {{
|
||||
width: 8px;
|
||||
}}
|
||||
.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;
|
||||
width: 0px;
|
||||
}}
|
||||
.link-box {{
|
||||
background: #161b22;
|
||||
|
||||
Reference in New Issue
Block a user