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:
494
script.js
Normal file
494
script.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user