Files
unraid-dashboard/script.js
Michael Simard 8396fbcf6f Add Docker deployment for Unraid
- Dockerfile with nginx:alpine for static file serving
- docker-compose.yml with port 9113:80 mapping
- deploy.sh for git-based deployment to Unraid
- setup-unraid.sh for initial server configuration
- manage.sh for container operations (logs, status, restart, etc.)
- .gitignore to exclude config.js

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 09:43:02 -06:00

656 lines
22 KiB
JavaScript

// 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 `<span class="trend-indicator trend-${trend}">${icons[trend]}</span>`;
}
// 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 `<a href="${url}" target="_blank" class="webui-link" title="Open Web UI">:${port}</a>`;
}
// Helper function to create tooltip HTML
function createTooltip(title, data) {
const rows = Object.entries(data).map(([label, value]) =>
`<div class="tooltip-row">
<span class="tooltip-label">${label}:</span>
<span class="tooltip-value">${value}</span>
</div>`
).join('');
return `<div class="tooltip">
<div class="tooltip-title">${title}</div>
<div class="tooltip-content">
${rows}
</div>
</div>`;
}
// 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 = `
<div class="disk-header">
<span class="disk-name">${diskLabel}</span>
<span class="disk-info">${disk.size} | ${disk.temperature}</span>
</div>
<div class="progress-bar">
<div class="progress-fill ${progressClass}" style="width: ${usedPercentage}%"></div>
<span class="progress-text">${usedPercentage}% USED ${createTrendIndicator(trend)}</span>
</div>
${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 = `
<div class="docker-name">${docker.name}${portLink}</div>
<div class="docker-status ${docker.status}">${docker.status.toUpperCase()}</div>
`;
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);
});