Files
caddy-waf/ui/index.html
fabriziosalmi 6b94875c0a ui fix
2025-02-03 00:39:15 +01:00

803 lines
33 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Real-time Security Metrics</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
integrity="sha512-9usAa10IRO0HhonpyAIVpjrylPvoDwiPUiKdWk5t3PyolY1cOd4DSE0Ga+ri4AuTroPR5aQvXU9xC6qOPnzFeg=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<script src="https://cdn.jsdelivr.net/npm/chart.js" defer></script>
<style>
/* --- Default Theme (Dark) --- */
:root {
--background-color: #121212;
--container-background-color: #1e1e1e;
--text-color: #eee;
--label-color: #bbb;
--accent-color: #64b5f6;
--success-color: #2ecc71;
--error-color: #e74c3c;
--section-border-color: #333;
--toggle-active-color: #64b5f6;
--toggle-inactive-color: #555;
--transition-duration: 0.2s;
--font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
--table-row-bg-even: #282828;
--table-row-bg-odd: #303030;
}
/* --- Light Theme --- */
:root[data-theme="light"] {
--background-color: #f9f9f9;
--container-background-color: #fff;
--text-color: #333;
--label-color: #777;
--accent-color: #2196f3;
--success-color: #27ae60;
--error-color: #c0392b;
--section-border-color: #ddd;
--toggle-active-color: #2196f3;
--toggle-inactive-color: #ccc;
--table-row-bg-even: #f0f0f0;
--table-row-bg-odd: #e8e8e8;
}
/* --- Automatic Theme Detection --- */
@media (prefers-color-scheme: light) {
:root {
--background-color: #f9f9f9;
--container-background-color: #fff;
--text-color: #333;
--label-color: #777;
--accent-color: #2196f3;
--success-color: #27ae60;
--error-color: #c0392b;
--section-border-color: #ddd;
--toggle-active-color: #2196f3;
--toggle-inactive-color: #ccc;
--table-row-bg-even: #f0f0f0;
--table-row-bg-odd: #e8e8e8;
}
}
body {
font-family: var(--font-family);
background-color: var(--background-color);
color: var(--text-color);
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
transition: background-color var(--transition-duration) ease, color var(--transition-duration) ease;
}
.container {
background-color: var(--container-background-color);
padding: 30px;
border-radius: 10px;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.25);
width: 95%;
max-width: 1200px;
margin-top: 25px;
transition: background-color var(--transition-duration) ease, box-shadow var(--transition-duration) ease;
}
.section-header {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.section-header a {
margin-right: auto;
}
.theme-toggle-container {
margin-left: auto;
}
.theme-toggle {
background: none;
border: none;
color: var(--text-color);
font-size: 1.4em;
cursor: pointer;
transition: color var(--transition-duration) ease;
}
.theme-toggle:focus {
outline: 2px solid var(--accent-color);
}
.section-icon {
font-size: 1.6em;
margin-right: 10px;
color: var(--label-color);
}
.section h2 {
font-size: 1.7em;
margin: 0;
font-weight: 500;
}
.metric-group {
display: flex;
justify-content: space-around;
margin-bottom: 20px;
flex-wrap: wrap;
}
/* Style for the second metric group to keep metrics in one line */
.metric-group.small-metrics {
flex-wrap: nowrap;
}
.metric.small-metrics-item {
min-width: 120px;
}
.metric.small-metrics-item .metric-icon {
font-size: 2em;
margin-bottom: 8px;
}
.metric.small-metrics-item .metric-label {
font-size: 1.1em; /* Increased */
margin-bottom: 4px;
}
.metric.small-metrics-item .metric-value {
font-size: 1.7em; /* Increased */
}
.metric {
text-align: center;
margin-bottom: 20px;
min-width: 160px;
}
.metric-icon {
font-size: 2.4em;
margin-bottom: 12px;
color: var(--label-color);
display: block;
}
.metric-label {
font-size: 1.1em;
color: var (--label-color);
margin-bottom: 6px;
font-weight: 400;
}
.metric-value {
font-size: 1.9em;
font-weight: bold;
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.3);
transition: color var(--transition-duration) ease, font-size var(--transition-duration) ease;
}
.section {
margin-bottom: 25px;
padding-bottom: 18px;
border-bottom: 1px solid var(--section-border-color);
}
#timeline-chart {
max-height: 40vh;
border-radius: 8px;
margin-top: 18px;
height: auto !important;
}
#rules-table-container {
margin-top: 18px;
overflow-x: auto;
}
#rules-table {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
font-size: 1.1em;
}
#rules-table th,
#rules-table td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid var(--section-border-color);
}
#rules-table th {
font-weight: bold;
color: var(--label-color);
text-transform: uppercase;
letter-spacing: 0.04em;
}
#rules-table tbody tr:nth-child(odd) {
background-color: var(--table-row-bg-odd);
}
#rules-table tbody tr:nth-child(even) {
background-color: var(--table-row-bg-even);
}
#rules-table tbody tr:last-child td {
border-bottom: none;
}
/* --- GeoIP Stats styles --- */
#geoip-stats-container {
margin-top: 18px;
}
#geoip-stats-container p {
margin: 8px 0;
font-size: 0.95em;
}
/* --- Update Animation --- */
.updating {
animation: pulse 0.3s;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
.rule-hits-updating {
animation: highlight-color 0.3s;
}
@keyframes highlight-color {
0% {
background-color: transparent;
color: var(--text-color);
}
50% {
background-color: var(--accent-color);
color: var(--background-color);
}
100% {
background-color: transparent;
color: var(--text-color);
}
}
/* New rule for the horizontal rule */
hr.custom-hr {
margin-bottom: 40px;
border: 1px solid var(--section-border-color);
}
</style>
</head>
<body>
<div class="container">
<div class="section">
<div class="section-header">
<a href="https://github.com/fabriziosalmi/caddy-waf" target="_blank" aria-label="GitHub Repository">
<i class="fa-brands fa-github" style="color: var(--text-color); font-size: x-large;"></i>
</a>
<div class="theme-toggle-container">
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
<i class="fas fa-sun"></i>
</button>
</div>
</div>
<div class="metric-group">
<div class="metric">
<i class="metric-icon fas fa-globe" style="color: var(--accent-color);"></i>
<div class="metric-label">Total Requests</div>
<div class="metric-value" id="total-requests">0</div>
</div>
<div class="metric">
<i class="metric-icon fas fa-check-circle" style="color: var(--success-color);"></i>
<div class="metric-label">Allowed Requests</div>
<div class="metric-value allowed" id="allowed-requests">0</div>
</div>
<div class="metric">
<i class="metric-icon fas fa-ban" style="color: var(--error-color);"></i>
<div class="metric-label">Blocked Requests</div>
<div class="metric-value blocked" id="blocked-requests">0</div>
</div>
<div class="metric">
<i class="metric-icon fas fa-chart-pie" style="color: var (--error-color);"></i>
<div class="metric-label">Blocked %</div>
<div class="metric-value blocked-percentage" id="blocked-percentage">0%</div>
</div>
</div>
<hr class="custom-hr">
<div class="section">
<div class="section-header">
<i class="section-icon fas fa-chart-line"></i>
<h2>Request Timeline</h2>
</div>
<div style="margin-bottom: 10px;">
<label style="margin-right: 10px;">Log Scale</label>
<input type="checkbox" id="log-scale-toggle">
</div>
<canvas id="timeline-chart"></canvas>
</div>
<div class="metric-group small-metrics" style="font-size: small;">
<div class="metric small-metrics-item">
<i class="metric-icon fas fa-circle-xmark" style="color: var(--label-color);"></i>
<div class="metric-label">DNS Blacklist Hits</div>
<div class="metric-value" id="dns-blacklist-hits">0</div>
</div>
<div class="metric small-metrics-item">
<i class="metric-icon fas fa-ethernet" style="color: var(--label-color);"></i>
<div class="metric-label">IP Blacklist Hits</div>
<div class="metric-value" id="ip-blacklist-hits">0</div>
</div>
<div class="metric small-metrics-item">
<i class="metric-icon fas fa-gauge" style="color: var(--label-color);"></i>
<div class="metric-label">Rate Limit Hits</div>
<div class="metric-value" id="rate-limit-hits">0</div>
</div>
<div class="metric small-metrics-item">
<i class="metric-icon fas fa-earth" style="color: var(--label-color);"></i>
<div class="metric-label">GeoIP Hits</div>
<div class="metric-value" id="geoip-hits">0</div>
</div>
<div class="metric small-metrics-item">
<i class="metric-icon fas fa-1" style="color: var(--label-color);"></i>
<div class="metric-label">Phase 1 Hits</div>
<div class="metric-value" id="phase-1-hits" style="font-size: 1.7em;">0</div>
</div>
<div class="metric small-metrics-item">
<i class="metric-icon fas fa-2" style="color: var(--label-color);"></i>
<div class="metric-label">Phase 2 Hits</div>
<div class="metric-value" id="phase-2-hits" style="font-size: 1.7em;">0</div>
</div>
<div class="metric small-metrics-item">
<i class="metric-icon fas fa-3" style="color: var(--label-color);"></i>
<div class="metric-label">Phase 3 Hits</div>
<div class="metric-value" id="phase-3-hits" style="font-size: 1.7em;">0</div>
</div>
<div class="metric small-metrics-item">
<i class="metric-icon fas fa-4" style="color: var(--label-color);"></i>
<div class="metric-label">Phase 4 Hits</div>
<div class="metric-value" id="phase-4-hits" style="font-size: 1.7em;">0</div>
</div>
</div>
</div>
<div class="section">
<div class="section-header">
<i class="section-icon fas fa-shield-alt"></i>
<h2>Top Security Rule Hits</h2>
</div>
<div id="rules-table-container">
<table id="rules-table">
<thead>
<tr>
<th>Rule Name</th>
<th>Hits</th>
</tr>
</thead>
<tbody id="rules-table-body">
<tr>
<td colspan="2" style="text-align: center; padding: 20px; color: var(--label-color);">
Loading rule hits...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="section">
<div class="section-header"></div>
<i class="section-icon fas fa-globe"></i>
<h2>GeoIP Stats</h2>
</div>
<div id="geoip-stats-container">
<p>Loading GeoIP data...</p>
</div>
</div>
</div>
<script>
window.addEventListener('DOMContentLoaded', function() {
// Get DOM elements
const totalRequestsElement = document.getElementById('total-requests');
const allowedRequestsElement = document.getElementById('allowed-requests');
const blockedRequestsElement = document.getElementById('blocked-requests');
const blockedPercentageElement = document.getElementById('blocked-percentage');
const phase1HitsElement = document.getElementById('phase-1-hits');
const phase2HitsElement = document.getElementById('phase-2-hits');
const phase3HitsElement = document.getElementById('phase-3-hits');
const phase4HitsElement = document.getElementById('phase-4-hits');
const dnsBlacklistHitsElement = document.getElementById('dns-blacklist-hits');
const ipBlacklistHitsElement = document.getElementById('ip-blacklist-hits');
const rateLimitHitsElement = document.getElementById('rate-limit-hits');
const geoipHitsElement = document.getElementById('geoip-hits');
const geoipStatsContainer = document.getElementById('geoip-stats-container');
const rulesTableBody = document.getElementById('rules-table-body');
const timelineChartElement = document.getElementById('timeline-chart').getContext('2d');
const logScaleToggle = document.getElementById('log-scale-toggle');
const themeToggle = document.getElementById('theme-toggle');
const root = document.documentElement;
let allRuleHitsData = {}; // Store all rule hits
let hasGeoIPData = false; // Flag to track if geoip data has loaded
const localStorageThemeKey = 'dashboardTheme';
// --- Placeholder Timeline Data ---
const timelineLabels = ['Now']; // Initial label
const totalRequestsData = [0]; // Initial data point
const blockedRequestsData = [0]; // Initial data point
const dnsBlacklistHitsData = [0];
const ipBlacklistHitsData = [0];
const rateLimitHitsData = [0];
const geoipHitsData = [0];
const phase1HitsData = [0];
const phase2HitsData = [0];
const phase3HitsData = [0];
const phase4HitsData = [0];
// --- Initialize Chart ---
const initialDatasets = [{
label: 'Total Requests',
data: totalRequestsData,
borderColor: '#367edb',
backgroundColor: '#367edb',
tension: 0.4
}, {
label: 'Blocked Requests',
data: blockedRequestsData,
borderColor: '#e74c3c',
backgroundColor: '#e74c3c',
tension: 0.4
}];
// Helper function to check if any element in an array is non-zero
function hasNonZeroValue(arr) {
return arr.some(val => val !== 0);
}
// Create the chart
const timelineChart = new Chart(timelineChartElement, {
type: 'line',
data: {
labels: timelineLabels,
datasets: initialDatasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
type: 'linear',
beginAtZero: true,
grid: { color: '#444' },
ticks: {
color: '#eee', callback: function (value, index, ticks) {
if (value === 0) return 0;
return value >= 1000 ? value / 1000 + "k" : value;
}
}
},
x: {
grid: { color: '#444' },
ticks: { color: '#eee' }
}
},
plugins: {
legend: {
labels: { color: '#eee' }
}
}
}
});
function updateChartOptions() {
const currentTheme = root.getAttribute('data-theme');
timelineChart.options.plugins.legend.labels.color = currentTheme === 'light' ? '#333' : '#eee';
timelineChart.update()
}
// Event listener for the log scale toggle
logScaleToggle.addEventListener('change', function () {
timelineChart.options.scales.y.type = this.checked ? 'logarithmic' : 'linear';
timelineChart.update();
});
// Function to manage chart dataset visibility
function updateChartDatasets() {
const datasetsToRemove = [];
timelineChart.data.datasets.forEach((dataset, index) => {
if (dataset.label === 'DNS Blacklist Hits' && !hasNonZeroValue(dnsBlacklistHitsData)) datasetsToRemove.push(index);
if (dataset.label === 'IP Blacklist Hits' && !hasNonZeroValue(ipBlacklistHitsData)) datasetsToRemove.push(index);
if (dataset.label === 'Rate Limit Hits' && !hasNonZeroValue(rateLimitHitsData)) datasetsToRemove.push(index);
if (dataset.label === 'GeoIP Hits' && !hasNonZeroValue(geoipHitsData)) datasetsToRemove.push(index);
if (dataset.label === 'Phase 1 Hits' && !hasNonZeroValue(phase1HitsData)) datasetsToRemove.push(index);
if (dataset.label === 'Phase 2 Hits' && !hasNonZeroValue(phase2HitsData)) datasetsToRemove.push(index);
if (dataset.label === 'Phase 3 Hits' && !hasNonZeroValue(phase3HitsData)) datasetsToRemove.push(index);
if (dataset.label === 'Phase 4 Hits' && !hasNonZeroValue(phase4HitsData)) datasetsToRemove.push(index);
});
// Remove datasets in reverse order to avoid index issues
for (let i = datasetsToRemove.length - 1; i >= 0; i--) {
timelineChart.data.datasets.splice(datasetsToRemove[i], 1);
}
// Add data sets only if needed
if (hasNonZeroValue(dnsBlacklistHitsData) && !timelineChart.data.datasets.find(dataset => dataset.label === 'DNS Blacklist Hits')) {
timelineChart.data.datasets.push({
label: 'DNS Blacklist Hits',
data: dnsBlacklistHitsData,
borderColor: '#FFA500',
backgroundColor: '#FFA500',
tension: 0.4
})
}
if (hasNonZeroValue(ipBlacklistHitsData) && !timelineChart.data.datasets.find(dataset => dataset.label === 'IP Blacklist Hits')) {
timelineChart.data.datasets.push({
label: 'IP Blacklist Hits',
data: ipBlacklistHitsData,
borderColor: '#800080',
backgroundColor: '#800080',
tension: 0.4
});
}
if (hasNonZeroValue(rateLimitHitsData) && !timelineChart.data.datasets.find(dataset => dataset.label === 'Rate Limit Hits')) {
timelineChart.data.datasets.push({
label: 'Rate Limit Hits',
data: rateLimitHitsData,
borderColor: '#008000',
backgroundColor: '#008000',
tension: 0.4
});
}
if (hasNonZeroValue(geoipHitsData) && !timelineChart.data.datasets.find(dataset => dataset.label === 'GeoIP Hits')) {
timelineChart.data.datasets.push({
label: 'GeoIP Hits',
data: geoipHitsData,
borderColor: '#8B4513',
backgroundColor: '#8B4513',
tension: 0.4
});
}
if (hasNonZeroValue(phase1HitsData) && !timelineChart.data.datasets.find(dataset => dataset.label === 'Phase 1 Hits')) {
timelineChart.data.datasets.push({
label: 'Phase 1 Hits',
data: phase1HitsData,
borderColor: '#0000FF',
backgroundColor: '#0000FF',
tension: 0.4
});
}
if (hasNonZeroValue(phase2HitsData) && !timelineChart.data.datasets.find(dataset => dataset.label === 'Phase 2 Hits')) {
timelineChart.data.datasets.push({
label: 'Phase 2 Hits',
data: phase2HitsData,
borderColor: '#FF0000',
backgroundColor: '#FF0000',
tension: 0.4
})
}
if (hasNonZeroValue(phase3HitsData) && !timelineChart.data.datasets.find(dataset => dataset.label === 'Phase 3 Hits')) {
timelineChart.data.datasets.push({
label: 'Phase 3 Hits',
data: phase3HitsData,
borderColor: '#00FFFF',
backgroundColor: '#00FFFF',
tension: 0.4
})
}
if (hasNonZeroValue(phase4HitsData) && !timelineChart.data.datasets.find(dataset => dataset.label === 'Phase 4 Hits')) {
timelineChart.data.datasets.push({
label: 'Phase 4 Hits',
data: phase4HitsData,
borderColor: '#FF00FF',
backgroundColor: '#FF00FF',
tension: 0.4
});
}
timelineChart.update();
}
// Function to fetch and update metrics
function updateMetrics() {
// change this to your own metrics endpoint
fetch('http://localhost:8080/waf_metrics', {
headers: {
'User-Agent': 'caddy-waf-ui'
}
})
.then(response => response.json())
.then(data => {
updateValue(totalRequestsElement, data.total_requests);
updateValue(allowedRequestsElement, data.allowed_requests);
updateValue(blockedRequestsElement, data.blocked_requests);
updateValue(dnsBlacklistHitsElement, data.dns_blacklist_hits);
updateValue(ipBlacklistHitsElement, data.ip_blacklist_hits);
// Update the rate-limit-hits with rate_limiter_blocked_requests
updateValue(rateLimitHitsElement, data.rate_limiter_blocked_requests);
// Use the length of the geoip_stats object
updateValue(geoipHitsElement, Object.keys(data.geoip_stats || {}).length);
const totalRequests = data.total_requests;
const blockedRequests = data.blocked_requests;
let blockedPercent = 0;
if (totalRequests > 0) {
blockedPercent = ((blockedRequests / totalRequests) * 100).toFixed(1);
}
updateValue(blockedPercentageElement, `${blockedPercent}%`);
allRuleHitsData = data.rule_hits;
updateRulesTableDisplay();
phase1HitsElement.textContent = data.rule_hits_by_phase['1'] || 0;
phase2HitsElement.textContent = data.rule_hits_by_phase['2'] || 0;
phase3HitsElement.textContent = data.rule_hits_by_phase['3'] || 0;
phase4HitsElement.textContent = data.rule_hits_by_phase['4'] || 0;
updateGeoIPStatsDisplay(data.geoip_stats);
const now = new Date().toLocaleTimeString();
timelineLabels.push(now);
totalRequestsData.push(data.total_requests);
blockedRequestsData.push(data.blocked_requests);
dnsBlacklistHitsData.push(data.dns_blacklist_hits);
ipBlacklistHitsData.push(data.ip_blacklist_hits);
// Update timeline for rateLimitHitsData with rate_limiter_blocked_requests
rateLimitHitsData.push(data.rate_limiter_blocked_requests);
geoipHitsData.push(Object.keys(data.geoip_stats || {}).length);
phase1HitsData.push(data.rule_hits_by_phase['1'] || 0);
phase2HitsData.push(data.rule_hits_by_phase['2'] || 0);
phase3HitsData.push(data.rule_hits_by_phase['3'] || 0);
phase4HitsData.push(data.rule_hits_by_phase['4'] || 0);
if (timelineLabels.length > 10) {
timelineLabels.shift();
totalRequestsData.shift();
blockedRequestsData.shift();
dnsBlacklistHitsData.shift();
ipBlacklistHitsData.shift();
rateLimitHitsData.shift();
geoipHitsData.shift();
phase1HitsData.shift();
phase2HitsData.shift();
phase3HitsData.shift();
phase4HitsData.shift();
}
updateChartDatasets();
})
.catch(error => {
console.error('Error fetching metrics data:', error);
rulesTableBody.innerHTML = `<tr><td colspan="2" style="text-align: center; padding: 20px; color: var(--error-color);">Error loading data.</td></tr>`;
geoipStatsContainer.innerHTML = `<p style="color: var(--error-color);">Error loading GeoIP data.</p>`;
});
}
// Function to update rule table display
function updateRulesTableDisplay() {
const previousRuleHits = allRuleHitsData;
rulesTableBody.innerHTML = '';
const sortedRuleEntries = Object.entries(allRuleHitsData)
.sort(([, countA], [, countB]) => countB - countA);
if (sortedRuleEntries.length === 0) {
rulesTableBody.innerHTML = `<tr><td colspan="2" style="text-align: center; padding: 20px; color: var(--label-color);">No rule hits yet.</td></tr>`;
return;
}
sortedRuleEntries.forEach(([ruleName, hitCount]) => {
const row = rulesTableBody.insertRow();
const nameCell = row.insertCell();
const hitsCell = row.insertCell();
nameCell.textContent = ruleName;
hitsCell.textContent = hitCount;
if (previousRuleHits[ruleName] !== undefined && previousRuleHits[ruleName] !== hitCount) {
hitsCell.classList.add('rule-hits-updating');
setTimeout(() => {
hitsCell.classList.remove('rule-hits-updating');
}, 300)
}
});
}
// Function to update GeoIP Stats display
function updateGeoIPStatsDisplay(geoipStats) {
geoipStatsContainer.innerHTML = '';
if (!geoipStats || Object.keys(geoipStats).length === 0) {
geoipStatsContainer.innerHTML = `<p style="color: var(--label-color);">No GeoIP data yet.</p>`;
hasGeoIPData = true; // Set the flag to prevent repeated updates of initial text
return;
}
hasGeoIPData = true; // Set the flag to prevent repeated updates of initial text
for (const countryCode in geoipStats) {
if (geoipStats.hasOwnProperty(countryCode)) {
const hits = geoipStats[countryCode];
const countryName = getCountryName(countryCode);
const p = document.createElement('p');
p.textContent = `${countryName} (${countryCode.toUpperCase()}): ${hits} hits`;
geoipStatsContainer.appendChild(p);
}
}
}
// Helper function to update value with animation
function updateValue(element, newValue) {
element.classList.add('updating');
setTimeout(() => {
element.textContent = newValue;
element.classList.remove('updating');
}, 100);
}
// Helper function to get country name from code
function getCountryName(countryCode) {
const countryNames = {
"US": "United States",
"CA": "Canada",
"GB": "United Kingdom",
"DE": "Germany",
"FR": "France",
};
return countryNames[countryCode.toUpperCase()] || countryCode.toUpperCase();
}
function setInitialTheme() {
const savedTheme = localStorage.getItem(localStorageThemeKey);
if (savedTheme) {
root.setAttribute('data-theme', savedTheme);
}
else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
root.setAttribute('data-theme', 'light');
} else {
root.setAttribute('data-theme', 'dark'); // Default to dark
}
updateThemeToggleIcon();
}
function updateThemeToggleIcon() {
const currentTheme = root.getAttribute('data-theme');
themeToggle.innerHTML = currentTheme === 'light' ? '<i class="fas fa-moon"></i>' : '<i class="fas fa-sun"></i>';
}
themeToggle.addEventListener('click', () => {
const currentTheme = root.getAttribute('data-theme');
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
root.setAttribute('data-theme', newTheme);
localStorage.setItem(localStorageThemeKey, newTheme);
updateThemeToggleIcon();
updateChartOptions();
});
// Initial metrics update and set interval
setInitialTheme();
updateMetrics();
updateChartOptions();
setInterval(updateMetrics, 5000);
});
</script>
</body>
</html>