added iprep to the dashboard, fixed bugs
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# Krawl Honeypot Configuration
|
# Krawl Honeypot Configuration
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: 5000
|
port: 1234
|
||||||
delay: 100 # Response delay in milliseconds
|
delay: 100 # Response delay in milliseconds
|
||||||
timezone: null # e.g., "America/New_York", "Europe/Paris" or null for system default
|
timezone: null # e.g., "America/New_York", "Europe/Paris" or null for system default
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ canary:
|
|||||||
dashboard:
|
dashboard:
|
||||||
# if set to "null" this will Auto-generates random path if not set
|
# if set to "null" this will Auto-generates random path if not set
|
||||||
# can be set to "/dashboard" or similar <-- note this MUST include a forward slash
|
# can be set to "/dashboard" or similar <-- note this MUST include a forward slash
|
||||||
secret_path: dashboard
|
secret_path: super-secret-dashboard-path
|
||||||
|
|
||||||
api:
|
api:
|
||||||
server_url: null
|
server_url: null
|
||||||
|
|||||||
@@ -16,9 +16,3 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- CONFIG_LOCATION=config.yaml
|
- CONFIG_LOCATION=config.yaml
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python3", "-c", "import requests; requests.get('http://localhost:5000')"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 10s
|
|
||||||
|
|||||||
@@ -578,6 +578,7 @@ class DatabaseManager:
|
|||||||
'city': stat.city,
|
'city': stat.city,
|
||||||
'asn': stat.asn,
|
'asn': stat.asn,
|
||||||
'asn_org': stat.asn_org,
|
'asn_org': stat.asn_org,
|
||||||
|
'list_on': stat.list_on or {},
|
||||||
'reputation_score': stat.reputation_score,
|
'reputation_score': stat.reputation_score,
|
||||||
'reputation_source': stat.reputation_source,
|
'reputation_source': stat.reputation_source,
|
||||||
'analyzed_metrics': stat.analyzed_metrics or {},
|
'analyzed_metrics': stat.analyzed_metrics or {},
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
127.0.0.1
|
|
||||||
@@ -410,6 +410,12 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str =
|
|||||||
color: #58a6ff;
|
color: #58a6ff;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
}}
|
||||||
|
.timeline-header {{
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}}
|
}}
|
||||||
.timeline {{
|
.timeline {{
|
||||||
@@ -470,6 +476,56 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str =
|
|||||||
color: #8b949e;
|
color: #8b949e;
|
||||||
margin: 0 7px;
|
margin: 0 7px;
|
||||||
}}
|
}}
|
||||||
|
.reputation-container {{
|
||||||
|
margin-top: 15px;
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid #30363d;
|
||||||
|
}}
|
||||||
|
.reputation-title {{
|
||||||
|
color: #58a6ff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}}
|
||||||
|
.reputation-badges {{
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}}
|
||||||
|
.reputation-badge {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #f851494d;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #f85149;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}}
|
||||||
|
.reputation-badge:hover {{
|
||||||
|
background: #1c2128;
|
||||||
|
border-color: #f85149;
|
||||||
|
}}
|
||||||
|
.reputation-badge-icon {{
|
||||||
|
font-size: 12px;
|
||||||
|
}}
|
||||||
|
.reputation-clean {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #3fb9504d;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #3fb950;
|
||||||
|
}}
|
||||||
|
.reputation-clean-icon {{
|
||||||
|
font-size: 13px;
|
||||||
|
}}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -627,11 +683,9 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
// Server timezone configuration
|
|
||||||
const SERVER_TIMEZONE = '{timezone}';
|
const SERVER_TIMEZONE = '{timezone}';
|
||||||
const DASHBOARD_PATH = '{dashboard_path}';
|
const DASHBOARD_PATH = '{dashboard_path}';
|
||||||
|
|
||||||
// Convert UTC timestamp to configured timezone
|
|
||||||
function formatTimestamp(isoTimestamp) {{
|
function formatTimestamp(isoTimestamp) {{
|
||||||
if (!isoTimestamp) return 'N/A';
|
if (!isoTimestamp) return 'N/A';
|
||||||
try {{
|
try {{
|
||||||
@@ -652,7 +706,6 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str =
|
|||||||
}}
|
}}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// Add sorting functionality to tables
|
|
||||||
document.querySelectorAll('th.sortable').forEach(header => {{
|
document.querySelectorAll('th.sortable').forEach(header => {{
|
||||||
header.addEventListener('click', function() {{
|
header.addEventListener('click', function() {{
|
||||||
const table = this.closest('table');
|
const table = this.closest('table');
|
||||||
@@ -661,30 +714,24 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str =
|
|||||||
const sortType = this.getAttribute('data-sort');
|
const sortType = this.getAttribute('data-sort');
|
||||||
const columnIndex = Array.from(this.parentElement.children).indexOf(this);
|
const columnIndex = Array.from(this.parentElement.children).indexOf(this);
|
||||||
|
|
||||||
// Determine sort direction
|
|
||||||
const isAscending = this.classList.contains('asc');
|
const isAscending = this.classList.contains('asc');
|
||||||
|
|
||||||
// Remove sort classes from all headers in this table
|
|
||||||
table.querySelectorAll('th.sortable').forEach(th => {{
|
table.querySelectorAll('th.sortable').forEach(th => {{
|
||||||
th.classList.remove('asc', 'desc');
|
th.classList.remove('asc', 'desc');
|
||||||
}});
|
}});
|
||||||
|
|
||||||
// Add appropriate class to clicked header
|
|
||||||
this.classList.add(isAscending ? 'desc' : 'asc');
|
this.classList.add(isAscending ? 'desc' : 'asc');
|
||||||
|
|
||||||
// Sort rows
|
|
||||||
rows.sort((a, b) => {{
|
rows.sort((a, b) => {{
|
||||||
let aValue = a.cells[columnIndex].textContent.trim();
|
let aValue = a.cells[columnIndex].textContent.trim();
|
||||||
let bValue = b.cells[columnIndex].textContent.trim();
|
let bValue = b.cells[columnIndex].textContent.trim();
|
||||||
|
|
||||||
// Handle numeric sorting
|
|
||||||
if (sortType === 'count') {{
|
if (sortType === 'count') {{
|
||||||
aValue = parseInt(aValue) || 0;
|
aValue = parseInt(aValue) || 0;
|
||||||
bValue = parseInt(bValue) || 0;
|
bValue = parseInt(bValue) || 0;
|
||||||
return isAscending ? bValue - aValue : aValue - bValue;
|
return isAscending ? bValue - aValue : aValue - bValue;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// Handle IP address sorting
|
|
||||||
if (sortType === 'ip') {{
|
if (sortType === 'ip') {{
|
||||||
const ipToNum = ip => {{
|
const ipToNum = ip => {{
|
||||||
const parts = ip.split('.');
|
const parts = ip.split('.');
|
||||||
@@ -696,7 +743,6 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str =
|
|||||||
return isAscending ? bNum - aNum : aNum - bNum;
|
return isAscending ? bNum - aNum : aNum - bNum;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// Default string sorting
|
|
||||||
if (isAscending) {{
|
if (isAscending) {{
|
||||||
return bValue.localeCompare(aValue);
|
return bValue.localeCompare(aValue);
|
||||||
}} else {{
|
}} else {{
|
||||||
@@ -704,12 +750,10 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str =
|
|||||||
}}
|
}}
|
||||||
}});
|
}});
|
||||||
|
|
||||||
// Re-append sorted rows
|
|
||||||
rows.forEach(row => tbody.appendChild(row));
|
rows.forEach(row => tbody.appendChild(row));
|
||||||
}});
|
}});
|
||||||
}});
|
}});
|
||||||
|
|
||||||
// IP stats dropdown functionality
|
|
||||||
document.querySelectorAll('.ip-clickable').forEach(cell => {{
|
document.querySelectorAll('.ip-clickable').forEach(cell => {{
|
||||||
cell.addEventListener('click', async function(e) {{
|
cell.addEventListener('click', async function(e) {{
|
||||||
const row = e.currentTarget.closest('.ip-row');
|
const row = e.currentTarget.closest('.ip-row');
|
||||||
@@ -731,7 +775,6 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str =
|
|||||||
|
|
||||||
const dropdown = statsRow.querySelector('.ip-stats-dropdown');
|
const dropdown = statsRow.querySelector('.ip-stats-dropdown');
|
||||||
|
|
||||||
// Always fetch fresh data from database
|
|
||||||
if (dropdown) {{
|
if (dropdown) {{
|
||||||
dropdown.innerHTML = '<div class="loading">Loading stats...</div>';
|
dropdown.innerHTML = '<div class="loading">Loading stats...</div>';
|
||||||
try {{
|
try {{
|
||||||
@@ -758,7 +801,6 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str =
|
|||||||
function formatIpStats(stats) {{
|
function formatIpStats(stats) {{
|
||||||
let html = '<div class="stats-left">';
|
let html = '<div class="stats-left">';
|
||||||
|
|
||||||
// Basic info
|
|
||||||
html += '<div class="stat-row">';
|
html += '<div class="stat-row">';
|
||||||
html += '<span class="stat-label-sm">Total Requests:</span>';
|
html += '<span class="stat-label-sm">Total Requests:</span>';
|
||||||
html += `<span class="stat-value-sm">${{stats.total_requests || 0}}</span>`;
|
html += `<span class="stat-value-sm">${{stats.total_requests || 0}}</span>`;
|
||||||
@@ -774,16 +816,6 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str =
|
|||||||
html += `<span class="stat-value-sm">${{formatTimestamp(stats.last_seen)}}</span>`;
|
html += `<span class="stat-value-sm">${{formatTimestamp(stats.last_seen)}}</span>`;
|
||||||
html += '</div>';
|
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) {{
|
if (stats.country_code || stats.city) {{
|
||||||
html += '<div class="stat-row">';
|
html += '<div class="stat-row">';
|
||||||
html += '<span class="stat-label-sm">Location:</span>';
|
html += '<span class="stat-label-sm">Location:</span>';
|
||||||
@@ -798,7 +830,6 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str =
|
|||||||
html += '</div>';
|
html += '</div>';
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// Reputation score if available
|
|
||||||
if (stats.reputation_score !== null && stats.reputation_score !== undefined) {{
|
if (stats.reputation_score !== null && stats.reputation_score !== undefined) {{
|
||||||
html += '<div class="stat-row">';
|
html += '<div class="stat-row">';
|
||||||
html += '<span class="stat-label-sm">Reputation Score:</span>';
|
html += '<span class="stat-label-sm">Reputation Score:</span>';
|
||||||
@@ -806,10 +837,53 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str =
|
|||||||
html += '</div>';
|
html += '</div>';
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// Category History Timeline
|
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>';
|
||||||
|
}}
|
||||||
|
|
||||||
if (stats.category_history && stats.category_history.length > 0) {{
|
if (stats.category_history && stats.category_history.length > 0) {{
|
||||||
html += '<div class="timeline-container">';
|
html += '<div class="timeline-container">';
|
||||||
|
|
||||||
|
html += '<div class="timeline-header">';
|
||||||
html += '<div class="timeline-title">Behavior Timeline</div>';
|
html += '<div class="timeline-title">Behavior Timeline</div>';
|
||||||
|
|
||||||
|
if (stats.list_on && Object.keys(stats.list_on).length > 0) {{
|
||||||
|
html += '<div class="reputation-badges">';
|
||||||
|
html += '<span class="reputation-title" style="margin-bottom:0; margin-right:4px;">Listed on</span>';
|
||||||
|
|
||||||
|
const sortedSources = Object.entries(stats.list_on).sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
|
||||||
|
sortedSources.forEach(([source, url]) => {{
|
||||||
|
if (url && url !== 'N/A') {{
|
||||||
|
html += `<a href="${{url}}" target="_blank" rel="noopener noreferrer" class="reputation-badge" title="Listed on ${'{'}source{'}'}">`;
|
||||||
|
html += '<span class="reputation-badge-icon"></span>';
|
||||||
|
html += `<span>${{source}}</span>`;
|
||||||
|
html += '</a>';
|
||||||
|
}} else {{
|
||||||
|
html += '<span class="reputation-badge" style="cursor: default;" title="Listed on">';
|
||||||
|
html += '<span class="reputation-badge-icon"></span>';
|
||||||
|
html += `<span>${{source}}</span>`;
|
||||||
|
html += '</span>';
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
}} else if (stats.country_code || stats.asn) {{
|
||||||
|
html += '<div class="reputation-badges">';
|
||||||
|
html += '<span class="reputation-title" style="margin-bottom:0; margin-right:4px;">Reputation</span>';
|
||||||
|
html += '<span class="reputation-clean" title="Not found on public blacklists">';
|
||||||
|
html += '<span class="reputation-clean-icon">✓</span>';
|
||||||
|
html += '<span>Clean</span>';
|
||||||
|
html += '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
}}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
html += '<div class="timeline">';
|
html += '<div class="timeline">';
|
||||||
|
|
||||||
stats.category_history.forEach((change, index) => {{
|
stats.category_history.forEach((change, index) => {{
|
||||||
@@ -841,7 +915,6 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str =
|
|||||||
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
// Radar chart on the right
|
|
||||||
if (stats.category_scores && Object.keys(stats.category_scores).length > 0) {{
|
if (stats.category_scores && Object.keys(stats.category_scores).length > 0) {{
|
||||||
html += '<div class="stats-right">';
|
html += '<div class="stats-right">';
|
||||||
html += '<div style="font-size: 13px; font-weight: 600; color: #58a6ff; margin-bottom: 10px;">Category Score</div>';
|
html += '<div style="font-size: 13px; font-weight: 600; color: #58a6ff; margin-bottom: 10px;">Category Score</div>';
|
||||||
@@ -855,13 +928,11 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str =
|
|||||||
unknown: stats.category_scores.unknown || 0
|
unknown: stats.category_scores.unknown || 0
|
||||||
}};
|
}};
|
||||||
|
|
||||||
// Normalize scores for better visualization
|
|
||||||
const maxScore = Math.max(...Object.values(scores), 1);
|
const maxScore = Math.max(...Object.values(scores), 1);
|
||||||
const minVisibleRadius = 0.15; // Minimum 15% visibility even for 0 values
|
const minVisibleRadius = 0.15;
|
||||||
const normalizedScores = {{}};
|
const normalizedScores = {{}};
|
||||||
|
|
||||||
Object.keys(scores).forEach(key => {{
|
Object.keys(scores).forEach(key => {{
|
||||||
// Scale values: ensure minimum visibility + proportional to max
|
|
||||||
normalizedScores[key] = minVisibleRadius + (scores[key] / maxScore) * (1 - minVisibleRadius);
|
normalizedScores[key] = minVisibleRadius + (scores[key] / maxScore) * (1 - minVisibleRadius);
|
||||||
}});
|
}});
|
||||||
|
|
||||||
@@ -881,14 +952,12 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str =
|
|||||||
unknown: 'Unknown'
|
unknown: 'Unknown'
|
||||||
}};
|
}};
|
||||||
|
|
||||||
// Draw radar background grid
|
|
||||||
const cx = 100, cy = 100, maxRadius = 75;
|
const cx = 100, cy = 100, maxRadius = 75;
|
||||||
for (let i = 1; i <= 5; i++) {{
|
for (let i = 1; i <= 5; i++) {{
|
||||||
const r = (maxRadius / 5) * i;
|
const r = (maxRadius / 5) * i;
|
||||||
html += `<circle cx="${{cx}}" cy="${{cy}}" r="${{r}}" fill="none" stroke="#30363d" stroke-width="0.5"/>`;
|
html += `<circle cx="${{cx}}" cy="${{cy}}" r="${{r}}" fill="none" stroke="#30363d" stroke-width="0.5"/>`;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// Draw axes (now with 5 points for pentagon)
|
|
||||||
const angles = [0, 72, 144, 216, 288];
|
const angles = [0, 72, 144, 216, 288];
|
||||||
const keys = ['good_crawler', 'regular_user', 'unknown', 'bad_crawler', 'attacker'];
|
const keys = ['good_crawler', 'regular_user', 'unknown', 'bad_crawler', 'attacker'];
|
||||||
|
|
||||||
@@ -898,14 +967,12 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str =
|
|||||||
const y2 = cy + maxRadius * Math.sin(rad);
|
const y2 = cy + maxRadius * Math.sin(rad);
|
||||||
html += `<line x1="${{cx}}" y1="${{cy}}" x2="${{x2}}" y2="${{y2}}" stroke="#30363d" stroke-width="0.5"/>`;
|
html += `<line x1="${{cx}}" y1="${{cy}}" x2="${{x2}}" y2="${{y2}}" stroke="#30363d" stroke-width="0.5"/>`;
|
||||||
|
|
||||||
// Add labels at consistent distance
|
|
||||||
const labelDist = maxRadius + 35;
|
const labelDist = maxRadius + 35;
|
||||||
const lx = cx + labelDist * Math.cos(rad);
|
const lx = cx + labelDist * Math.cos(rad);
|
||||||
const ly = cy + labelDist * Math.sin(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>`;
|
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 = [];
|
let points = [];
|
||||||
angles.forEach((angle, i) => {{
|
angles.forEach((angle, i) => {{
|
||||||
const normalizedScore = normalizedScores[keys[i]];
|
const normalizedScore = normalizedScores[keys[i]];
|
||||||
@@ -916,14 +983,11 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str =
|
|||||||
points.push(`${{x}},${{y}}`);
|
points.push(`${{x}},${{y}}`);
|
||||||
}});
|
}});
|
||||||
|
|
||||||
// Determine dominant category color
|
|
||||||
const dominantKey = Object.keys(scores).reduce((a, b) => scores[a] > scores[b] ? a : b);
|
const dominantKey = Object.keys(scores).reduce((a, b) => scores[a] > scores[b] ? a : b);
|
||||||
const dominantColor = colors[dominantKey];
|
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"/>`;
|
html += `<polygon points="${{points.join(' ')}}" fill="${{dominantColor}}" fill-opacity="0.4" stroke="${{dominantColor}}" stroke-width="2.5"/>`;
|
||||||
|
|
||||||
// Draw points
|
|
||||||
angles.forEach((angle, i) => {{
|
angles.forEach((angle, i) => {{
|
||||||
const normalizedScore = normalizedScores[keys[i]];
|
const normalizedScore = normalizedScores[keys[i]];
|
||||||
const rad = (angle - 90) * Math.PI / 180;
|
const rad = (angle - 90) * Math.PI / 180;
|
||||||
@@ -935,7 +999,6 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str =
|
|||||||
|
|
||||||
html += '</svg>';
|
html += '</svg>';
|
||||||
|
|
||||||
// Legend
|
|
||||||
html += '<div class="radar-legend">';
|
html += '<div class="radar-legend">';
|
||||||
keys.forEach(key => {{
|
keys.forEach(key => {{
|
||||||
html += '<div class="radar-legend-item">';
|
html += '<div class="radar-legend-item">';
|
||||||
|
|||||||
Reference in New Issue
Block a user