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>
This commit is contained in:
Michael Simard
2026-01-22 09:42:20 -06:00
parent 86d34e9462
commit 8396fbcf6f
10 changed files with 1551 additions and 162 deletions

401
script.js
View File

@@ -5,6 +5,125 @@ if (typeof API_CONFIG === 'undefined') {
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 {
@@ -55,12 +174,136 @@ async function fetchArrayInfo() {
// Fetch Docker container information
async function fetchDockerContainers() {
const query = `query { docker { containers { id names state status autoStart } } }`;
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();
@@ -81,8 +324,12 @@ 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.textContent = percentage + '%';
progressText.innerHTML = percentage + '%' + createTrendIndicator(trend);
// Apply color coding
progressBar.classList.remove('warning', 'critical');
@@ -98,8 +345,12 @@ 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.textContent = percentage + '%';
progressText.innerHTML = percentage + '%' + createTrendIndicator(trend);
// Apply color coding
progressBar.classList.remove('warning', 'critical');
@@ -112,132 +363,22 @@ function updateMemory(percentage) {
// Update Disk Ring Chart
function updateDiskRingChart(percentage, usedKB, totalKB) {
const ring = document.getElementById('disk-ring');
const text = document.getElementById('disk-percentage');
const fraction = document.getElementById('disk-fraction');
// SVG circle circumference calculation: 2 * PI * radius (radius = 80)
const circumference = 2 * Math.PI * 80; // 502.65
const offset = circumference - (percentage / 100) * circumference;
ring.style.strokeDashoffset = offset;
text.textContent = percentage + '%';
// Calculate and display fraction (convert KB to TB, rounded to 2 decimals)
const usedTB = (usedKB / 1024 / 1024 / 1024).toFixed(2);
const totalTB = (totalKB / 1024 / 1024 / 1024).toFixed(2);
fraction.textContent = `${usedTB} / ${totalTB} TB`;
// Change color based on usage
if (percentage >= 90) {
ring.style.stroke = '#ffffff';
text.style.fill = '#ffffff';
fraction.style.fill = '#ffffff';
} else if (percentage >= 70) {
ring.style.stroke = '#ff00ff';
text.style.fill = '#ff00ff';
fraction.style.fill = '#ff00ff';
} else {
ring.style.stroke = '#00ffff';
text.style.fill = '#00ffff';
fraction.style.fill = '#00ffff';
}
updateRingChartGeneric('disk', 'disk-tooltip-container', 'Total Disk Usage', percentage, usedKB, totalKB);
}
// Update Photos Ring Chart (Disk 7)
function updatePhotosRingChart(percentage, usedKB, totalKB) {
const ring = document.getElementById('photos-ring');
const text = document.getElementById('photos-percentage');
const fraction = document.getElementById('photos-fraction');
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);
fraction.textContent = `${usedTB} / ${totalTB} TB`;
// Change color based on usage
if (percentage >= 90) {
ring.style.stroke = '#ffffff';
text.style.fill = '#ffffff';
fraction.style.fill = '#ffffff';
} else if (percentage >= 70) {
ring.style.stroke = '#ff00ff';
text.style.fill = '#ff00ff';
fraction.style.fill = '#ff00ff';
} else {
ring.style.stroke = '#00ffff';
text.style.fill = '#00ffff';
fraction.style.fill = '#00ffff';
}
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) {
const ring = document.getElementById('media-ring');
const text = document.getElementById('media-percentage');
const fraction = document.getElementById('media-fraction');
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);
fraction.textContent = `${usedTB} / ${totalTB} TB`;
// Change color based on usage
if (percentage >= 90) {
ring.style.stroke = '#ffffff';
text.style.fill = '#ffffff';
fraction.style.fill = '#ffffff';
} else if (percentage >= 70) {
ring.style.stroke = '#ff00ff';
text.style.fill = '#ff00ff';
fraction.style.fill = '#ff00ff';
} else {
ring.style.stroke = '#00ffff';
text.style.fill = '#00ffff';
fraction.style.fill = '#00ffff';
}
updateRingChartGeneric('media', 'media-tooltip-container', 'Media Storage', percentage, usedKB, totalKB);
}
// Update Docker Ring Chart (Disk 6)
function updateDockerRingChart(percentage, usedKB, totalKB) {
const ring = document.getElementById('docker-ring');
const text = document.getElementById('docker-percentage');
const fraction = document.getElementById('docker-fraction');
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);
fraction.textContent = `${usedTB} / ${totalTB} TB`;
// Change color based on usage
if (percentage >= 90) {
ring.style.stroke = '#ffffff';
text.style.fill = '#ffffff';
fraction.style.fill = '#ffffff';
} else if (percentage >= 70) {
ring.style.stroke = '#ff00ff';
text.style.fill = '#ff00ff';
fraction.style.fill = '#ff00ff';
} else {
ring.style.stroke = '#00ffff';
text.style.fill = '#00ffff';
fraction.style.fill = '#00ffff';
}
updateRingChartGeneric('docker', 'docker-tooltip-container', 'Docker Storage (Disk 6)', percentage, usedKB, totalKB);
}
// Update Parity status
@@ -276,6 +417,10 @@ function updateDisks(disks) {
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') {
@@ -284,6 +429,14 @@ function updateDisks(disks) {
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>
@@ -291,8 +444,9 @@ function updateDisks(disks) {
</div>
<div class="progress-bar">
<div class="progress-fill ${progressClass}" style="width: ${usedPercentage}%"></div>
<span class="progress-text">${usedPercentage}% USED</span>
<span class="progress-text">${usedPercentage}% USED ${createTrendIndicator(trend)}</span>
</div>
${diskTooltip}
`;
container.appendChild(diskElement);
@@ -310,8 +464,14 @@ function updateDocker(containers) {
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}</div>
<div class="docker-name">${docker.name}${portLink}</div>
<div class="docker-status ${docker.status}">${docker.status.toUpperCase()}</div>
`;
@@ -449,7 +609,8 @@ async function updateDashboard() {
return {
name: name,
status: state
status: state,
ports: container.ports || []
};
});
console.log('Processed docker data:', dockerData);