#!/usr/bin/env bash # # This bash script installs a modification to the Proxmox Virtual Environment (PVE) # web user interface (UI) to display NVIDIA GPU information. # # Author: Based on pve-mod-gui-sensors.sh by Meliox # License: MIT # ################### Configuration ############# # Temperature thresholds (Celsius) TEMP_WARNING=70 TEMP_CRITICAL=85 # Overwrite default backup location (leave empty for default ~/PVE-MODS) BACKUP_DIR="" ##################### DO NOT EDIT BELOW ####################### # This script's working directory SCRIPT_CWD="$(dirname "$(readlink -f "$0")")" # File paths PVE_MANAGER_LIB_JS_FILE="/usr/share/pve-manager/js/pvemanagerlib.js" NODES_PM_FILE="/usr/share/perl5/PVE/API2/Nodes.pm" #region message tools # Section header (bold) function msgb() { local message="$1" echo -e "\e[1m${message}\e[0m" } # Info (green) function info() { local message="$1" echo -e "\e[0;32m[info] ${message}\e[0m" } # Warning (yellow) function warn() { local message="$1" echo -e "\e[0;33m[warning] ${message}\e[0m" } # Error (red) function err() { local message="$1" echo -e "\e[0;31m[error] ${message}\e[0m" exit 1 } # Prompts (cyan) function ask() { local prompt="$1" local response read -p $'\n\e[1;36m'"${prompt}:"$'\e[0m ' response echo "$response" } #endregion message tools # Function to display usage information function usage { msgb "\nUsage:\n$0 [install | uninstall]\n" msgb "Options:" echo " install Install the NVIDIA GPU monitoring modification" echo " uninstall Remove the modification and restore original files" echo "" exit 1 } # System checks function check_root_privileges() { if [[ $EUID -ne 0 ]]; then err "This script must be run as root. Please run it with 'sudo $0'." fi info "Root privileges verified." } # Check if nvidia-smi is available function check_nvidia_smi() { if ! command -v nvidia-smi &>/dev/null; then err "nvidia-smi is not installed or not in PATH. Please install NVIDIA drivers first." fi info "nvidia-smi found." } # Detect NVIDIA GPUs function detect_gpus() { local gpu_count gpu_count=$(nvidia-smi --query-gpu=count --format=csv,noheader,nounits 2>/dev/null | head -1) if [[ -z "$gpu_count" ]] || [[ "$gpu_count" -eq 0 ]]; then err "No NVIDIA GPUs detected by nvidia-smi." fi echo "$gpu_count" } # Configure installation options function configure() { msgb "\n=== Detecting NVIDIA GPUs ===" check_nvidia_smi local gpu_count gpu_count=$(detect_gpus) info "Detected $gpu_count NVIDIA GPU(s):" # Display detected GPUs nvidia-smi --query-gpu=index,name --format=csv,noheader 2>/dev/null | while read -r line; do echo " GPU $line" done # Temperature unit selection msgb "\n=== Display Settings ===" local unit unit=$(ask "Display temperatures in Celsius [C] or Fahrenheit [f]? (C/f)") case "$unit" in [fF]) TEMP_UNIT="F" info "Using Fahrenheit." ;; *) TEMP_UNIT="C" info "Using Celsius." ;; esac } # Function to check if the modification is already installed function check_mod_installation() { if grep -q 'nvidiaGpuOutput' "$NODES_PM_FILE" 2>/dev/null; then err "NVIDIA GPU mod is already installed. Please uninstall first before reinstalling." fi } # Set backup directory function set_backup_directory() { if [[ -z "$BACKUP_DIR" ]]; then BACKUP_DIR="$HOME/PVE-MODS" info "Using default backup directory: $BACKUP_DIR" else if [[ ! -d "$BACKUP_DIR" ]]; then err "The specified backup directory does not exist: $BACKUP_DIR" fi info "Using custom backup directory: $BACKUP_DIR" fi } # Create backup directory function create_backup_directory() { set_backup_directory if [[ ! -d "$BACKUP_DIR" ]]; then mkdir -p "$BACKUP_DIR" 2>/dev/null || { err "Failed to create backup directory: $BACKUP_DIR. Please check permissions." } info "Created backup directory: $BACKUP_DIR" else info "Backup directory already exists: $BACKUP_DIR" fi } # Create file backup function create_file_backup() { local source_file="$1" local timestamp="$2" local filename filename=$(basename "$source_file") local backup_file="$BACKUP_DIR/nvidia-gpu.${filename}.$timestamp" [[ -f "$source_file" ]] || err "Source file does not exist: $source_file" [[ -r "$source_file" ]] || err "Cannot read source file: $source_file" cp "$source_file" "$backup_file" || err "Failed to create backup: $backup_file" # Verify backup integrity if ! cmp -s "$source_file" "$backup_file"; then err "Backup verification failed for: $backup_file" fi info "Created backup: $backup_file" } # Perform backup of files function perform_backup() { local timestamp timestamp=$(date +%Y%m%d_%H%M%S) msgb "\n=== Creating backups of modified files ===" create_backup_directory create_file_backup "$NODES_PM_FILE" "$timestamp" create_file_backup "$PVE_MANAGER_LIB_JS_FILE" "$timestamp" } # Restart pveproxy service function restart_proxy() { info "Restarting PVE proxy..." systemctl restart pveproxy } # Insert NVIDIA GPU data collection into Nodes.pm function insert_node_info() { msgb "\n=== Inserting NVIDIA GPU data retrieval code ===" local nvidia_cmd='nvidia-smi --query-gpu=index,name,temperature.gpu,utilization.gpu,utilization.memory,memory.used,memory.total,power.draw,power.limit,fan.speed --format=csv,noheader,nounits 2>/dev/null' # Insert the nvidia-smi command into Nodes.pm # This adds a new field to the API response sed -i "/my \$dinfo = df('\/', 1);/i\\ # Collect NVIDIA GPU data\\ \$res->{nvidiaGpuOutput} = \`$nvidia_cmd\`;\\ " "$NODES_PM_FILE" if [[ $? -ne 0 ]]; then err "Failed to insert NVIDIA GPU retrieval code into Nodes.pm" fi info "NVIDIA GPU retriever added to \"$NODES_PM_FILE\"." } # Insert GPU widget before the 'cpus' item in pvemanagerlib.js function insert_gpu_widget() { msgb "\n=== Inserting GPU widget into UI ===" local temp_js_file="/tmp/nvidia_gpu_widget.js" # Generate the GPU widget JavaScript code with proper indentation # Indentation matches the pvemanagerlib.js style (tab-based) cat > "$temp_js_file" << 'WIDGET_EOF' { xtype: 'box', colspan: 2, html: gettext('GPU(s)'), }, { itemId: 'nvidiaGpu', colspan: 2, printBar: false, title: gettext('NVIDIA GPU Status'), iconCls: 'fa fa-fw fa-television', textField: 'nvidiaGpuOutput', renderer: function(value) { if (!value || value.trim() === '') { return '
No NVIDIA GPUs detected or nvidia-smi not available
'; } // Temperature conversion settings WIDGET_EOF # Add temperature settings based on user selection if [[ "$TEMP_UNIT" == "F" ]]; then cat >> "$temp_js_file" << 'WIDGET_EOF' var toFahrenheit = true; var tempUnit = '°F'; var tempWarning = 158; // 70°C in F var tempCritical = 185; // 85°C in F WIDGET_EOF else cat >> "$temp_js_file" << 'WIDGET_EOF' var toFahrenheit = false; var tempUnit = '°C'; var tempWarning = 70; var tempCritical = 85; WIDGET_EOF fi # Continue with the rest of the widget code cat >> "$temp_js_file" << 'WIDGET_EOF' function convertTemp(celsius) { if (toFahrenheit) { return (celsius * 9 / 5) + 32; } return celsius; } function formatTemp(celsius) { var temp = convertTemp(celsius); var style = ''; var convertedWarning = toFahrenheit ? tempWarning : 70; var convertedCritical = toFahrenheit ? tempCritical : 85; if (temp >= convertedCritical) { style = 'color: #ff4444; font-weight: bold;'; } else if (temp >= convertedWarning) { style = 'color: #FFC300; font-weight: bold;'; } return '' + temp.toFixed(0) + tempUnit + ''; } function formatMemory(used, total) { var percent = (used / total * 100).toFixed(1); var style = ''; if (percent >= 90) { style = 'color: #ff4444; font-weight: bold;'; } else if (percent >= 75) { style = 'color: #FFC300;'; } return '' + used.toLocaleString() + ' of ' + total.toLocaleString() + ' MiB (' + percent + '%)'; } function formatPower(draw, limit) { // Handle NaN power draw (some GPUs don't report power) if (isNaN(draw)) { draw = 0; } if (isNaN(limit) || limit === 0) { return '' + draw.toFixed(0) + 'W'; } var percent = (draw / limit * 100); var style = ''; if (percent >= 90) { style = 'color: #ff4444; font-weight: bold;'; } else if (percent >= 75) { style = 'color: #FFC300;'; } return '' + draw.toFixed(0) + ' of ' + limit.toFixed(0) + 'W'; } function formatUtilization(util) { var style = ''; if (util >= 90) { style = 'color: #FFC300;'; } return '' + util + '%'; } function formatFan(fan) { if (fan === null || fan === undefined || isNaN(fan) || fan === '[Not Supported]' || fan === '') { return 'N/A'; } return fan + '%'; } var lines = value.trim().split('\n'); var result = []; for (var i = 0; i < lines.length; i++) { var line = lines[i].trim(); if (!line) continue; var parts = line.split(',').map(function(p) { return p.trim(); }); if (parts.length < 10) continue; var gpuIndex = parts[0]; var gpuName = parts[1]; var temp = parseFloat(parts[2]); var gpuUtil = parseInt(parts[3], 10); var memUtil = parseInt(parts[4], 10); var memUsed = parseFloat(parts[5]); var memTotal = parseFloat(parts[6]); var powerDraw = parseFloat(parts[7]); var powerLimit = parseFloat(parts[8]); var fanSpeed = parts[9]; // Parse fan speed (may be [Not Supported] or a number) var fanValue = parseFloat(fanSpeed); if (isNaN(fanValue)) { fanValue = null; } var gpuHtml = '
'; gpuHtml += '
GPU ' + gpuIndex + ': ' + gpuName + '
'; gpuHtml += '
'; gpuHtml += 'Temp: ' + formatTemp(temp); gpuHtml += '  |  GPU: ' + formatUtilization(gpuUtil); gpuHtml += '  |  Mem: ' + formatMemory(memUsed, memTotal); gpuHtml += '  |  Power: ' + formatPower(powerDraw, powerLimit); gpuHtml += '  |  Fan: ' + formatFan(fanValue); gpuHtml += '
'; result.push(gpuHtml); } if (result.length === 0) { return '
No GPU data available
'; } return '
' + result.join('') + '
'; } }, WIDGET_EOF if [[ $? -ne 0 ]]; then err "Failed to generate GPU widget code" fi # Insert the widget BEFORE the 'cpus' item # We use perl for reliable multi-line pattern matching and insertion # The BEGIN block reads the widget file before processing the main file perl -i -0777 -pe ' BEGIN { open(my $fh, "<", "/tmp/nvidia_gpu_widget.js") or die "Cannot open widget file: $!"; local $/; $::widget = <$fh>; close($fh); } # Find the cpus item within StatusView and insert widget before it s/(Ext\.define\('\''PVE\.node\.StatusView'\''.*?items:\s*\[.*?)({\s*itemId:\s*'\''cpus'\'')/$1$::widget$2/s; ' "$PVE_MANAGER_LIB_JS_FILE" local insert_status=$? # Verify the insertion succeeded if [[ $insert_status -ne 0 ]]; then rm -f "$temp_js_file" err "Failed to insert GPU widget into pvemanagerlib.js (perl error)" fi # Verify the widget was actually inserted by checking for our itemId if ! grep -q "itemId: 'nvidiaGpu'" "$PVE_MANAGER_LIB_JS_FILE"; then rm -f "$temp_js_file" err "Widget insertion verification failed - nvidiaGpu itemId not found in file" fi rm -f "$temp_js_file" info "GPU widget inserted into \"$PVE_MANAGER_LIB_JS_FILE\"." } # Main installation function function install_mod() { msgb "\n=== Preparing NVIDIA GPU mod installation ===" check_root_privileges check_mod_installation configure perform_backup insert_node_info insert_gpu_widget msgb "\n=== Finalizing installation ===" restart_proxy info "Installation completed successfully." msgb "\nIMPORTANT: Clear your browser cache (Ctrl+Shift+R) to see the changes." } # Uninstall the modification function uninstall_mod() { msgb "\n=== Uninstalling NVIDIA GPU Mod ===" check_root_privileges # Check if mod is installed if ! grep -q 'nvidiaGpuOutput' "$NODES_PM_FILE" 2>/dev/null; then err "NVIDIA GPU mod is not installed." fi set_backup_directory # Check for other mods that would be affected by backup restoration local other_mods_detected=false local detected_mods="" if grep -q 'sensorsOutput' "$NODES_PM_FILE" 2>/dev/null; then other_mods_detected=true detected_mods="pve-mod-gui-sensors" fi if [[ "$other_mods_detected" == true ]]; then warn "Other PVE mods detected: $detected_mods" warn "Restoring from backup will remove ALL mods installed after the nvidia-gpu backup was created." msgb "\nYou have two options:" echo " 1) Continue - Restore backup, then reinstall other mods afterward" echo " 2) Cancel - Manually remove nvidia-gpu code from files instead" echo "" local confirm confirm=$(ask "Continue with backup restoration? (y/N)") if [[ ! "$confirm" =~ ^[yY]$ ]]; then info "Uninstall cancelled." msgb "\nTo manually remove, edit these files:" echo " - $NODES_PM_FILE (remove nvidiaGpuOutput lines)" echo " - $PVE_MANAGER_LIB_JS_FILE (remove nvidiaGpu widget)" exit 0 fi fi info "Restoring modified files..." # Find the latest Nodes.pm backup local latest_nodes_pm latest_nodes_pm=$(find "$BACKUP_DIR" -name "nvidia-gpu.Nodes.pm.*" -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n 1 | awk '{print $2}') if [[ -n "$latest_nodes_pm" ]]; then msgb "Restoring Nodes.pm from backup: $latest_nodes_pm" cp "$latest_nodes_pm" "$NODES_PM_FILE" info "Restored Nodes.pm successfully." else warn "No Nodes.pm backup found. Attempting manual removal..." # Remove the nvidia-smi lines manually sed -i '/# Collect NVIDIA GPU data/,/nvidiaGpuOutput.*nvidia-smi/d' "$NODES_PM_FILE" fi # Find the latest pvemanagerlib.js backup local latest_pvemanagerlibjs latest_pvemanagerlibjs=$(find "$BACKUP_DIR" -name "nvidia-gpu.pvemanagerlib.js.*" -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n 1 | awk '{print $2}') if [[ -n "$latest_pvemanagerlibjs" ]]; then msgb "Restoring pvemanagerlib.js from backup: $latest_pvemanagerlibjs" cp "$latest_pvemanagerlibjs" "$PVE_MANAGER_LIB_JS_FILE" info "Restored pvemanagerlib.js successfully." else warn "No pvemanagerlib.js backup found. Manual restoration may be required." warn "You can reinstall pve-manager package to restore: apt install --reinstall pve-manager" fi restart_proxy info "Uninstallation completed." msgb "\nIMPORTANT: Clear your browser cache (Ctrl+Shift+R) to see the changes." } # Process command line arguments executed=0 while [[ $# -gt 0 ]]; do case "$1" in install) executed=$((executed + 1)) install_mod ;; uninstall) executed=$((executed + 1)) uninstall_mod ;; *) warn "Unknown option: $1" usage ;; esac shift done # If no arguments provided, show usage if [[ $executed -eq 0 ]]; then usage fi