#!/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