Initial commit: Thanos Systems Monitor

- Cyberpunk-themed Unraid dashboard
- Disk usage monitoring with ring charts
- System metrics (CPU, Memory, Parity)
- Docker container status display
- Optimized for Raspberry Pi 3
- GraphQL API integration

🤖 Generated with Claude Code (https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Simard
2025-11-22 20:08:26 -06:00
commit 1b30aa4892
10 changed files with 5639 additions and 0 deletions

494
script.js Normal file
View File

@@ -0,0 +1,494 @@
// API Configuration
// IMPORTANT: Replace these values with your actual Unraid server details
const API_CONFIG = {
serverUrl: 'http://YOUR_UNRAID_IP:PORT/graphql',
apiKey: 'YOUR_API_KEY_HERE'
};
// 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 } } }`;
const data = await executeGraphQLQuery(query);
return data.docker.containers;
}
// 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');
progressBar.style.width = percentage + '%';
progressText.textContent = percentage + '%';
// 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');
progressBar.style.width = percentage + '%';
progressText.textContent = percentage + '%';
// 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) {
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';
}
}
// 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';
}
}
// 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';
}
}
// 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';
}
}
// 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';
}
// 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)';
}
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</span>
</div>
`;
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';
dockerElement.innerHTML = `
<div class="docker-name">${docker.name}</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
};
});
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);
});