- 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>
656 lines
22 KiB
JavaScript
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);
|
|
});
|