// API Configuration is loaded from config.js // If API_CONFIG is not defined, the config.js file is missing if (typeof API_CONFIG === 'undefined') { console.error('API_CONFIG is not defined. Please copy config.example.js to config.js and configure your credentials.'); alert('Configuration Error: config.js file is missing. Please check the console for details.'); } // Theme Toggle Functionality const ThemeToggle = { init() { console.log('Theme toggle initializing...'); // Load saved theme preference or default to dark mode const savedTheme = localStorage.getItem('theme') || 'dark'; console.log('Saved theme:', savedTheme); this.setTheme(savedTheme); // Add click event listener to toggle button const toggleButton = document.getElementById('theme-toggle'); console.log('Toggle button found:', toggleButton); if (toggleButton) { toggleButton.addEventListener('click', () => { console.log('Theme toggle clicked!'); this.toggleTheme(); }); } }, setTheme(theme) { console.log('Setting theme to:', theme); const body = document.body; const toggleButton = document.getElementById('theme-toggle'); if (theme === 'light') { body.classList.add('light-mode'); console.log('Light mode activated - body classes:', body.className); if (toggleButton) toggleButton.textContent = 'DARK MODE'; } else { body.classList.remove('light-mode'); console.log('Dark mode activated - body classes:', body.className); if (toggleButton) toggleButton.textContent = 'LIGHT MODE'; } localStorage.setItem('theme', theme); }, toggleTheme() { const currentTheme = document.body.classList.contains('light-mode') ? 'light' : 'dark'; const newTheme = currentTheme === 'light' ? 'dark' : 'light'; this.setTheme(newTheme); // Refresh dashboard to update colors if (typeof updateDashboard === 'function') { updateDashboard(); } } }; // Initialize theme on page load document.addEventListener('DOMContentLoaded', () => { ThemeToggle.init(); }); // Theme-aware color helper function getThemeColors() { const isLightMode = document.body.classList.contains('light-mode'); if (isLightMode) { return { normal: '#0066cc', // Blue for normal usage warning: '#f0ad4e', // Orange for warning critical: '#d9534f' // Red for critical }; } else { return { normal: '#00ffff', // Cyan for normal usage warning: '#ff00ff', // Magenta for warning critical: '#ffffff' // White for critical }; } } // Historical data tracking const HistoricalData = { maxDataPoints: 20, // Keep last 20 data points cpu: [], memory: [], disks: {}, addCPUData(percentage) { this.cpu.push({ timestamp: Date.now(), value: percentage }); if (this.cpu.length > this.maxDataPoints) { this.cpu.shift(); } }, addMemoryData(percentage) { this.memory.push({ timestamp: Date.now(), value: percentage }); if (this.memory.length > this.maxDataPoints) { this.memory.shift(); } }, addDiskData(diskName, percentage) { if (!this.disks[diskName]) { this.disks[diskName] = []; } this.disks[diskName].push({ timestamp: Date.now(), value: percentage }); if (this.disks[diskName].length > this.maxDataPoints) { this.disks[diskName].shift(); } }, getTrend(dataArray) { if (dataArray.length < 2) return 'stable'; const recent = dataArray.slice(-3); const avg = recent.reduce((sum, item) => sum + item.value, 0) / recent.length; const oldest = recent[0].value; const change = avg - oldest; if (Math.abs(change) < 1) return 'stable'; return change > 0 ? 'up' : 'down'; } }; // GraphQL query executor async function executeGraphQLQuery(query) { try { const response = await fetch(API_CONFIG.serverUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': API_CONFIG.apiKey }, body: JSON.stringify({ query }) }); if (!response.ok) { const errorText = await response.text(); console.error('API Error:', errorText); throw new Error(`HTTP error! status: ${response.status}`); } const result = await response.json(); if (result.errors) { console.error('GraphQL errors:', result.errors); throw new Error('GraphQL query failed'); } return result.data; } catch (error) { console.error('API request failed:', error); throw error; } } // Fetch system metrics (CPU, Memory usage) async function fetchSystemMetrics() { const query = `query { metrics { cpu { percentTotal } memory { total used free percentTotal } } }`; const data = await executeGraphQLQuery(query); return data.metrics; } // Fetch array and disk information async function fetchArrayInfo() { const query = `query { array { state disks { name size status temp fsSize fsFree fsUsed } parityCheckStatus { status errors date } } }`; const data = await executeGraphQLQuery(query); return data.array; } // Fetch Docker container information async function fetchDockerContainers() { const query = `query { docker { containers { id names state status autoStart ports { privatePort publicPort type } } } }`; const data = await executeGraphQLQuery(query); return data.docker.containers; } // Helper function to create trend indicator HTML function createTrendIndicator(trend) { const icons = { up: '▲', down: '▼', stable: '━' }; return `${icons[trend]}`; } // Helper function to extract web UI ports from container ports function getWebUIPort(ports) { if (!ports || ports.length === 0) { console.log('No ports available'); return null; } console.log('All ports:', JSON.stringify(ports)); // Common web UI ports (HTTP/HTTPS) const webPorts = [80, 443, 8080, 8443, 3000, 5000, 8000, 8888, 9000]; // Find TCP ports that are commonly used for web interfaces // Note: GraphQL returns type as uppercase "TCP" or "UDP" const tcpPorts = ports.filter(p => p.type === 'TCP' && p.publicPort); console.log('TCP ports with public mapping:', JSON.stringify(tcpPorts)); // Prioritize common web ports for (const port of webPorts) { const match = tcpPorts.find(p => p.privatePort === port); if (match) { console.log('Found common web port:', match.publicPort); return match.publicPort; } } // If no common port found, return the first TCP port with a public mapping if (tcpPorts.length > 0) { console.log('Using first TCP port:', tcpPorts[0].publicPort); return tcpPorts[0].publicPort; } console.log('No suitable port found'); return null; } // Helper function to create a clickable web UI link function createWebUILink(port) { if (!port) return ''; // Use the Unraid server IP from config const serverHost = API_CONFIG.serverUrl.match(/http:\/\/([^:]+)/)[1]; const url = `http://${serverHost}:${port}`; return `:${port}`; } // Helper function to create tooltip HTML function createTooltip(title, data) { const rows = Object.entries(data).map(([label, value]) => `
${label}: ${value}
` ).join(''); return `
${title}
${rows}
`; } // Generic ring chart update function to reduce code duplication function updateRingChartGeneric(elementId, containerId, title, percentage, usedKB, totalKB) { const ring = document.getElementById(`${elementId}-ring`); const text = document.getElementById(`${elementId}-percentage`); const fraction = document.getElementById(`${elementId}-fraction`); const container = document.getElementById(containerId); const circumference = 2 * Math.PI * 80; const offset = circumference - (percentage / 100) * circumference; ring.style.strokeDashoffset = offset; text.textContent = percentage + '%'; const usedTB = (usedKB / 1024 / 1024 / 1024).toFixed(2); const totalTB = (totalKB / 1024 / 1024 / 1024).toFixed(2); const freeTB = (totalTB - usedTB).toFixed(2); fraction.textContent = `${usedTB} / ${totalTB} TB`; // Add tooltip const existingTooltip = container.querySelector('.tooltip'); if (existingTooltip) { existingTooltip.remove(); } const tooltipHTML = createTooltip(title, { 'Used': `${usedTB} TB`, 'Free': `${freeTB} TB`, 'Total': `${totalTB} TB`, 'Usage': `${percentage}%` }); container.insertAdjacentHTML('beforeend', tooltipHTML); // Change color based on usage (theme-aware) const colors = getThemeColors(); let color; if (percentage >= 90) { color = colors.critical; } else if (percentage >= 70) { color = colors.warning; } else { color = colors.normal; } ring.style.stroke = color; text.style.fill = color; fraction.style.fill = color; } // Update timestamp function updateTimestamp() { const now = new Date(); const formatted = now.toLocaleString('en-US', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); document.getElementById('timestamp').textContent = formatted; } // Update CPU usage function updateCPU(percentage) { const progressBar = document.getElementById('cpu-progress'); const progressText = document.getElementById('cpu-text'); // Track historical data HistoricalData.addCPUData(percentage); const trend = HistoricalData.getTrend(HistoricalData.cpu); progressBar.style.width = percentage + '%'; progressText.innerHTML = percentage + '%' + createTrendIndicator(trend); // Apply color coding progressBar.classList.remove('warning', 'critical'); if (percentage >= 90) { progressBar.classList.add('critical'); } else if (percentage >= 70) { progressBar.classList.add('warning'); } } // Update Memory usage function updateMemory(percentage) { const progressBar = document.getElementById('memory-progress'); const progressText = document.getElementById('memory-text'); // Track historical data HistoricalData.addMemoryData(percentage); const trend = HistoricalData.getTrend(HistoricalData.memory); progressBar.style.width = percentage + '%'; progressText.innerHTML = percentage + '%' + createTrendIndicator(trend); // Apply color coding progressBar.classList.remove('warning', 'critical'); if (percentage >= 90) { progressBar.classList.add('critical'); } else if (percentage >= 70) { progressBar.classList.add('warning'); } } // Update Disk Ring Chart function updateDiskRingChart(percentage, usedKB, totalKB) { updateRingChartGeneric('disk', 'disk-tooltip-container', 'Total Disk Usage', percentage, usedKB, totalKB); } // Update Photos Ring Chart (Disk 7) function updatePhotosRingChart(percentage, usedKB, totalKB) { updateRingChartGeneric('photos', 'photos-tooltip-container', 'Photos Storage (Disk 7)', percentage, usedKB, totalKB); } // Update Media Ring Chart (All disks except 6 and 7) function updateMediaRingChart(percentage, usedKB, totalKB) { updateRingChartGeneric('media', 'media-tooltip-container', 'Media Storage', percentage, usedKB, totalKB); } // Update Docker Ring Chart (Disk 6) function updateDockerRingChart(percentage, usedKB, totalKB) { updateRingChartGeneric('docker', 'docker-tooltip-container', 'Docker Storage (Disk 6)', percentage, usedKB, totalKB); } // Update Parity status function updateParity(parityData) { const statusElement = document.getElementById('parity-status'); const errorsElement = document.getElementById('parity-errors'); statusElement.textContent = parityData.status; errorsElement.textContent = parityData.errors; if (parityData.status !== 'VALID' || parityData.errors > 0) { statusElement.classList.add('error'); errorsElement.classList.add('error'); } else { statusElement.classList.remove('error'); errorsElement.classList.remove('error'); } document.getElementById('parity-last-check').textContent = parityData.lastCheck; } // Update Disk Array function updateDisks(disks) { const container = document.getElementById('disk-container'); container.innerHTML = ''; disks.forEach(disk => { const diskElement = document.createElement('div'); diskElement.className = 'disk-item'; const usedPercentage = disk.used; let progressClass = ''; if (usedPercentage >= 90) { progressClass = 'critical'; } else if (usedPercentage >= 80) { progressClass = 'warning'; } // Track historical data for this disk HistoricalData.addDiskData(disk.name, usedPercentage); const trend = HistoricalData.getTrend(HistoricalData.disks[disk.name] || []); // Add label for disk6 and disk7 let diskLabel = disk.name; if (disk.name === 'disk6') { diskLabel = disk.name + ' (docker)'; } else if (disk.name === 'disk7') { diskLabel = disk.name + ' (photos)'; } // Create tooltip with disk details const diskTooltip = createTooltip(`${disk.name} Details`, { 'Capacity': disk.size, 'Temperature': disk.temperature, 'Usage': `${usedPercentage}%`, 'Status': disk.status || 'Active' }); diskElement.innerHTML = `
${diskLabel} ${disk.size} | ${disk.temperature}
${usedPercentage}% USED ${createTrendIndicator(trend)}
${diskTooltip} `; container.appendChild(diskElement); }); } // Update Docker Containers function updateDocker(containers) { const container = document.getElementById('docker-container'); const gridDiv = document.createElement('div'); gridDiv.className = 'docker-grid'; containers.forEach(docker => { const dockerElement = document.createElement('div'); dockerElement.className = 'docker-item'; // Extract web UI port console.log('Container:', docker.name, 'Ports:', docker.ports); const webUIPort = getWebUIPort(docker.ports); console.log('Detected web UI port for', docker.name, ':', webUIPort); const portLink = createWebUILink(webUIPort); dockerElement.innerHTML = `
${docker.name}${portLink}
${docker.status.toUpperCase()}
`; gridDiv.appendChild(dockerElement); }); container.innerHTML = ''; container.appendChild(gridDiv); } // Process and update all dashboard data async function updateDashboard() { try { // Fetch all data in parallel const [metrics, arrayInfo, dockerContainers] = await Promise.all([ fetchSystemMetrics(), fetchArrayInfo(), fetchDockerContainers() ]); // Update CPU usage from metrics if (metrics.cpu && metrics.cpu.percentTotal !== undefined) { const cpuUsage = Math.round(metrics.cpu.percentTotal); updateCPU(cpuUsage); } // Update Memory usage from metrics if (metrics.memory && metrics.memory.percentTotal !== undefined) { const memoryPercentage = Math.round(metrics.memory.percentTotal); updateMemory(memoryPercentage); } // Update Parity status if (arrayInfo.parityCheckStatus) { const parity = arrayInfo.parityCheckStatus; const parityData = { status: parity.status || 'UNKNOWN', lastCheck: parity.date || 'N/A', errors: parity.errors || 0 }; updateParity(parityData); } // Update Disks if (arrayInfo.disks && arrayInfo.disks.length > 0) { console.log('Processing disks:', arrayInfo.disks); // Calculate total disk usage across all disks let totalCapacity = 0; let totalUsed = 0; let dockerCapacity = 0; let dockerUsed = 0; let photosCapacity = 0; let photosUsed = 0; let mediaCapacity = 0; let mediaUsed = 0; const disksData = arrayInfo.disks.map(disk => { // Use filesystem data from the disk itself (fsSize, fsUsed are in KB) const total = disk.fsSize || disk.size || 0; const used = disk.fsUsed || 0; const usedPercentage = total > 0 ? Math.round((used / total) * 100) : 0; // Accumulate totals for ring charts totalCapacity += total; totalUsed += used; // Docker = disk6 if (disk.name === 'disk6') { dockerCapacity = total; dockerUsed = used; } // Photos = disk7 if (disk.name === 'disk7') { photosCapacity = total; photosUsed = used; } // Media = all disks except disk6 and disk7 if (disk.name !== 'disk6' && disk.name !== 'disk7') { mediaCapacity += total; mediaUsed += used; } // Handle temperature - could be null, number, or NaN let temperature = 'N/A'; if (disk.temp !== null && disk.temp !== undefined && !isNaN(disk.temp)) { temperature = disk.temp + '°C'; } return { name: disk.name || 'Unknown', size: formatBytes(total * 1024), // Convert KB to bytes for formatting used: usedPercentage, temperature: temperature }; }); // Update ring charts const totalUsagePercentage = totalCapacity > 0 ? Math.round((totalUsed / totalCapacity) * 100) : 0; console.log('Total usage:', { totalUsagePercentage, totalUsed, totalCapacity }); updateDiskRingChart(totalUsagePercentage, totalUsed, totalCapacity); const dockerUsagePercentage = dockerCapacity > 0 ? Math.round((dockerUsed / dockerCapacity) * 100) : 0; console.log('Docker usage:', { dockerUsagePercentage, dockerUsed, dockerCapacity }); updateDockerRingChart(dockerUsagePercentage, dockerUsed, dockerCapacity); const photosUsagePercentage = photosCapacity > 0 ? Math.round((photosUsed / photosCapacity) * 100) : 0; console.log('Photos usage:', { photosUsagePercentage, photosUsed, photosCapacity }); updatePhotosRingChart(photosUsagePercentage, photosUsed, photosCapacity); const mediaUsagePercentage = mediaCapacity > 0 ? Math.round((mediaUsed / mediaCapacity) * 100) : 0; console.log('Media usage:', { mediaUsagePercentage, mediaUsed, mediaCapacity }); updateMediaRingChart(mediaUsagePercentage, mediaUsed, mediaCapacity); console.log('Processed disk data:', disksData); updateDisks(disksData); } else { console.log('No disks data available'); } // Update Docker containers if (dockerContainers && dockerContainers.length > 0) { console.log('Processing docker containers:', dockerContainers); const dockerData = dockerContainers.map(container => { // Extract container name (remove leading slash if present) const name = container.names[0] ? container.names[0].replace(/^\//, '') : 'Unknown'; let state = container.state.toLowerCase(); // Map "exited" to "offline" if (state === 'exited') { state = 'offline'; } return { name: name, status: state, ports: container.ports || [] }; }); console.log('Processed docker data:', dockerData); updateDocker(dockerData); } else { console.log('No docker containers available'); } } catch (error) { console.error('Dashboard update failed:', error); // Show error state in UI showError('Failed to fetch data from Unraid server. Retrying...'); } } // Format bytes to human-readable format function formatBytes(bytes) { if (!bytes || bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; } // Show error message function showError(message) { const timestamp = document.getElementById('timestamp'); if (timestamp) { timestamp.textContent = `ERROR: ${message}`; timestamp.style.color = '#ffffff'; } } // Initialize dashboard document.addEventListener('DOMContentLoaded', () => { updateDashboard(); // Update every 5 seconds setInterval(updateDashboard, 5000); });