diff --git a/PveMod_SensorInfo.pm b/PveMod_SensorInfo.pm new file mode 100644 index 0000000..6ecd528 --- /dev/null +++ b/PveMod_SensorInfo.pm @@ -0,0 +1,2121 @@ +package PVE::API2::PVEMod_SensorInfo; + +use strict; +use warnings; +use JSON; +use POSIX qw(WNOHANG); +use Time::HiRes qw(time); +use Fcntl qw(:flock O_CREAT O_EXCL O_WRONLY); +use File::Path qw(remove_tree make_path); +use PVE::INotify; +use RRDs; + +# debug configuration - set to 0 to disable all _debug output +my $DEBUG_ENABLED = 1; +my $VERSION = '1.0'; + +# ============================================================================ +# Configuration +# ============================================================================ +my %config = ( + gpu => { + intel_enabled => 1, + amd_enabled => 0, + nvidia_enabled => 0, + }, + debug => { + nvidia_mode => 1, + nvidia_devices_file => '/tmp/nvidia-smi-devices.csv', + nvidia_output_file => '/tmp/nvidia-smi-output.csv', + sensors_mode => 0, + sensors_output_file => '/tmp/sensors-output.json', + }, + intervals => { + data_pull => 1, # seconds between data pulls + collector_timeout => 10, # stop collectors after N seconds of inactivity + }, + ups => { + enabled => 1, + device_name => 'ups@192.168.3.2', + }, + paths => { + working_dir => '/run/pveproxy/pve-mod', + }, +); + +# ============================================================================ +# Derived paths and runtime state +# ============================================================================ + +# Derived paths from configuration +my $pve_mod_working_dir = $config{paths}{working_dir}; +my $stats_dir = $pve_mod_working_dir; +my $state_file = "$pve_mod_working_dir/stats.json"; +my $sensors_state_file = "$pve_mod_working_dir/sensors.json"; +my $ups_state_file = "$pve_mod_working_dir/ups.json"; +my $pve_mod_worker_lock = "$pve_mod_working_dir/pve_mod_worker.lock"; +my $startup_lock = "$pve_mod_working_dir/startup.lock"; +my $RRD_SOCKET = '/var/run/rrdcached.sock'; +my $RRD_BASE = '/var/lib/rrdcached/db/pve-mod-gpu'; + +# Runtime state variables +my $process_type = 'main'; # 'main', 'worker', or 'collector' +my $last_snapshot = {}; +my $last_mtime = 0; +my $last_get_graphic_stats_time = 0; +my $pve_mod_worker_pid; +my $pve_mod_worker_running = 0; + +# Collector registry - only populated in worker process +my %collectors = (); # key: device/card name, value: PID + +# ============================================================================ +# Shared Utility Functions +# ============================================================================ + +# debug function showing line number and call chain +# Usage: _debug(__LINE__, "message") +sub _debug { + return unless $DEBUG_ENABLED; + + my ($line, $message) = @_; + + # Get function call chain + my @caller1 = caller(1); # who called _debug() + my @caller2 = caller(2); # parent of caller + + my $sub1 = $caller1[3] || 'main'; + my $sub2 = $caller2[3]; + + $sub1 =~ s/.*:://; # Remove package prefix + + if (defined $sub2) { + $sub2 =~ s/.*:://; + warn "[$sub2 -> $sub1:$line] $message\n"; + } else { + # No parent caller (called from top level) + warn "[$sub1:$line] $message\n"; + } +} + +sub read_sysfs { + my ($path) = @_; + + return "unknown" unless defined $path && -f $path; + + if (open my $fh, '<', $path) { + my $value = <$fh>; + close $fh; + + if (defined $value) { + chomp $value; + # Remove leading/trailing whitespace + $value =~ s/^\s+|\s+$//g; + return $value ne '' ? $value : "unknown"; + } + } + + return "unknown"; +} + +sub _is_process_alive { + my ($pid) = @_; + return -d "/proc/$pid"; +} + +sub _read_lock_pid { + my ($lock_path) = @_; + + return undef unless open(my $fh, '<', $lock_path); + + my $pid = <$fh>; + close($fh); + chomp $pid if defined $pid; + + return $pid; +} + +sub _acquire_exclusive_lock { + my ($lock_path, $purpose) = @_; + $purpose //= 'lock'; + + my $fh; + + # Try to create lock file exclusively + if (sysopen($fh, $lock_path, O_CREAT|O_EXCL|O_WRONLY, 0644)) { + _debug(__LINE__, "Acquired $purpose on first try"); + return $fh; + } + + # Lock file creation failed - check if it's stale or held by another process + _debug(__LINE__, ucfirst($purpose) . " exists, checking if stale"); + + my $lock_pid = _read_lock_pid($lock_path); + + if (!defined $lock_pid) { + _debug(__LINE__, "Could not read $purpose file: $!"); + return undef; + } + + if ($lock_pid eq '' || $lock_pid !~ /^\d+$/) { + _debug(__LINE__, "Invalid PID in $purpose: '" . ($lock_pid // 'undefined') . "', removing"); + unlink($lock_path); + } elsif (_is_process_alive($lock_pid)) { + _debug(__LINE__, ucfirst($purpose) . " holder PID $lock_pid is still alive"); + return undef; + } else { + _debug(__LINE__, ucfirst($purpose) . " holder PID $lock_pid is dead, removing stale lock"); + unlink($lock_path); + } + + # Try to acquire lock again after cleanup + unless (sysopen($fh, $lock_path, O_CREAT|O_EXCL|O_WRONLY, 0644)) { + _debug(__LINE__, "Failed to acquire $purpose on retry: $!"); + return undef; + } + + _debug(__LINE__, "Acquired $purpose after removing stale lock"); + return $fh; +} + +sub _ensure_pve_mod_directory_exists { + unless (-d $pve_mod_working_dir) { + _debug(__LINE__, "Creating directory $pve_mod_working_dir"); + unless (mkdir($pve_mod_working_dir, 0755)) { + _debug(__LINE__, "Failed to create $pve_mod_working_dir: $!. PVE Mod cannot start."); + die "Failed to create $pve_mod_working_dir: $!"; + } + _debug(__LINE__, "Directory $pve_mod_working_dir created"); + } else { + _debug(__LINE__, "Directory $pve_mod_working_dir already exists"); + } +} + +# Generic function to check if required executable exists +# Returns 1 if executable exists or debug mode is enabled for that type +# Returns 0 if executable doesn't exist and debug mode is not enabled +sub _check_executable { + my ($exec_path, $type, $debug_mode_enabled, $debug_file) = @_; + + # If debug mode is enabled for this type, check if debug file exists instead + if (defined $debug_mode_enabled && $debug_mode_enabled) { + if (defined $debug_file && -f $debug_file) { + _debug(__LINE__, "Debug mode enabled for $type, using debug file: $debug_file"); + return 1; + } elsif (defined $debug_file) { + _debug(__LINE__, "Debug mode enabled for $type but debug file missing: $debug_file"); + return 0; + } else { + _debug(__LINE__, "Debug mode enabled for $type, skipping executable check for $exec_path"); + return 1; + } + } + + # Normal mode: check if executable exists + unless (-x $exec_path) { + _debug(__LINE__, "$type executable not found or not executable: $exec_path"); + return 0; + } + + _debug(__LINE__, "$type executable found: $exec_path"); + return 1; +} + +sub _pve_mod_hello { + _debug(__LINE__, "PVE Mod is being started. Version $VERSION"); +} + +# Setup common signal handlers for collector processes +sub _setup_collector_signals { + my ($name, $shutdown_ref, $extra_cleanup) = @_; + + $SIG{TERM} = sub { + _debug(__LINE__, "Collector $name received SIGTERM"); + $$shutdown_ref = 1; + $extra_cleanup->() if $extra_cleanup; + }; + $SIG{INT} = sub { + _debug(__LINE__, "Collector $name received SIGINT"); + $$shutdown_ref = 1; + $extra_cleanup->() if $extra_cleanup; + }; +} + +# Safe JSON file write with error handling +sub _safe_write_json { + my ($filepath, $data, $pretty) = @_; + $pretty //= 1; + + eval { + open my $fh, '>', $filepath or die "Failed to open $filepath: $!"; + my $json = $pretty ? JSON->new->pretty->encode($data) : encode_json($data); + print $fh $json; + close $fh; + _debug(__LINE__, "Wrote JSON to $filepath"); + }; + if ($@) { + _debug(__LINE__, "Error writing to $filepath: $@"); + return 0; + } + return 1; +} + +# Safe JSON file read with error handling +sub _safe_read_json { + my ($filepath, $as_string) = @_; + + return unless -f $filepath; + + my $result; + eval { + open my $fh, '<', $filepath or die "Failed to open $filepath: $!"; + local $/; + my $json = <$fh>; + close $fh; + + if ($as_string) { + $result = $json; + } else { + $result = decode_json($json); + } + _debug(__LINE__, "Read JSON from $filepath"); + }; + if ($@) { + _debug(__LINE__, "Error reading $filepath: $@"); + return; + } + return $result; +} + +# Parse CSV line with trimming +sub _parse_csv_line { + my ($line, $expected_fields) = @_; + + return unless $line; + $line =~ s/^\s+|\s+$//g; + + my @values = map { s/^\s+|\s+$//gr } split(/,/, $line); + + return unless !$expected_fields || @values >= $expected_fields; + return @values; +} + +# Enhance sensors data with cached lookups (unified for drives and CPUs) +sub _enhance_sensors_with_cache { + my ($sensors_output, $cache_ref, $pattern, $lookup_sub, $field_names) = @_; + + $cache_ref //= {}; + + my $sensors_data = _safe_read_json(\$sensors_output); + return $sensors_output unless $sensors_data; + + # For string input, parse it + unless (ref $sensors_data eq 'HASH') { + eval { $sensors_data = decode_json($sensors_output); }; + return $sensors_output if $@; + } + + my @entries = grep { /$pattern/ } keys %{$sensors_data}; + _debug(__LINE__, "Found " . scalar(@entries) . " entries matching pattern"); + + foreach my $entry (@entries) { + my $metadata; + + # Check cache first + if (exists $cache_ref->{$entry}) { + $metadata = $cache_ref->{$entry}; + _debug(__LINE__, "Using cached info for $entry"); + } else { + # Lookup information + $metadata = $lookup_sub->($entry); + $cache_ref->{$entry} = $metadata if $metadata; + } + + # Add metadata to sensors data + if ($metadata && exists $sensors_data->{$entry}) { + foreach my $key (keys %$metadata) { + $sensors_data->{$entry}->{$key} = $metadata->{$key}; + } + _debug(__LINE__, "Enhanced $entry with metadata"); + } + } + + return JSON->new->pretty->canonical->encode($sensors_data); +} + +# ============================================================================ +# Intel GPU Support +# ============================================================================ + +# Parse Intel GPU line output format +sub _parse_intel_gpu_line { + my ($line) = @_; + + # Expected format (with aligned columns): + # Freq MHz IRQ RC6 Power W RCS BCS VCS VECS + # req act /s % gpu pkg % se wa % se wa % se wa % se wa + # 0 0 0 0 0.00 7.47 0.00 0 0 0.00 0 0 0.00 0 0 0.00 0 0 + + # Remove leading/trailing whitespace + $line =~ s/^\s+|\s+$//g; + + # Split by whitespace and filter empty values + my @values = grep { $_ ne '' } split(/\s+/, $line); + + # Expected: req(0) act(1) irq(2) rc6(3) gpu(4) pkg(5) rcs%(6) rcs_se(7) rcs_wa(8) + # bcs%(9) bcs_se(10) bcs_wa(11) vcs%(12) vcs_se(13) vcs_wa(14) vecs%(15) vecs_se(16) vecs_wa(17) + + return unless @values >= 18; + + my $stats = { + frequency => { + requested => $values[0] + 0.0, + actual => $values[1] + 0.0, + unit => "MHz" + }, + interrupts => { + count => $values[2] + 0.0, + unit => "irq/s" + }, + rc6 => { + value => $values[3] + 0.0, + unit => "%" + }, + power => { + GPU => $values[4] + 0.0, + Package => $values[5] + 0.0, + unit => "W" + }, + engines => { + "Render/3D" => { + busy => $values[6] + 0.0, + sema => $values[7] + 0.0, + wait => $values[8] + 0.0, + unit => "%" + }, + Blitter => { + busy => $values[9] + 0.0, + sema => $values[10] + 0.0, + wait => $values[11] + 0.0, + unit => "%" + }, + Video => { + busy => $values[12] + 0.0, + sema => $values[13] + 0.0, + wait => $values[14] + 0.0, + unit => "%" + }, + VideoEnhance => { + busy => $values[15] + 0.0, + sema => $values[16] + 0.0, + wait => $values[17] + 0.0, + unit => "%" + } + }, + clients => {} + }; + + return $stats; +} + +# Get list of Intel GPU devices +sub _get_intel_gpu_devices { + my @devices = (); + + # Check if intel_gpu_top is available (debug mode doesn't apply to device listing) + return @devices unless _check_executable('/usr/bin/intel_gpu_top', 'Intel GPU'); + + _debug(__LINE__, "Getting Intel GPU devices"); + if (open my $fh, '-|', 'intel_gpu_top -L') { + while (<$fh>) { + chomp; + # Parse: "card0 Intel Alderlake_n (Gen12) pci:vendor=8086,device=46D0,card=0" + # or: "card0 Intel Alderlake_n (Gen12) pci:0000:00:02.0" + if (/^(card\d+)\s+(.+?)\s+(pci:[^\s]+)/) { + my $card = $1; + my $name = $2; + my $path = $3; + push @devices, { + card => $card, + name => $name, + path => $path, + drm_path => "/dev/dri/$card" + }; + _debug(__LINE__, "Found Intel device: $card -> $name ($path)"); + } + } + close $fh; + } else { + _debug(__LINE__, "Failed to run intel_gpu_top -L: $!"); + } + + return @devices; +} + +sub _collector_for_intel_device { + my ($device) = @_; + $process_type = 'collector'; + $0 = "collector-gpu-intel-$device->{card}"; + + my $drm_dev = "drm:/dev/dri/$device->{card}"; + my $intel_gpu_top_pid = undef; + + # Each device writes to its own file + my $device_state_file = "$pve_mod_working_dir/stats-$device->{card}.json"; + + _debug(__LINE__, "Collector started for device: $drm_dev, writing to $device_state_file"); + + # Set up signal handlers for graceful shutdown + my $shutdown = 0; + _setup_collector_signals($device->{card}, \$shutdown, sub { + kill 'TERM', $intel_gpu_top_pid if defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0; + }); + + # Run intel_gpu_top once and keep reading from it + _debug(__LINE__, "About to open pipe to intel_gpu_top"); + my $intel_pull_interval = $config{intervals}{data_pull} * 1000; # in milliseconds + $intel_gpu_top_pid = open(my $fh, '-|', "intel_gpu_top -d $drm_dev -s $intel_pull_interval -l 2>&1"); + + unless (defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0) { + _debug(__LINE__, "Failed to run intel_gpu_top for $drm_dev: $!"); + exit 1; + } + + _debug(__LINE__, "Pipe opened successfully, PID=$intel_gpu_top_pid"); + + my $line_count = 0; + my $node_name = "node0"; # You may want to generate this based on device index + + while (my $line = <$fh>) { + last if $shutdown; + + $line_count++; + chomp $line; + + # Skip header lines + next if $line =~ /MHz|IRQ|RC6|Power|RCS|BCS|VCS|VECS|req\s+act|^\s*$/; + + # Check if this is a data line + if ($line =~ /^\s*[\d\s\.]+$/) { + my $stats = _parse_intel_gpu_line($line); + + if ($stats) { + # Build device-specific structure (just the node, not the full Graphics/Intel hierarchy) + my $device_data = { + $node_name => { + name => $device->{name}, + device_path => $device->{path}, + drm_path => $device->{drm_path}, + stats => $stats + } + }; + + # Write to device-specific file + _safe_write_json($device_state_file, $device_data); + _update_intel_gpu_rrd($device->{card}, $stats); + } + } + } + + close $fh; + _debug(__LINE__, "Collector for $device->{card} shutting down"); + exit 0; +} + +# ============================================================================ +# RRD Support for GPU metrics +# ============================================================================ + +sub _get_nodename { + return PVE::INotify::nodename(); +} + +sub _gpu_rrd_path { + my ($card) = @_; + return "$RRD_BASE/" . _get_nodename() . "/$card"; +} + +sub _ensure_intel_gpu_rrd { + my ($card) = @_; + my $path = _gpu_rrd_path($card); + return if -f $path; + + my $dir = "$RRD_BASE/" . _get_nodename(); + make_path($dir, { mode => 0755 }) unless -d $dir; + + RRDs::create( + $path, + '--step', '1', + 'DS:freq_req:GAUGE:120:0:U', + 'DS:freq_act:GAUGE:120:0:U', + 'DS:rc6:GAUGE:120:0:100', + 'DS:power_gpu:GAUGE:120:0:U', + 'DS:power_pkg:GAUGE:120:0:U', + 'DS:render_busy:GAUGE:120:0:100', + 'DS:blitter_busy:GAUGE:120:0:100', + 'DS:video_busy:GAUGE:120:0:100', + 'DS:videnh_busy:GAUGE:120:0:100', + 'RRA:AVERAGE:0.5:1:1440', + 'RRA:AVERAGE:0.5:60:1440', + 'RRA:AVERAGE:0.5:1800:1344', + 'RRA:AVERAGE:0.5:21600:1464', + 'RRA:AVERAGE:0.5:604800:520', + 'RRA:MAX:0.5:1:1440', + 'RRA:MAX:0.5:60:1440', + 'RRA:MAX:0.5:1800:1344', + 'RRA:MAX:0.5:21600:1464', + 'RRA:MAX:0.5:604800:520', + ); + my $err = RRDs::error(); + _debug(__LINE__, "Created Intel GPU RRD $path: " . ($err // 'OK')); +} + +sub _update_intel_gpu_rrd { + my ($card, $stats) = @_; + _ensure_intel_gpu_rrd($card); + my $path = _gpu_rrd_path($card); + + my $freq_req = $stats->{frequency}{requested} // 'U'; + my $freq_act = $stats->{frequency}{actual} // 'U'; + my $rc6 = $stats->{rc6}{value} // 'U'; + my $power_gpu = $stats->{power}{GPU} // 'U'; + my $power_pkg = $stats->{power}{Package} // 'U'; + my $render_busy = $stats->{engines}{'Render/3D'}{busy} // 'U'; + my $blitter = $stats->{engines}{Blitter}{busy} // 'U'; + my $video = $stats->{engines}{Video}{busy} // 'U'; + my $videnh = $stats->{engines}{VideoEnhance}{busy} // 'U'; + + my @daemon_args = (-S $RRD_SOCKET) ? ('--daemon', "unix:$RRD_SOCKET") : (); + RRDs::update( + $path, + @daemon_args, + "N:$freq_req:$freq_act:$rc6:$power_gpu:$power_pkg:$render_busy:$blitter:$video:$videnh", + ); + my $err = RRDs::error(); + _debug(__LINE__, "RRD update intel $card: $err") if $err; +} + +sub _ensure_nvidia_gpu_rrd { + my ($index) = @_; + my $card = "nvidia$index"; + my $path = _gpu_rrd_path($card); + return if -f $path; + + my $dir = "$RRD_BASE/" . _get_nodename(); + make_path($dir, { mode => 0755 }) unless -d $dir; + + RRDs::create( + $path, + '--step', '1', + 'DS:gpu_util:GAUGE:120:0:100', + 'DS:mem_util:GAUGE:120:0:100', + 'DS:mem_used:GAUGE:120:0:U', + 'DS:mem_total:GAUGE:120:0:U', + 'DS:power_draw:GAUGE:120:0:U', + 'DS:power_limit:GAUGE:120:0:U', + 'DS:temp_gpu:GAUGE:120:0:U', + 'DS:fan_speed:GAUGE:120:0:100', + 'RRA:AVERAGE:0.5:1:1440', + 'RRA:AVERAGE:0.5:60:1440', + 'RRA:AVERAGE:0.5:1800:1344', + 'RRA:AVERAGE:0.5:21600:1464', + 'RRA:AVERAGE:0.5:604800:520', + 'RRA:MAX:0.5:1:1440', + 'RRA:MAX:0.5:60:1440', + 'RRA:MAX:0.5:1800:1344', + 'RRA:MAX:0.5:21600:1464', + 'RRA:MAX:0.5:604800:520', + ); + my $err = RRDs::error(); + _debug(__LINE__, "Created NVIDIA GPU RRD $path: " . ($err // 'OK')); +} + +sub _update_nvidia_gpu_rrd { + my ($index, $stats) = @_; + _ensure_nvidia_gpu_rrd($index); + my $card = "nvidia$index"; + my $path = _gpu_rrd_path($card); + + my $gpu_util = $stats->{utilization}{gpu} // 'U'; + my $mem_util = $stats->{utilization}{memory} // 'U'; + my $mem_used = $stats->{memory}{used} // 'U'; + my $mem_total = $stats->{memory}{total} // 'U'; + my $power_draw = $stats->{power}{draw} // 'U'; + my $power_limit = $stats->{power}{limit} // 'U'; + my $temp_gpu = $stats->{temperature}{gpu} // 'U'; + my $fan_speed = $stats->{fan}{speed} // 'U'; + + my @daemon_args = (-S $RRD_SOCKET) ? ('--daemon', "unix:$RRD_SOCKET") : (); + RRDs::update( + $path, + @daemon_args, + "N:$gpu_util:$mem_util:$mem_used:$mem_total:$power_draw:$power_limit:$temp_gpu:$fan_speed", + ); + my $err = RRDs::error(); + _debug(__LINE__, "RRD update nvidia$index: $err") if $err; +} + +# ============================================================================ +# AMD GPU Support (Placeholder) +# ============================================================================ + +sub _get_amd_gpu_devices { + # TODO: Implement AMD GPU detection + # Use rocminfo or similar tools to detect AMD GPUs + _debug(__LINE__, "AMD GPU support not yet implemented"); + return (); +} + +sub _parse_amd_gpu_line { + my ($line) = @_; + # TODO: Implement AMD GPU line parsing + # Parse rocm-smi or similar output + _debug(__LINE__, "AMD GPU line parsing not yet implemented"); + return undef; +} + +sub _collector_for_amd_device { + my ($device) = @_; + # TODO: Implement AMD GPU collector + _debug(__LINE__, "AMD GPU collector not yet implemented"); + exit 0; +} + +# ============================================================================ +# NVIDIA GPU Support +# ============================================================================ + +sub get_nvidia_gpu_devices { + my @devices = (); + + # Expected format (CSV with header): + # index, name + # 0, NVIDIA GeForce RTX 3080 + # 1, NVIDIA RTX A4000 + + # Check if nvidia-smi is available (or debug mode with debug file) + unless (_check_executable('/usr/bin/nvidia-smi', 'NVIDIA', $config{debug}{nvidia_mode}, $config{debug}{nvidia_devices_file})) { + return @devices; + } + + if ($config{debug}{nvidia_mode} && -f $config{debug}{nvidia_devices_file}) { + _debug(__LINE__, "Debug mode: reading NVIDIA GPU devices from $config{debug}{nvidia_devices_file}"); + if (open my $fh, '<', $config{debug}{nvidia_devices_file}) { + my $line_num = 0; + while (<$fh>) { + chomp; + $line_num++; + + # Skip header line and empty lines + next if $line_num == 1 || /^\s*$/; + + # Parse CSV using shared helper + my @values = _parse_csv_line($_, 2); + if (@values) { + push @devices, { + index => $values[0], + name => $values[1], + }; + _debug(__LINE__, "Found NVIDIA GPU device (debug): $values[1] -> (index: $values[0])"); + } + } + close $fh; + } else { + _debug(__LINE__, "Failed to open debug file $config{debug}{nvidia_devices_file}: $!"); + } + } else { + # Use nvidia-smi to get device list + if (open my $fh, '-|', 'nvidia-smi --query-gpu=index,name --format=csv') { + my $line_num = 0; + while (<$fh>) { + chomp; + $line_num++; + + # Skip header line and empty lines + next if $line_num == 1 || /^\s*$/; + + # Parse CSV using shared helper + my @values = _parse_csv_line($_, 2); + if (@values) { + push @devices, { + index => $values[0], + name => $values[1], + }; + _debug(__LINE__, "Found NVIDIA GPU device: $values[1] -> (index: $values[0])"); + } + } + close $fh; + } + } + + return @devices; +} + +sub parse_nvidia_gpu_line { + my ($line) = @_; + + # Expected format (CSV) for multiple GPUs: + # index, name, temperature.gpu, utilization.gpu, utilization.memory, memory.used, memory.total, power.draw, power.limit, fan.speed + #0, NVIDIA GeForce RTX 3080, 62, 79, 44, 8260, 10240, 268.12, 320.00, 67 + + # Parse CSV using shared helper + my @values = _parse_csv_line($line, 10); + return unless @values; + + my $stats = { + index => $values[0] + 0, + name => $values[1], + temperature => { + gpu => $values[2] + 0.0, + unit => "°C" + }, + utilization => { + gpu => $values[3] + 0.0, + memory => $values[4] + 0.0, + unit => "%" + }, + memory => { + used => $values[5] + 0.0, + total => $values[6] + 0.0, + unit => "MiB" + }, + power => { + draw => $values[7] + 0.0, + limit => $values[8] + 0.0, + unit => "W" + }, + fan => { + speed => $values[9] + 0.0, + unit => "%" + } + }; + + return $stats; +} + +sub _get_and_write_nvidia_stats { + my ($devices) = @_; + my @all_stats; + + if ($config{debug}{nvidia_mode} && -f $config{debug}{nvidia_output_file}) { + # Debug mode: read all GPUs from single file + _debug(__LINE__, "Debug mode: reading NVIDIA GPU stats from $config{debug}{nvidia_output_file}"); + if (open my $fh, '<', $config{debug}{nvidia_output_file}) { + my $line_num = 0; + while (<$fh>) { + chomp; + $line_num++; + + # Skip header and empty lines + next if $line_num == 1 || /^\s*$/; + + # Parse the stats line + my $stats = parse_nvidia_gpu_line($_); + push @all_stats, $stats if $stats; + } + close $fh; + } else { + _debug(__LINE__, "Failed to open debug file $config{debug}{nvidia_output_file}: $!"); + } + } else { + # Production mode: check if nvidia-smi is available before querying + unless (_check_executable('/usr/bin/nvidia-smi', 'NVIDIA')) { + _debug(__LINE__, "nvidia-smi not available, cannot collect stats"); + return 0; + } + + # Query all GPUs at once + my $query = 'index,name,temperature.gpu,utilization.gpu,utilization.memory,memory.used,memory.total,power.draw,power.limit,fan.speed'; + my $cmd = "nvidia-smi --query-gpu=$query --format=csv,nounits"; + + if (open my $fh, '-|', $cmd) { + my $line_num = 0; + while (<$fh>) { + chomp; + $line_num++; + + # Skip header and empty lines + next if $line_num == 1 || /^\s*$/; + + # Parse the stats line + my $stats = parse_nvidia_gpu_line($_); + push @all_stats, $stats if $stats; + } + close $fh; + } + } + + # Write each GPU's stats to its own file + foreach my $stats (@all_stats) { + my $device_index = $stats->{index}; + + # Untaint device_index for file operations (validate it's a number) + unless ($device_index =~ /^(\d+)$/) { + _debug(__LINE__, "Invalid device index: $device_index, skipping"); + next; + } + $device_index = $1; # Now untainted + + my $node_name = "gpu$device_index"; + my $device_state_file = "$pve_mod_working_dir/stats-nvidia$device_index.json"; + + # Find device name from devices array + my $device_name = $stats->{name}; # Fallback to name from stats + foreach my $dev (@$devices) { + if ($dev->{index} == $device_index) { + $device_name = $dev->{name}; + last; + } + } + + # Build device-specific structure + my $device_data = { + $node_name => { + name => $device_name, + index => $device_index, + stats => $stats + } + }; + + # Write to device-specific file + _safe_write_json($device_state_file, $device_data); + _update_nvidia_gpu_rrd($device_index, $stats); + } + + unless (@all_stats) { + _debug(__LINE__, "No valid NVIDIA GPU stats collected"); + } + + return scalar(@all_stats); +} + +sub _collector_for_nvidia_devices { + my ($devices) = @_; + $process_type = 'collector'; + + $0 = "collector-gpu-nvidia-all"; + + _debug(__LINE__, "NVIDIA collector started for " . scalar(@$devices) . " GPU(s)"); + + # Set up signal handlers for graceful shutdown + my $shutdown = 0; + _setup_collector_signals('nvidia-all', \$shutdown); + + # Expected CSV format (with header): + # index, name, temperature.gpu, utilization.gpu, utilization.memory, memory.used, memory.total, power.draw, power.limit, fan.speed + # 0, NVIDIA GeForce RTX 3080, 62, 79, 44, 8260, 10240, 268.12, 320.00, 67 + # 1, NVIDIA RTX A4000, 58, 45, 32, 4120, 16384, 145.50, 200.00, 55 + + while (!$shutdown) { + # Collect and write NVIDIA GPU stats + _get_and_write_nvidia_stats($devices); + + sleep $config{intervals}{data_pull} unless $shutdown; + } + + _debug(__LINE__, "NVIDIA collector shutting down"); + exit 0; +} + +# ============================================================================ +# Temperature Sensors +# ============================================================================ + +sub _collector_for_temperature_sensors { + my ($device) = @_; + $process_type = 'collector'; + $0 = "collector-temperature-sensors"; + + _debug(__LINE__, "Temperature sensor collector started"); + + # Check if lm-sensors is available (or debug mode with debug file) + unless (_check_executable('/usr/bin/sensors', 'lm-sensors', $config{debug}{sensors_mode}, $config{debug}{sensors_output_file})) { + _debug(__LINE__, "sensors not available and not in debug mode, exiting"); + exit(1); + } + + # Cache for drive and CPU names + my %cache_ref; + + # Set up signal handlers for graceful shutdown + my $shutdown = 0; + _setup_collector_signals('temperature-sensors', \$shutdown); + + while (!$shutdown) { + my $sensorsData = _get_temperature_sensors(\%cache_ref); + + # Write to sensors state file (as string, not parsed JSON) + eval { + open my $ofh, '>', $sensors_state_file or die "Failed to open $sensors_state_file: $!"; + print $ofh $sensorsData; + close $ofh; + _debug(__LINE__, "Wrote temperature sensor data to $sensors_state_file"); + }; + if ($@) { + _debug(__LINE__, "Error writing temperature sensor data: $@"); + } + + sleep $config{intervals}{data_pull} unless $shutdown; + } + + _debug(__LINE__, "Temperature sensor collector shutting down"); + exit 0; +} + +sub _get_temperature_sensors { + my ($cache_ref) = @_; + + my $sensorsOutput; + + # Collect sensor data from lm-sensors + if ($config{debug}{sensors_mode} && -f $config{debug}{sensors_output_file}) { + # Debug mode: read from file + _debug(__LINE__, "Debug mode: reading sensors data from $config{debug}{sensors_output_file}"); + if (open my $fh, '<', $config{debug}{sensors_output_file}) { + local $/; + $sensorsOutput = <$fh>; + close $fh; + _debug(__LINE__, "Read sensors data from debug file, length: " . length($sensorsOutput) . " bytes"); + } else { + _debug(__LINE__, "Failed to open debug file $config{debug}{sensors_output_file}: $!"); + $sensorsOutput = '{}'; + } + } else { + # Production mode: call sensors command + $sensorsOutput = `sensors -j 2>/dev/null | python3 -m json.tool`; + _debug(__LINE__, "Raw sensors output collected from command"); + } + + _debug(__LINE__, "Raw sensors output collected"); + + # sanitize output + my $sensorsData = _sanitize_sensors($sensorsOutput); + + _debug(__LINE__, "Sanitized sensors output"); + + # translate drive names (pass cache reference) + $sensorsData = _get_drive_names($sensorsData, $cache_ref); + + _debug(__LINE__, "Translated drive names in sensors output"); + + # translate CPU names (pass cache reference) + $sensorsData = _get_cpu_name($sensorsData, $cache_ref); + + _debug(__LINE__, "Translated CPU names in sensors output"); + + # Good, now add a master node called lm sensors exhanced by PVE MOD + my $sensors_json; + eval { + $sensors_json = decode_json($sensorsData); + }; + if ($@) { + _debug(__LINE__, "Failed to parse final sensors JSON: $@"); + return $sensorsData; # Return original output on parse error + } + my $enhanced_data = { + "PVE MOD lm-sensors Enhanced" => $sensors_json + }; + $sensorsData = JSON->new->pretty->encode($enhanced_data); + + + + return $sensorsData; +} + +sub _sanitize_sensors { + my ($sensorsOutput) = @_; + + # Sanitize JSON output to handle common lm-sensors parsing issues + # Replace ERROR lines with placeholder values + $sensorsOutput =~ s/ERROR:.+\s(\w+):\s(.+)/\"$1\": 0.000,/g; + $sensorsOutput =~ s/ERROR:.+\s(\w+)!/\"$1\": 0.000,/g; + + # Remove trailing commas before closing braces + $sensorsOutput =~ s/,\s*(})/$1/g; + + # Replace NaN values with null for valid JSON + $sensorsOutput =~ s/\bNaN\b/null/g; + + # Fix duplicate SODIMM keys by appending temperature sensor number + # This prevents JSON key overwrites when multiple SODIMM sensors exist + # Example: "SODIMM":{"temp3_input":34.0} becomes "SODIMM3":{"temp3_input":34.0} + $sensorsOutput =~ s/\"SODIMM\":\{\"temp(\d+)_input\"/\"SODIMM$1\":\{\"temp$1_input\"/g; + + return $sensorsOutput; +} + +sub _get_drive_names { + my ($sensorsOutput, $cache_ref) = @_; + + # Use empty hash if no cache reference provided (shouldn't happen) + $cache_ref //= {}; + + my @drive_names; + + # Parse sensors output to extract drive entries + my $sensors_data; + eval { + $sensors_data = decode_json($sensorsOutput); + }; + if ($@) { + _debug(__LINE__, "Failed to parse sensors JSON: $@"); + return $sensorsOutput; # Return original output on parse error + } + + # Extract drive entries from sensors data + my @entries = grep { + /^drivetemp-scsi-/ || /^drivetemp-nvme-/ || /^nvme-pci-/ + } keys %{$sensors_data}; + + _debug(__LINE__, "Found " . scalar(@entries) . " drive entries in sensors output"); + + foreach my $entry (@entries) { + my ($dev_path, $model, $serial) = ("unknown", "unknown", "unknown"); + + # Check cache first + if (exists $cache_ref->{$entry}) { + my $cached = $cache_ref->{$entry}; + $dev_path = $cached->{device_path}; + $model = $cached->{model}; + $serial = $cached->{serial}; + _debug(__LINE__, "Using cached drive info for $entry"); + } else { + # Lookup drive information + + # ----- SCSI/SATA ----- + if ($entry =~ /^drivetemp-scsi-(\d+)-(\d+)/) { + my ($host, $id) = ($1, $2); + my $scsi_path = "/sys/class/scsi_disk/$host:$id:0:0/device/block"; + + if (opendir(my $sdh, $scsi_path)) { + my @devs = grep { /^sd/ } readdir($sdh); + closedir($sdh); + if (@devs) { + $dev_path = "/dev/$devs[0]"; + $model = read_sysfs("/sys/class/block/$devs[0]/device/model"); + $serial = read_sysfs("/sys/class/block/$devs[0]/device/serial"); + } + } + + # ----- Numeric NVMe ----- + } elsif ($entry =~ /^drivetemp-nvme-(\d+)/) { + my $nvme_index = $1; + $dev_path = "/dev/nvme${nvme_index}n1"; + if (-e $dev_path) { + $model = read_sysfs("/sys/class/block/nvme${nvme_index}n1/device/model"); + $serial = read_sysfs("/sys/class/block/nvme${nvme_index}n1/device/serial"); + } + + # ----- PCI-style NVMe ----- + } elsif ($entry =~ /^nvme-pci-(\w+)/) { + my $pci_addr = $1; + + # Convert short PCI address to pattern + # nvme-pci-0600 -> 0000:06:00 + # Format: domain:bus:device (function is usually .0) + my $pci_pattern; + if ($pci_addr =~ /^([0-9a-f]{2})([0-9a-f]{2})$/i) { + # Short format like "0600" -> "06:00" + my ($bus, $dev) = ($1, $2); + $pci_pattern = sprintf("%04x:%02x:%02x", 0, hex($bus), hex($dev)); + _debug(__LINE__, "Converted PCI address $pci_addr to pattern $pci_pattern"); + } else { + # Already in some other format, use as-is + $pci_pattern = $pci_addr; + } + + # Try multiple approaches to find the NVMe device + my $found = 0; + + # Approach 1: Check /sys/class/nvme/ + my $nvme_dir = "/sys/class/nvme"; + _debug(__LINE__, "Searching for NVMe devices in $nvme_dir matching PCI pattern $pci_pattern"); + if (opendir(my $ndh, $nvme_dir)) { + my @nvme_devs = grep { /^nvme\d+$/ && -d "$nvme_dir/$_" } readdir($ndh); + closedir($ndh); + + _debug(__LINE__, "Found NVMe devices: " . join(", ", @nvme_devs)); + + foreach my $nvme_dev (@nvme_devs) { + # Check if this nvme device matches our PCI address + my $device_link = readlink("$nvme_dir/$nvme_dev/device"); + if ($device_link && $device_link =~ /$pci_pattern/) { + _debug(__LINE__, "NVMe device $nvme_dev matches PCI pattern $pci_pattern"); + # Found matching device + $dev_path = "/dev/$nvme_dev" . "n1"; + $model = read_sysfs("$nvme_dir/$nvme_dev/model"); + $serial = read_sysfs("$nvme_dir/$nvme_dev/serial"); + $found = 1; + _debug(__LINE__, "Found NVMe device via /sys/class/nvme: $dev_path (matched $pci_pattern)"); + last; + } + _debug(__LINE__, "NVMe device $nvme_dev did not match PCI pattern $pci_pattern"); + } + } + + # Approach 2: Try direct block device lookup if not found + if (!$found && opendir(my $bdh, "/sys/class/block")) { + my @block_devs = grep { /^nvme\d+n\d+$/ } readdir($bdh); + closedir($bdh); + + foreach my $block_dev (@block_devs) { + my $device_link = readlink("/sys/class/block/$block_dev/device"); + if ($device_link && $device_link =~ /$pci_pattern/) { + $dev_path = "/dev/$block_dev"; + # For block devices, go up to the nvme controller for model/serial + my $nvme_ctrl = $block_dev; + $nvme_ctrl =~ s/n\d+$//; # nvme0n1 -> nvme0 + $model = read_sysfs("/sys/class/nvme/$nvme_ctrl/model"); + $serial = read_sysfs("/sys/class/nvme/$nvme_ctrl/serial"); + $found = 1; + _debug(__LINE__, "Found NVMe device via /sys/class/block: $dev_path (matched $pci_pattern)"); + last; + } + } + } + + unless ($found) { + _debug(__LINE__, "Could not find device for nvme-pci-$pci_addr (pattern: $pci_pattern)"); + } + } else { + next; # unknown device type + } + + # Cache the lookup result + $cache_ref->{$entry} = { + device_path => $dev_path, + model => $model, + serial => $serial + }; + + _debug(__LINE__, "Drive: $entry -> $dev_path (Model: $model, Serial: $serial)"); + } + + # Add to result array + push @drive_names, [$entry, $dev_path, $model, $serial]; + } + + # Now enhance the sensors_data structure directly (not as string manipulation) + foreach my $drive_entry (@drive_names) { + my ($original_name, $dev_path, $model, $serial) = @$drive_entry; + + # Add metadata directly to the data structure + if (exists $sensors_data->{$original_name}) { + $sensors_data->{$original_name}->{device_path} = $dev_path; + $sensors_data->{$original_name}->{model} = $model; + $sensors_data->{$original_name}->{serial} = $serial; + _debug(__LINE__, "Enhanced $original_name with drive info"); + } + } + + # Re-encode as pretty JSON + my $enhanced_json = JSON->new->pretty->canonical->encode($sensors_data); + + return $enhanced_json; +} + +sub _get_cpu_name { + my ($sensorsOutput, $cache_ref) = @_; + + # Use empty hash if no cache reference provided + $cache_ref //= {}; + + # Parse sensors output to extract CPU entries + my $sensors_data; + eval { + $sensors_data = decode_json($sensorsOutput); + }; + if ($@) { + _debug(__LINE__, "Failed to parse sensors JSON: $@"); + return $sensorsOutput; # Return original output on parse error + } + + # Extract CPU entries from sensors data + my @entries = grep { /^coretemp-isa-/ || /^k10temp-pci-/ } keys %{$sensors_data}; + + _debug(__LINE__, "Found " . scalar(@entries) . " CPU entries in sensors output"); + + foreach my $entry (@entries) { + my ($cpu_model, $pkg) = ("unknown", "unknown"); + + # Check cache first + if (exists $cache_ref->{$entry}) { + my $cached = $cache_ref->{$entry}; + $cpu_model = $cached->{model}; + $pkg = $cached->{package}; + _debug(__LINE__, "Using cached CPU info for $entry"); + } else { + # Lookup CPU information + + # ----- Intel coretemp ----- + if ($entry =~ /^coretemp-isa-(\d+)/) { + my $isa_id = $1; + + # Find matching hwmon device + for my $hwmon (glob "/sys/class/hwmon/hwmon*") { + my $name = read_sysfs("$hwmon/name"); + next unless $name eq 'coretemp'; + + my $dev = readlink("$hwmon/device"); + next unless $dev; + + # coretemp.0 → package 0 + if ($dev =~ /\.([0-9]+)$/) { + $pkg = $1; + $cpu_model = _cpu_model_by_package($pkg); + _debug(__LINE__, "Found Intel CPU: $entry -> Package $pkg, Model: $cpu_model"); + last; + } + } + } + + # ----- AMD k10temp ----- + elsif ($entry =~ /^k10temp-pci-(\w+)/) { + my $pci_addr = $1; + + # Convert short PCI address to pattern + # k10temp-pci-00c3 -> 0000:00:18.3 + my $pci_pattern; + if ($pci_addr =~ /^([0-9a-f]{2})([0-9a-f]{2})$/i) { + # Short format like "00c3" -> "00:18" (bus:device) + my ($bus, $dev_func) = ($1, $2); + $pci_pattern = sprintf("%04x:%02x:%02x", 0, hex($bus), hex($dev_func)); + _debug(__LINE__, "Converted PCI address $pci_addr to pattern $pci_pattern"); + } + + # Find matching hwmon device + for my $hwmon (glob "/sys/class/hwmon/hwmon*") { + my $name = read_sysfs("$hwmon/name"); + next unless $name eq 'k10temp'; + + my $dev = readlink("$hwmon/device"); + next unless $dev; + + if ($dev =~ /$pci_pattern/ || $dev =~ /$pci_addr/) { + # For AMD, package/node info might be in different location + # Try to determine from PCI device or use 0 as default + $pkg = 0; + + # Attempt to find package from CPU topology + if (opendir(my $dh, "/sys/devices/system/cpu")) { + my @cpus = grep { /^cpu\d+$/ } readdir($dh); + closedir($dh); + + foreach my $cpu (@cpus) { + my $cpu_pkg = read_sysfs("/sys/devices/system/cpu/$cpu/topology/physical_package_id"); + if ($cpu_pkg ne "unknown" && $cpu_pkg =~ /^\d+$/) { + $pkg = $cpu_pkg; + last; + } + } + } + + $cpu_model = _cpu_model_by_package($pkg); + _debug(__LINE__, "Found AMD CPU: $entry -> Package $pkg, Model: $cpu_model"); + last; + } + } + } + + # Cache the lookup result + $cache_ref->{$entry} = { + model => $cpu_model, + package => $pkg + }; + + _debug(__LINE__, "CPU: $entry -> Package $pkg (Model: $cpu_model)"); + } + + # Add metadata directly to the data structure + if (exists $sensors_data->{$entry}) { + $sensors_data->{$entry}->{cpu_model} = $cpu_model; + $sensors_data->{$entry}->{cpu_package} = $pkg; + _debug(__LINE__, "Enhanced $entry with CPU info"); + } + } + + # Re-encode as pretty JSON + my $enhanced_json = JSON->new->pretty->canonical->encode($sensors_data); + + return $enhanced_json; +} + +# Helper function to get CPU model by package ID +sub _cpu_model_by_package { + my ($pkg) = @_; + + # Try to read from /proc/cpuinfo + if (open my $fh, '<', '/proc/cpuinfo') { + my $current_pkg = -1; + my $model_name = "unknown"; + + while (my $line = <$fh>) { + chomp $line; + + # Extract physical id + if ($line =~ /^physical id\s+:\s+(\d+)/) { + $current_pkg = $1; + } + + # Extract model name + if ($line =~ /^model name\s+:\s+(.+)$/) { + $model_name = $1; + $model_name =~ s/^\s+|\s+$//g; # Trim whitespace + + # If this is the package we're looking for, return it + if ($current_pkg == $pkg) { + close($fh); + return $model_name; + } + } + } + close($fh); + + # If we didn't find the specific package, return the last model found + # (single socket systems won't have physical id) + return $model_name if $model_name ne "unknown"; + } + + return "unknown"; +} + +# ============================================================================ +# UPS Support +# ============================================================================ + +sub _collector_for_ups { + my ($device) = @_; + $process_type = 'collector'; + $0 = "collector-ups-$device->{ups_name}"; + _debug(__LINE__, "UPS collector started"); + + # Set up signal handlers for graceful shutdown + my $shutdown = 0; + _setup_collector_signals("ups-$device->{ups_name}", \$shutdown); + + while (!$shutdown) { + my $upsData = _get_ups_status($device->{ups_name}); + + # Write to ups state file (as string, not parsed JSON) + eval { + open my $ofh, '>', $ups_state_file or die "Failed to open $ups_state_file: $!"; + print $ofh $upsData; + close $ofh; + _debug(__LINE__, "Wrote ups data to $ups_state_file"); + }; + if ($@) { + _debug(__LINE__, "Error writing ups data: $@"); + } + + sleep $config{intervals}{data_pull} unless $shutdown; + } + _debug(__LINE__, "UPS collector shutting down"); + exit 0; +} + +sub _get_ups_status { + my ($ups_name) = @_; + + # upsc upsname[@hostname[:port]] + _debug(__LINE__, "Collecting UPS status for $ups_name"); + + # Execute command and capture output + my $output = `/usr/bin/upsc $ups_name 2>/dev/null`; + + unless (defined $output) { + _debug(__LINE__, "Failed to execute upsc"); + return encode_json({ error => "Failed to execute upsc" }); + } + + # Check if we got any output + unless (defined $output && length($output) > 0) { + _debug(__LINE__, "No output from upsc for $ups_name"); + return encode_json({ error => "No data from UPS $ups_name" }); + } + + # Convert upsc output to nested hash structure + my $ups_data = _parse_upsc_output($output); + + # Check if we got any parsed data + unless (keys %$ups_data) { + _debug(__LINE__, "No data received from upsc for $ups_name"); + return encode_json({ error => "No data from UPS $ups_name" }); + } + + # Wrap in UPS name structure + my $result = { + $ups_name => $ups_data + }; + + # Return as pretty JSON + return JSON->new->pretty->canonical->encode($result); +} + +sub _parse_upsc_output { + my ($output) = @_; + + my $ups_data = {}; + + _debug(__LINE__, "Parsing upsc output"); + + eval { + foreach my $line (split /\n/, $output) { + # Skip empty lines and SSL init message + next if $line =~ /^\s*$/; + next if $line =~ /^Init SSL/; + + # Parse key-value pairs (format: "key: value") + if ($line =~ /^([^:]+):\s*(.*)$/) { + my ($key, $value) = ($1, $2); + + # Trim whitespace + $key =~ s/^\s+|\s+$//g; + $value =~ s/^\s+|\s+$//g; + + # Store as flat key-value pairs (no nesting) + # Convert numeric values to numbers, keep strings as strings + if ($value =~ /^-?\d+\.?\d*$/) { + $ups_data->{$key} = $value + 0; + } else { + $ups_data->{$key} = $value; + } + } + } + }; + if ($@) { + _debug(__LINE__, "Error parsing upsc output: $@"); + } + + _debug(__LINE__, "Completed parsing upsc output"); + + return $ups_data; +} + +# ============================================================================ +# API calls +# ============================================================================ + +sub get_graphic_info { + # todo name the process without overruling other processes + _debug(__LINE__, "get_graphic_stats called"); + + # Start PVE Mod + _pve_mod_starter(); + + # Find all device-specific stat files + my $dh; + unless (opendir($dh, $stats_dir)) { + _debug(__LINE__, "Failed to open stats directory: $stats_dir: $!"); + return $last_snapshot; + } + + my @stat_files = grep { /^stats-(card\d+|nvidia\d+)\.json$/ } readdir($dh); + closedir($dh); + + unless (@stat_files) { + _debug(__LINE__, "No device stat files found in $stats_dir"); + return $last_snapshot; + } + + _debug(__LINE__, "Found " . scalar(@stat_files) . " device stat file(s): " . join(', ', @stat_files)); + + # Check if any files have been modified + my $newest_mtime = 0; + my $files_changed = 0; + + foreach my $file (@stat_files) { + my $filepath = "$stats_dir/$file"; + my @stat = stat($filepath); + if (@stat && $stat[9] > $newest_mtime) { + $newest_mtime = $stat[9]; + } + } + + if ($newest_mtime == $last_mtime) { + _debug(__LINE__, "No device files modified, returning cached snapshot"); + return $last_snapshot; + } + + _debug(__LINE__, "Device files modified ($last_mtime -> $newest_mtime), reading and merging files"); + + # Merge all device files + my $merged = { + Graphics => { + Intel => {}, + NVIDIA => {} + } + }; + + foreach my $file (@stat_files) { + my $filepath = "$stats_dir/$file"; + + _debug(__LINE__, "Reading device file: $filepath"); + + eval { + my $fh; + unless (open($fh, '<', $filepath)) { + _debug(__LINE__, "Failed to open $filepath: $!"); + return; + } + + local $/; + my $json = <$fh>; + close($fh); + + _debug(__LINE__, "Read $file, JSON length: " . length($json) . " bytes"); + + my $device_data = decode_json($json); + + # Determine device type from filename and merge accordingly + my $device_type = ($file =~ /^stats-card/) ? 'Intel' : 'NVIDIA'; + + # Merge this device's data into the main structure + foreach my $node_name (keys %$device_data) { + $merged->{Graphics}->{$device_type}->{$node_name} = $device_data->{$node_name}; + _debug(__LINE__, "Merged $device_type node '$node_name' from $file"); + } + }; + if ($@) { + _debug(__LINE__, "Failed to read/parse $filepath: $@"); + } + } + + # Update cache + $last_snapshot = $merged; + $last_mtime = $newest_mtime; + $last_get_graphic_stats_time = time(); + + my $intel_count = scalar(keys %{$merged->{Graphics}->{Intel}}); + my $nvidia_count = scalar(keys %{$merged->{Graphics}->{NVIDIA}}); + _debug(__LINE__, "Successfully merged $intel_count Intel + $nvidia_count NVIDIA device node(s)"); + + # Notify pve_mod_worker of activity + _notify_pve_mod_worker(); + + return $last_snapshot; +} + +sub get_sensors_info { + _debug(__LINE__, "get_sensors_stats called"); + + # Start PVE Mod + _pve_mod_starter(); + + unless (-f $sensors_state_file) { + _debug(__LINE__, "Sensors state file does not exist: $sensors_state_file"); + return {}; + } + + my $sensors_data; + eval { + open my $fh, '<', $sensors_state_file or die "Failed to open $sensors_state_file: $!"; + local $/; + my $json = <$fh>; + close($fh); + $sensors_data = $json; + _debug(__LINE__, "Read sensors data, JSON length: " . length($json) . " bytes"); + _debug(__LINE__, "Read sensors data from $sensors_state_file"); + }; + if ($@) { + _debug(__LINE__, "Failed to read/parse sensors data: $@"); + return {}; + } + + + # Notify pve_mod_worker of activity + _notify_pve_mod_worker(); + + return $sensors_data; +} + +sub get_ups_info { + _debug(__LINE__, "get_ups_stats called"); + + # Start PVE Mod + _pve_mod_starter(); + + unless (-f $ups_state_file) { + _debug(__LINE__, "UPS state file does not exist: $ups_state_file"); + return {}; + } + + my $ups_data; + eval { + open my $fh, '<', $ups_state_file or die "Failed to open $ups_state_file: $!"; + local $/; + my $json = <$fh>; + close($fh); + $ups_data = $json; + _debug(__LINE__, "Read UPS data, JSON length: " . length($json) . " bytes"); + _debug(__LINE__, "Read UPS data from $ups_state_file"); + }; + if ($@) { + _debug(__LINE__, "Failed to read/parse UPS data: $@"); + return {}; + } + + # Notify pve_mod_worker of activity + _notify_pve_mod_worker(); + + return $ups_data; +} + +sub get_pve_mod_version { + return $VERSION; +} + +# ============================================================================ +# Main Collector +# ============================================================================ + +sub _start_collector { + my ($collector_name, $collector_type, $collector_sub, $device) = @_; + + _debug(__LINE__, "Starting $collector_type collector: $collector_name"); + + # Check if already running (in worker's hash) + if (exists $collectors{$collector_name}) { + my $pid = $collectors{$collector_name}; + if (kill(0, $pid)) { + _debug(__LINE__, "$collector_type collector '$collector_name' already running with PID $pid"); + return $pid; + } else { + _debug(__LINE__, "Collector '$collector_name' PID $pid is stale, removing from registry"); + delete $collectors{$collector_name}; + } + } + + # Start the collector + my $pid = _start_child_collector($collector_name, $collector_sub, $device); + + unless ($pid) { + _debug(__LINE__, "Failed to start $collector_type collector '$collector_name'"); + return undef; + } + + # Register in worker's hash + $collectors{$collector_name} = $pid; + _debug(__LINE__, "Registered $collector_type collector '$collector_name' with PID $pid"); + + # Verify it's alive + sleep 0.1; + if (kill(0, $pid)) { + _debug(__LINE__, "Verified $collector_type collector '$collector_name' (PID $pid) is alive"); + return $pid; + } else { + _debug(__LINE__, "WARNING - $collector_type collector '$collector_name' (PID $pid) died immediately!"); + delete $collectors{$collector_name}; + return undef; + } +} + +sub _start_graphics_collectors { + + if (!$config{gpu}{intel_enabled} && !$config{gpu}{amd_enabled} && !$config{gpu}{nvidia_enabled}) { + _debug(__LINE__, "No GPU types enabled, skipping collector startup"); + return; + } + else { + _debug(__LINE__, "Starting graphics collectors"); + } + + # Generalized device collector management for future AMD/NVIDIA support + my @all_devices; + my @all_types; + my @all_collector_subs; + + # Intel + if ($config{gpu}{intel_enabled}) { + _debug(__LINE__, "Intel GPU support enabled"); + _debug(__LINE__, "Checking for intel_gpu_top"); + + return unless _check_executable('/usr/bin/intel_gpu_top', 'Intel'); + + my @intel_devices = _get_intel_gpu_devices(); + unless (@intel_devices) { + _debug(__LINE__, "No Intel GPU devices found"); + } else { + _debug(__LINE__, "Found " . scalar(@intel_devices) . " Intel GPU device(s)"); + foreach my $device (@intel_devices) { + push @all_devices, $device; + push @all_types, 'intel'; + push @all_collector_subs, \&_collector_for_intel_device; + } + } + } + + # AMD (future) + if ($config{gpu}{amd_enabled}) { + _debug(__LINE__, "AMD GPU support enabled"); + + return unless _check_executable('/usr/bin/rocm-smi', 'AMD'); + + my @amd_devices = _get_amd_gpu_devices(); + _debug(__LINE__, "Got " . scalar(@amd_devices) . " AMD devices"); + foreach my $device (@amd_devices) { + push @all_devices, $device; + push @all_types, 'amd'; + push @all_collector_subs, \&_collector_for_amd_device; + } + } + + _debug(__LINE__, "Finished detecting devices. Total collectors to manage: " . scalar(@all_devices)); + + # Start each graphics collector using unified function (Intel/AMD only - NVIDIA handled separately) + my $started_count = 0; + + # NVIDIA - single collector for all devices + if ($config{gpu}{nvidia_enabled}) { + _debug(__LINE__, "NVIDIA GPU support enabled"); + + my @nvidia_devices = get_nvidia_gpu_devices(); + _debug(__LINE__, "Got " . scalar(@nvidia_devices) . " NVIDIA devices"); + + if (@nvidia_devices) { + # Start single collector for all NVIDIA GPUs + my $pid = _start_collector('nvidia-all', 'nvidia', \&_collector_for_nvidia_devices, \@nvidia_devices); + $started_count++ if $pid; + } + } + for (my $i = 0; $i < @all_devices; $i++) { + my $device = $all_devices[$i]; + my $type = $all_types[$i]; + my $collector_sub = $all_collector_subs[$i]; + my $device_name = $device->{card} // $device->{name} // "device$i"; + + my $pid = _start_collector($device_name, $type, $collector_sub, $device); + $started_count++ if $pid; + } + + _debug(__LINE__, "Started/verified $started_count graphics collector(s) (Intel/AMD)"); +} + +sub _start_sensors_collector { + _debug(__LINE__, "Starting temperature sensor collector"); + + # Check if sensors is available (or debug mode with debug file) + unless (_check_executable('/usr/bin/sensors', 'lm-sensors', $config{debug}{sensors_mode}, $config{debug}{sensors_output_file})) { + _debug(__LINE__, "sensors not available and not in debug mode, skipping"); + return; + } + + # Use unified collector startup + _start_collector('sensors', 'sensors', \&_collector_for_temperature_sensors, { name => 'sensors' }); +} + +sub _start_ups_collector { + + if (!$config{ups}{enabled}) { + _debug(__LINE__, "UPS support not enabled, skipping collector startup"); + return; + } + + _debug(__LINE__, "Starting UPS collector"); + + # Check if upsc is available + unless (_check_executable('/usr/bin/upsc', 'UPS')) { + _debug(__LINE__, "upsc not available, skipping UPS collector startup"); + return; + } + + # Check if UPS is configured + unless ($config{ups}{device_name}) { + _debug(__LINE__, "No UPS configured, skipping collector startup"); + return; + } + + # Use unified collector startup + _start_collector('ups', 'ups', \&_collector_for_ups, { ups_name => $config{ups}{device_name} }); +} + +# ============================================================================ +# PVE Mod Worker +# ============================================================================ + +sub _start_child_collector { + my ($collector_name, $collector_sub, $device) = @_; + + _debug(__LINE__, "Starting child collector: $collector_name"); + + my $pid = fork(); + unless (defined $pid) { + _debug(__LINE__, "fork failed for $collector_name: $!"); + return undef; + } + + if ($pid == 0) { + # Child process + $process_type = 'collector'; + _debug(__LINE__, "In child process for $collector_name"); + $0 = "collector-$collector_name"; + $collector_sub->($device); + exit(0); + } + + # Parent process (worker only) + _debug(__LINE__, "Forked child PID $pid for $collector_name"); + return $pid; +} + +sub _pve_mod_starter { + # Check if pve_mod_worker is already running - if so, entire system is already up + _debug(__LINE__, "Checking if pve_mod_worker is already running"); + if (_is_pve_mod_worker_running()) { + _debug(__LINE__, "pve_mod_worker process already running, system is already started"); + return "pve_mod_worker process already running, system is already started"; + } + _debug(__LINE__, "PVE mod worker is not running. PVE Mod will be started."); + + _pve_mod_hello(); + + # Ensure directory exists + _ensure_pve_mod_directory_exists(); + + # Try to get the lock + _debug(__LINE__, "Trying to acquire startup lock: $startup_lock"); + my $startup_fh = _acquire_exclusive_lock($startup_lock, 'startup lock'); + return unless $startup_fh; + + # SECOND CHECK (after lock) - verify nothing changed while waiting + if (_is_pve_mod_worker_running()) { + _debug(__LINE__, "Worker started by another process while we waited for lock"); + close($startup_fh); + unlink($startup_lock); + return "already running"; + } + + # Now we KNOW we're the only one starting things + print $startup_fh "$$\n"; + $startup_fh->flush(); + _debug(__LINE__, "Wrote PID, $$, to startup lock"); + + # Start pve mod worker (which will start all collectors) + _pve_mod_worker(); + + # Remove startup lock LAST + unlink($startup_lock); + _debug(__LINE__, "Released startup lock"); + + _debug(__LINE__, "pve_mod_worker started successfully, returning"); +} + +sub _pve_mod_worker { + _debug(__LINE__, "_pve_mod_worker called"); + + # Check if worker is already running + my $pve_mod_worker_fh = _acquire_exclusive_lock($pve_mod_worker_lock, 'pve_mod_worker lock'); + return unless $pve_mod_worker_fh; + print $pve_mod_worker_fh "$$\n"; + close($pve_mod_worker_fh); + + _debug(__LINE__, "Forking new pve_mod_worker process"); + my $pve_mod_worker_pid = fork(); + + unless (defined $pve_mod_worker_pid) { + _debug(__LINE__, "Failed to fork pve_mod_worker process: $!"); + return; + } + + if ($pve_mod_worker_pid == 0) { + # Child process - run the pve_mod_worker + $0 = "pve_mod_worker_controller"; + _debug(__LINE__, "Child process forked, calling _pve_mod_keep_alive"); + _pve_mod_keep_alive(); + exit(0); # Should never reach here + } else { + # Parent process - write PID to lock file + _debug(__LINE__, "Forked pve_mod_worker process with PID $pve_mod_worker_pid"); + + if (open my $fh, '>', $pve_mod_worker_lock) { + print $fh "$pve_mod_worker_pid\n"; + close $fh; + _debug(__LINE__, "Wrote pve_mod_worker PID to lock file: $pve_mod_worker_lock"); + } else { + _debug(__LINE__, "Failed to write pve_mod_worker lock file: $!"); + kill('TERM', $pve_mod_worker_pid); + } + } + _debug(__LINE__, "pve_mod_worker process started successfully"); +} + +sub _notify_pve_mod_worker { + _debug(__LINE__, "_notify_pve_mod_worker called"); + unless (-f $pve_mod_worker_lock) { + _debug(__LINE__, "pve_mod_worker lock file does not exist"); + return; + } + + _debug(__LINE__, "pve_mod_worker lock file exists, reading PID"); + if (open my $fh, '<', $pve_mod_worker_lock) { + my $pid = <$fh>; + close $fh; + chomp $pid if defined $pid; + if (defined $pid && $pid =~ /^(\d+)$/) { + # Untaint by capturing in regex - $1 is now untainted + my $clean_pid = $1; + + if (_is_process_alive($clean_pid)) { + _debug(__LINE__, "Sending USR1 signal to pve_mod_worker PID $clean_pid"); + my $result = kill('USR1', $clean_pid); + _debug(__LINE__, "Signal result: $result"); + } else { + _debug(__LINE__, "pve_mod_worker process $clean_pid is not alive, removing stale lock"); + unlink($pve_mod_worker_lock); + } + } else { + # Stale lock, remove it + _debug(__LINE__, "pve_mod_worker lock is stale (PID: " . ($pid // 'undefined') . "), removing"); + unlink($pve_mod_worker_lock); + } + } else { + _debug(__LINE__, "Failed to open pve_mod_worker lock file: $!"); + } +} + +sub _pve_mod_keep_alive { + $process_type = 'worker'; + _debug(__LINE__, "pve_mod_worker process started with PID $$"); + + my $last_activity = time(); + + # Set up signal handlers + $SIG{USR1} = sub { + $last_activity = time(); + _debug(__LINE__, "Activity ping received"); + }; + + # SIGCHLD handler to prevent zombies and clean up collector registry + $SIG{CHLD} = sub { + while ((my $pid = waitpid(-1, WNOHANG)) > 0) { + my $exit_status = $? >> 8; + _debug(__LINE__, "Child process $pid exited with status $exit_status"); + + # Find and remove from collector registry + foreach my $name (keys %collectors) { + if ($collectors{$name} == $pid) { + _debug(__LINE__, "Collector '$name' (PID $pid) exited, removing from registry"); + delete $collectors{$name}; + last; + } + } + } + }; + + $SIG{TERM} = sub { + _debug(__LINE__, "pve_mod_worker received SIGTERM, shutting down"); + _stop_child_collectors(); + unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock; + exit(0); + }; + $SIG{INT} = sub { + _debug(__LINE__, "pve_mod_worker received SIGINT, shutting down"); + _stop_child_collectors(); + unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock; + exit(0); + }; + + # Worker now starts all collectors (moved from _pve_mod_starter) + _debug(__LINE__, "Worker starting all collectors"); + _start_sensors_collector(); + _start_graphics_collectors(); + _start_ups_collector(); + _debug(__LINE__, "All collectors started by worker"); + + _debug(__LINE__, "Entering pve_mod_worker loop, timeout=$config{intervals}{collector_timeout}s"); + + while (1) { + _debug(__LINE__, "pve_mod_worker loop start: checking activity"); + + my $idle_time = time() - $last_activity; + + _debug(__LINE__, "pve_mod_worker loop: idle_time=${idle_time}s, timeout=$config{intervals}{collector_timeout}s"); + + if ($idle_time > $config{intervals}{collector_timeout}) { + _debug(__LINE__, "Timeout reached, stopping collectors"); + _stop_child_collectors(); + _debug(__LINE__, "Collectors stopped, exiting pve_mod_worker"); + unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock; + exit(0); + } + sleep(1); + } + + # Should never reach here + _debug(__LINE__, "pve_mod_worker loop exited unexpectedly!"); +} + +sub _is_pve_mod_worker_running { + return -f $pve_mod_worker_lock; +} + +sub _stop_child_collectors { + _debug(__LINE__, "Stopping all collectors"); + + # Get PIDs from worker's collector registry + my @pids = values %collectors; + + if (@pids) { + _debug(__LINE__, "Sending SIGTERM to " . scalar(@pids) . " collector process(es)"); + foreach my $pid (@pids) { + if (kill(0, $pid)) { + kill('TERM', $pid); + _debug(__LINE__, "Sent SIGTERM to collector PID $pid"); + } + } + + # Wait up to 2 seconds for graceful shutdown + my $timeout = 2; + my $start = time(); + while (time() - $start < $timeout) { + my $any_alive = 0; + foreach my $pid (@pids) { + if (kill(0, $pid)) { + $any_alive = 1; + last; + } + } + last unless $any_alive; + select(undef, undef, undef, 0.1); + } + + # Force kill any survivors + foreach my $pid (@pids) { + if (kill(0, $pid)) { + _debug(__LINE__, "Force killing collector process $pid"); + kill('KILL', $pid); + } + } + } + + # Clear collector registry + %collectors = (); + _debug(__LINE__, "Cleared collector registry"); + + # Remove state files + if (-f $state_file) { + unlink $state_file or _debug(__LINE__, "Failed to remove $state_file: $!"); + } + + # Remove pve mod worker directory and all files if it exists + if (-d $pve_mod_working_dir) { + remove_tree($pve_mod_working_dir, { error => \my $err }); + _debug(__LINE__, "Cleanup errors: @$err") if @$err; + } + + _debug(__LINE__, "Cleanup complete"); +} + +END { + if ($process_type eq 'worker') { + _debug(__LINE__, "PVE Mod Worker END block: cleaning up"); + _stop_child_collectors(); + } elsif ($process_type eq 'collector') { + _debug(__LINE__, "Collector ($0) END block: no cleanup needed"); + # Collectors just exit, no cleanup needed + } else { + _debug(__LINE__, "Main process END block: no cleanup needed"); + # Main pveproxy process doesn't cleanup + } +} + +1; diff --git a/PveMod_pvemanagerlib.js b/PveMod_pvemanagerlib.js new file mode 100644 index 0000000..39504d5 --- /dev/null +++ b/PveMod_pvemanagerlib.js @@ -0,0 +1,1534 @@ +Ext.define('PVE.mod.TempHelper', { + //singleton: true, + + requires: ['Ext.util.Format'], + + statics: { + CELSIUS: 0, + FAHRENHEIT: 1 + }, + + srcUnit: null, + dstUnit: null, + + isValidUnit: function (unit) { + return ( + Ext.isNumber(unit) && (unit === this.self.CELSIUS || unit === this.self.FAHRENHEIT) + ); + }, + + constructor: function (config) { + this.srcUnit = config && this.isValidUnit(config.srcUnit) ? config.srcUnit : this.self.CELSIUS; + this.dstUnit = config && this.isValidUnit(config.dstUnit) ? config.dstUnit : this.self.CELSIUS; + }, + + toFahrenheit: function (tempCelsius) { + return Ext.isNumber(tempCelsius) + ? tempCelsius * 9 / 5 + 32 + : NaN; + }, + + toCelsius: function (tempFahrenheit) { + return Ext.isNumber(tempFahrenheit) + ? (tempFahrenheit - 32) * 5 / 9 + : NaN; + }, + + getTemp: function (value) { + if (this.srcUnit !== this.dstUnit) { + switch (this.srcUnit) { + case this.self.CELSIUS: + switch (this.dstUnit) { + case this.self.FAHRENHEIT: + return this.toFahrenheit(value); + + default: + Ext.raise({ + msg: + 'Unsupported destination temperature unit: ' + this.dstUnit, + }); + } + case this.self.FAHRENHEIT: + switch (this.dstUnit) { + case this.self.CELSIUS: + return this.toCelsius(value); + + default: + Ext.raise({ + msg: + 'Unsupported destination temperature unit: ' + this.dstUnit, + }); + } + default: + Ext.raise({ + msg: 'Unsupported source temperature unit: ' + this.srcUnit, + }); + } + } else { + return value; + } + }, + + getUnit: function(plainText) { + switch (this.dstUnit) { + case this.self.CELSIUS: + return plainText !== true ? '°C' : '\'C'; + + case this.self.FAHRENHEIT: + return plainText !== true ? '°F' : '\'F'; + + default: + Ext.raise({ + msg: 'Unsupported destination temperature unit: ' + this.srcUnit, + }); + } + }, +}); +Ext.define('PVE.node.StatusView', { + extend: 'Proxmox.panel.StatusView', + alias: 'widget.pveNodeStatus', + + minHeight: 360, + flex: 1, + collapsible: true, + titleCollapse: true, + bodyPadding: '20 15 20 15', + + layout: { + type: 'table', + columns: 2, + trAttrs: { valign: 'top' }, + tableAttrs: { + style: { + width: '100%', + }, + }, + }, + + defaults: { + xtype: 'pmxInfoWidget', + padding: '0 10 2 10', + }, + + items: [ + // ========== Primary Metrics ========== + { + xtype: 'box', + colspan: 2, + padding: '0', + html: '
Primary Metrics
', + }, + { + itemId: 'cpu', + iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon', + title: gettext('CPU Usage'), + valueField: 'cpu', + maxField: 'cpuinfo', + renderer: function(value, record) { + let result = Proxmox.Utils.render_node_cpu_usage(value, record); + // Append CPU model if available + if (record && record.cpuinfo && record.cpuinfo.model) { + result += ` (${record.cpuinfo.model})`; + } + return result; + }, + }, + { + iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', + itemId: 'memory', + title: gettext('Memory Usage'), + valueField: 'memory', + maxField: 'memory', + warningThreshold: 0.9, + criticalThreshold: 0.975, + renderer: Proxmox.Utils.render_node_size_usage, + }, + { + itemId: 'ksm', + iconCls: 'fa fa-fw fa-clone', + printBar: false, + title: gettext('KSM sharing'), + textField: 'ksm', + renderer: function (record) { + return Proxmox.Utils.render_size(record.shared); + }, + }, + { + itemId: 'gpu', + iconCls: 'fa fa-fw fa-desktop', + title: gettext('GPU Usage'), + printBar: false, + textField: 'PveMod_graphicsInfo', + renderer: function(gpuStats) { + if (!gpuStats || !gpuStats.Graphics) { + return ''; + } + + let hasActiveGPU = false; + let gpuName = ''; + + // Check Intel GPUs + if (gpuStats.Graphics.Intel) { + const keys = Object.keys(gpuStats.Graphics.Intel).sort(); + if (keys.length > 0) { + const gpuData = gpuStats.Graphics.Intel[keys[0]]; + hasActiveGPU = true; + gpuName = gpuData.name; + } + } + + // Check NVIDIA GPUs + if (gpuStats.Graphics.NVIDIA) { + const keys = Object.keys(gpuStats.Graphics.NVIDIA).sort(); + if (keys.length > 0) { + const stats = gpuStats.Graphics.NVIDIA[keys[0]].stats; + hasActiveGPU = true; + gpuName = stats.name; + } + } + + return hasActiveGPU ? gpuName : ''; + }, + }, + { + itemId: 'gpu_usage', + iconCls: 'fa fa-fw fa-desktop', + title: gettext('GPU 0'), + valueField: 'gpuStats', + printBar: false, + textField: 'gpuStats', + renderer: function(gpuStats) { + if (!gpuStats || !gpuStats.Graphics) { + return ''; + } + + // Check Intel GPUs + if (gpuStats.Graphics.Intel) { + const keys = Object.keys(gpuStats.Graphics.Intel).sort(); + if (keys.length > 0) { + const gpuData = gpuStats.Graphics.Intel[keys[0]]; + if (gpuData.stats.engines && gpuData.stats.engines['Render/3D']) { + const usage = gpuData.stats.engines['Render/3D'].busy; + return `${usage}%`; + } + } + } + + // Check NVIDIA GPUs + if (gpuStats.Graphics.NVIDIA) { + const keys = Object.keys(gpuStats.Graphics.NVIDIA).sort(); + if (keys.length > 0) { + const stats = gpuStats.Graphics.NVIDIA[keys[0]].stats; + if (stats.utilization) { + return `${stats.utilization.gpu}%`; + } + } + } + + return ''; + }, + }, + { + iconCls: 'fa fa-fw fa-hdd-o', + itemId: 'rootfs', + title: gettext('Disk (/) Usage'), + valueField: 'rootfs', + maxField: 'rootfs', + renderer: Proxmox.Utils.render_node_size_usage, + }, + { + iconCls: 'fa fa-fw fa-refresh', + itemId: 'swap', + title: gettext('SWAP Usage'), + valueField: 'swap', + maxField: 'swap', + warningThreshold: 0.4, + criticalThreshold: 0.8, + renderer: Proxmox.Utils.render_node_size_usage, + }, + // Fill the remaining cell so the next colspan:2 section header starts on a new row. + { + xtype: 'box', + html: '', + padding: 0, + }, + + // ========== Secondary Metrics ========== + { + xtype: 'box', + colspan: 2, + padding: '15 0 5 0', + html: '
Secondary Metrics
', + }, + { + itemId: 'load', + iconCls: 'fa fa-fw fa-tasks', + title: gettext('CPU Load Average'), + printBar: false, + textField: 'loadavg', + }, + { + itemId: 'wait', + iconCls: 'fa fa-fw fa-clock-o', + title: gettext('CPU I/O Delay'), + valueField: 'wait', + }, + { + itemId: 'thermalCpu', + colspan: 2, + printBar: false, + title: gettext('CPU Thermal State'), + iconCls: 'fa fa-fw fa-thermometer-half', + textField: 'PveMod_JsonSensorInfo', + renderer: function(value){ + // sensors configuration + const cpuTempHelper = Ext.create('PVE.mod.TempHelper', {srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.CELSIUS}); + // display configuration + const itemsPerRow = 0; + // --- + let objValue; + try { + objValue = JSON.parse(value) || {}; + objValue = objValue[Object.keys(objValue)[0]] || {}; + } catch(e) { + objValue = {}; + } + + const cpuKeysI = Object.keys(objValue).filter(item => String(item).startsWith('coretemp-isa-')).sort(); + const cpuKeysA = Object.keys(objValue).filter(item => String(item).startsWith('k10temp-pci-')).sort(); + const bINTEL = cpuKeysI.length > 0 ? true : false; + const INTELPackagePrefix = 'Core' == 'Core' ? 'Core ' : 'Package id'; + const INTELPackageCaption = 'Core' == 'Core' ? 'Core' : 'Package'; + let AMDPackagePrefix = 'Tccd'; + let AMDPackageCaption = 'CCD'; + + if (cpuKeysA.length > 0) { + let bTccd = false; + let bTctl = false; + let bTdie = false; + let bCpuCoreTemp = false; + cpuKeysA.forEach((cpuKey, cpuIndex) => { + let items = objValue[cpuKey]; + bTccd = Object.keys(items).findIndex(item => { return String(item).startsWith('Tccd'); }) >= 0; + bTctl = Object.keys(items).findIndex(item => { return String(item).startsWith('Tctl'); }) >= 0; + bTdie = Object.keys(items).findIndex(item => { return String(item).startsWith('Tdie'); }) >= 0; + bCpuCoreTemp = Object.keys(items).findIndex(item => { return String(item) === 'CPU Core Temp'; }) >= 0; + }); + if (bTccd && 'Core' == 'Core') { + AMDPackagePrefix = 'Tccd'; + AMDPackageCaption = 'ccd'; + } else if (bCpuCoreTemp && 'Core' == 'Package') { + AMDPackagePrefix = 'CPU Core Temp'; + AMDPackageCaption = 'CPU Core Temp'; + } else if (bTdie) { + AMDPackagePrefix = 'Tdie'; + AMDPackageCaption = 'die'; + } else if (bTctl) { + AMDPackagePrefix = 'Tctl'; + AMDPackageCaption = 'ctl'; + } else { + AMDPackagePrefix = 'temp'; + AMDPackageCaption = 'Temp'; + } + } + + const cpuKeys = bINTEL ? cpuKeysI : cpuKeysA; + const cpuItemPrefix = bINTEL ? INTELPackagePrefix : AMDPackagePrefix; + const cpuTempCaption = bINTEL ? INTELPackageCaption : AMDPackageCaption; + const formatTemp = bINTEL ? '0' : '0.0'; + const cpuCount = cpuKeys.length; + let temps = []; + + cpuKeys.forEach((cpuKey, cpuIndex) => { + let cpuTemps = []; + const items = objValue[cpuKey]; + const cpuModel = items.cpu_model || ''; + + const itemKeys = Object.keys(items).filter(item => { + if ('Core' == 'Core') { + // In Core mode: only show individual cores/CCDs, exclude overall CPU temp + return String(item).includes(cpuItemPrefix) || String(item).startsWith('Tccd'); + } else { + // In Package mode: show overall CPU temp and package-level readings + return String(item).includes(cpuItemPrefix) || String(item) === 'CPU Core Temp'; + } + }).sort((a, b) => { + // Sort cores numerically + let numA = parseInt(a.match(/\d+/)?.[0] || '0', 10); + let numB = parseInt(b.match(/\d+/)?.[0] || '0', 10); + return numA - numB; + }); + + itemKeys.forEach((coreKey) => { + try { + let tempVal = NaN, tempMax = NaN, tempCrit = NaN; + Object.keys(items[coreKey]).forEach((secondLevelKey) => { + if (secondLevelKey.endsWith('_input')) { + tempVal = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); + } else if (secondLevelKey.endsWith('_max')) { + tempMax = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); + } else if (secondLevelKey.endsWith('_crit')) { + tempCrit = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); + } + }); + + if (!isNaN(tempVal)) { + let tempStyle = ''; + if (!isNaN(tempMax) && tempVal >= tempMax) { + tempStyle = 'color: #FFC300; font-weight: bold;'; + } + if (!isNaN(tempCrit) && tempVal >= tempCrit) { + tempStyle = 'color: red; font-weight: bold;'; + } + + let tempStr = ''; + + // Enhanced parsing for AMD temperatures + if (coreKey.startsWith('Tccd')) { + let tempIndex = coreKey.match(/Tccd(\d+)/); + if (tempIndex !== null && tempIndex.length > 1) { + tempIndex = tempIndex[1]; + tempStr = `${cpuTempCaption} ${tempIndex}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } else { + tempStr = `${cpuTempCaption}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } + } + // Handle CPU Core Temp (single overall temperature) + else if (coreKey === 'CPU Core Temp') { + tempStr = `${cpuTempCaption}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } + // Enhanced parsing for Intel cores (P-Core, E-Core, regular Core) + else { + let tempIndex = coreKey.match(/(?:P\s+Core|E\s+Core|Core)\s*(\d+)/); + if (tempIndex !== null && tempIndex.length > 1) { + tempIndex = tempIndex[1]; + let coreType = coreKey.startsWith('P Core') ? 'P Core' : + coreKey.startsWith('E Core') ? 'E Core' : + cpuTempCaption; + tempStr = `${coreType} ${tempIndex}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } else { + // fallback for CPUs which do not have a core index + let coreType = coreKey.startsWith('P Core') ? 'P Core' : + coreKey.startsWith('E Core') ? 'E Core' : + cpuTempCaption; + tempStr = `${coreType}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } + } + + cpuTemps.push(tempStr); + } + } catch (e) { /*_*/ } + }); + + if(cpuTemps.length > 0) { + temps.push({ model: cpuModel, temps: cpuTemps }); + } + }); + + let html = ''; + temps.forEach((cpuData, cpuIndex) => { + const strCoreTemps = cpuData.temps.map((strTemp, index, arr) => { + return strTemp + (index + 1 < arr.length ? (itemsPerRow > 0 && (index + 1) % itemsPerRow === 0 ? '
' : ' | ') : ''); + }); + if(strCoreTemps.length > 0) { + let cpuLabel = cpuCount > 1 ? `Socket ${cpuIndex + 1}` : 'Socket 1'; + let cpuModelStr = cpuData.model || 'Unknown CPU'; + + html += ''; + html += ``; + html += ``; + html += ''; + } + }); + html += '
${cpuModelStr}${strCoreTemps.join('')}
'; + + return html.indexOf('') > 0 + ? '
' + html + '
' + : 'N/A'; + } + }, + { + itemId: 'gpu_details', + colspan: 2, + iconCls: 'fa fa-fw fa-desktop', + title: gettext('GPU Details'), + printBar: false, + textField: 'PveMod_graphicsInfo', + renderer: function(gpuStats) { + if (!gpuStats || !gpuStats.Graphics) { + return ''; + } + + let html = ''; + + // Intel GPUs - Secondary details + if (gpuStats.Graphics.Intel) { + Object.keys(gpuStats.Graphics.Intel).sort().forEach(key => { + const gpuData = gpuStats.Graphics.Intel[key]; + + let details = []; + + // All engine details + if (gpuData.stats.engines) { + if (gpuData.stats.engines['Render/3D']) { + details.push(`Render/3D: ${gpuData.stats.engines['Render/3D'].busy}%`); + } + if (gpuData.stats.engines['Video']) { + details.push(`Video: ${gpuData.stats.engines['Video'].busy}%`); + } + if (gpuData.stats.engines['Blitter']) { + details.push(`Blitter: ${gpuData.stats.engines['Blitter'].busy}%`); + } + if (gpuData.stats.engines['VideoEnhance']) { + details.push(`VideoEnhance: ${gpuData.stats.engines['VideoEnhance'].busy}%`); + } + } + + // Power + if (gpuData.stats.power) { + details.push(`Power: ${gpuData.stats.power?.GPU ?? 'N/A'} / ${gpuData.stats.power?.Package ?? 'N/A'} ${gpuData.stats.power?.unit || 'W'}`); + } + + // Frequency + if (gpuData.stats.frequency) { + details.push(`Freq: ${gpuData.stats.frequency?.actual ?? 'N/A'}/${gpuData.stats.frequency?.requested ?? 'N/A'} ${gpuData.stats.frequency?.unit || 'MHz'}`); + } + + html += ''; + html += ``; + html += ``; + html += ''; + }); + } + + // NVIDIA GPUs - Secondary details + if (gpuStats.Graphics.NVIDIA) { + Object.keys(gpuStats.Graphics.NVIDIA).sort().forEach(key => { + const gpuData = gpuStats.Graphics.NVIDIA[key]; + const stats = gpuData.stats; + + let details = []; + + // Memory Utilization + if (stats.utilization && stats.utilization.memory) { + const memUsage = parseInt(stats.utilization.memory); + let memStyle = ''; + if (memUsage >= 90) memStyle = 'color: #d9534f; font-weight: bold;'; + else if (memUsage >= 70) memStyle = 'color: #f0ad4e; font-weight: bold;'; + details.push(`MEM: ${stats.utilization.memory}%`); + } + + // VRAM Usage + if (stats.memory) { + const vramUsedGB = parseInt(stats.memory.used); + const vramTotalGB = parseInt(stats.memory.total); + const vramPercent = (vramUsedGB / vramTotalGB) * 100; + let vramStyle = ''; + if (vramPercent >= 90) vramStyle = 'color: #d9534f; font-weight: bold;'; + else if (vramPercent >= 70) vramStyle = 'color: #f0ad4e; font-weight: bold;'; + details.push(`VRAM: ${stats.memory.used}/${stats.memory.total} ${stats.memory.unit}`); + } + + // Temperature + if (stats.temperature) { + let tempStyle = ''; + if (stats.temperature.gpu >= 80) { + tempStyle = 'color: red; font-weight: bold;'; + } else if (stats.temperature.gpu >= 70) { + tempStyle = 'color: #FFC300; font-weight: bold;'; + } + details.push(`Temp: ${stats.temperature.gpu}${stats.temperature.unit}`); + } + + // Power + if (stats.power) { + details.push(`Power: ${stats.power.draw}/${stats.power.limit} ${stats.power.unit}`); + } + + html += ''; + html += ``; + html += ``; + html += ''; + }); + } + + html += '
${gpuData.name}${details.join(' | ')}
${stats.name}${details.join(' | ')}
'; + return html.indexOf('') > 0 + ? '
' + html + '
' + : ''; + }, + }, + { + itemId: 'thermalNvme', + colspan: 2, + printBar: false, + title: gettext('NVMe Temperatures'), + iconCls: 'fa fa-fw fa-thermometer-half', + textField: 'PveMod_JsonSensorInfo', + renderer: function(value) { + // sensors configuration + const addressPrefix = "nvme-pci-"; + const sensorName = "Composite"; + const tempHelper = Ext.create('PVE.mod.TempHelper', {srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.CELSIUS}); + // display configuration + const itemsPerRow = 0; + // --- + let objValue; + try { + objValue = JSON.parse(value) || {}; + objValue = objValue[Object.keys(objValue)[0]] || {}; + } catch(e) { + objValue = {}; + } + const nvmeKeys = Object.keys(objValue).filter(item => String(item).startsWith(addressPrefix)).sort(); + let nvmeData = []; + nvmeKeys.forEach((nvmeKey, index) => { + try { + let tempVal = NaN, tempMax = NaN, tempCrit = NaN, model = '', serial = ''; + Object.keys(objValue[nvmeKey][sensorName]).forEach((secondLevelKey) => { + if (secondLevelKey.endsWith('_input')) { + tempVal = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); + } else if (secondLevelKey.endsWith('_max')) { + tempMax = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); + } else if (secondLevelKey.endsWith('_crit')) { + tempCrit = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); + } + }); + model = objValue[nvmeKey]['model'] || 'Unknown'; + serial = objValue[nvmeKey]['serial'] || ''; + + if (!isNaN(tempVal)) { + let tempStyle = ''; + if (!isNaN(tempMax) && tempVal >= tempMax) { + tempStyle = 'color: #FFC300; font-weight: bold;'; + } + if (!isNaN(tempCrit) && tempVal >= tempCrit) { + tempStyle = 'color: red; font-weight: bold;'; + } + nvmeData.push({ + model: model, + serial: serial, + temp: tempVal, + tempStyle: tempStyle, + unit: tempHelper.getUnit() + }); + } + } catch(e) { /*_*/ } + }); + + if (nvmeData.length === 0) { + return 'N/A'; + } + + let html = ''; + nvmeData.forEach((data) => { + let deviceName = data.model; + if (data.serial) { + deviceName += ` (${data.serial})`; + } + html += ''; + html += ``; + html += ``; + html += ''; + }); + html += '
${deviceName}${Ext.util.Format.number(data.temp, '0.0')}${data.unit}
'; + return '
' + html + '
'; + } + }, + + // ========== TERTIARY DIAGNOSTICS (Tier 3) ========== + { + xtype: 'box', + colspan: 2, + padding: '15 0 5 0', + html: '
Diagnostics
', + }, + { + itemId: 'speedFan', + colspan: 2, + printBar: false, + title: gettext('System Fans'), + iconCls: 'fa fa-fw fa-snowflake-o', + textField: 'PveMod_JsonSensorInfo', + renderer: function(value) { + // --- + let objValue; + try { + objValue = JSON.parse(value) || {}; + objValue = objValue[Object.keys(objValue)[0]] || {}; + } catch(e) { + objValue = {}; + } + + // Recursive function to find fan keys and values + function findFanKeys(obj, fanKeys, parentKey = null) { + Object.keys(obj).forEach(key => { + const value = obj[key]; + if (typeof value === 'object' && value !== null) { + // If the value is an object, recursively call the function + findFanKeys(value, fanKeys, key); + } else if (/^fan[0-9]+(_input)?$/.test(key)) { + if (true != true && value === 0) { + // Skip this fan if DISPLAY_ZERO_SPEED_FANS is false and value is 0 + return; + } + // If the key matches the pattern, add the parent key and value to the fanKeys array + fanKeys.push({ key: parentKey, value: value }); + } + }); + } + + let speeds = []; + // Loop through the parent keys + Object.keys(objValue).forEach(parentKey => { + const parentObj = objValue[parentKey]; + // Array to store fan keys and values + const fanKeys = []; + // Call the recursive function to find fan keys and values + findFanKeys(parentObj, fanKeys); + // Sort the fan keys + fanKeys.sort((a, b) => { + if (a.key < b.key) return -1; + if (a.key > b.key) return 1; + return 0; + }); + // Process each fan key and value + fanKeys.forEach(({ key: fanKey, value: fanSpeed }) => { + try { + const fan = fanKey.charAt(0).toUpperCase() + fanKey.slice(1); // Capitalize the first letter of fanKey + speeds.push(`${fan}: ${fanSpeed} RPM`); + } catch(e) { + console.error(`Error retrieving fan speed for ${fanKey} in ${parentKey}:`, e); // Debug: Log specific error + } + }); + }); + return '
' + (speeds.length > 0 ? speeds.join(' | ') : 'N/A') + '
'; + } + }, + { + itemId: 'gpuFans', + colspan: 2, + printBar: false, + title: gettext('GPU Fans'), + iconCls: 'fa fa-fw fa-snowflake-o', + textField: 'PveMod_graphicsInfo', + renderer: function(gpuStats) { + if (!gpuStats || !gpuStats.Graphics || !gpuStats.Graphics.NVIDIA) { + return ''; + } + + let rows = []; + + // todo: handle intel, amd + + Object.keys(gpuStats.Graphics.NVIDIA).sort().forEach(key => { + const gpuData = gpuStats.Graphics.NVIDIA[key]; + const stats = gpuData?.stats; + const fan = stats?.fan; + + if (!fan || fan.speed === undefined || fan.speed === null) { + return; + } + + const gpuName = stats?.name || key; + const unit = fan.unit || '%'; + rows.push( + '' + + `${gpuName}` + + `Fan: ${fan.speed}${unit}` + + '', + ); + }); + + if (rows.length === 0) { + return 'N/A'; + } + + return '
' + rows.join('') + '
'; + }, + }, + { + itemId: 'upsc', + colspan: 2, + printBar: false, + title: gettext('UPS Status'), + iconCls: 'fa fa-fw fa-battery-three-quarters', + textField: 'PveMod_upsInfo', + renderer: function(value) { + let objValue = {}; + try { + // Parse the UPS data + if (typeof value === 'string') { + objValue = JSON.parse(value) || {}; + } else if (typeof value === 'object') { + objValue = value || {}; + } + } catch(e) { + objValue = {}; + } + + // If objValue is null or empty, return N/A + if (!objValue || Object.keys(objValue).length === 0) { + return 'N/A'; + } + + // Helper function to get status color + function getStatusColor(status) { + if (!status) return '#999'; + const statusUpper = status.toUpperCase(); + if (statusUpper.includes('OL')) return null; + if (statusUpper.includes('OB')) return '#d9534f'; + if (statusUpper.includes('LB')) return '#d9534f'; + return '#f0ad4e'; + } + + // Helper function to get load/charge color + function getPercentageColor(value, isLoad = false) { + if (!value || isNaN(value)) return '#999'; + const num = parseFloat(value); + if (isLoad) { + if (num >= 80) return '#d9534f'; + if (num >= 60) return '#f0ad4e'; + return null; + } else { + if (num <= 20) return '#d9534f'; + if (num <= 50) return '#f0ad4e'; + return null; + } + } + + // Helper function to format runtime + function formatRuntime(seconds) { + if (!seconds || isNaN(seconds)) return 'N/A'; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}m ${secs}s`; + } + + // Process each UPS in the data + let allDisplayItems = []; + + Object.keys(objValue).forEach(upsKey => { + const upsData = objValue[upsKey]; + + // Extract key UPS information + const batteryCharge = upsData['battery.charge']; + const batteryRuntime = upsData['battery.runtime']; + const inputVoltage = upsData['input.voltage']; + const upsLoad = upsData['ups.load']; + const upsStatus = upsData['ups.status']; + const upsModel = upsData['ups.model'] || upsData['device.model']; + const testResult = upsData['ups.test.result']; + const batteryChargeLow = upsData['battery.charge.low']; + const batteryRuntimeLow = upsData['battery.runtime.low']; + const upsRealPowerNominal = upsData['ups.realpower.nominal']; + const batteryMfrDate = upsData['battery.mfr.date']; + + // Main status line with all metrics + let statusLine = ''; + + // Status + if (upsStatus) { + const statusUpper = upsStatus.toUpperCase(); + let statusText = 'Unknown'; + let statusColor = '#f0ad4e'; + + if (statusUpper.includes('OL')) { + statusText = 'Online'; + statusColor = null; + } else if (statusUpper.includes('OB')) { + statusText = 'On Battery'; + statusColor = '#d9534f'; + } else if (statusUpper.includes('LB')) { + statusText = 'Low Battery'; + statusColor = '#d9534f'; + } else { + statusText = upsStatus; + statusColor = '#f0ad4e'; + } + + let statusStyle = statusColor ? ('color: ' + statusColor + ';') : ''; + statusLine += 'Status: ' + statusText + ''; + } else { + statusLine += 'Status: N/A'; + } + + // Battery charge + if (statusLine) statusLine += ' | '; + if (batteryCharge) { + const chargeColor = getPercentageColor(batteryCharge, false); + let chargeStyle = chargeColor ? ('color: ' + chargeColor + ';') : ''; + statusLine += 'Battery: ' + batteryCharge + '%'; + } else { + statusLine += 'Battery: N/A'; + } + + // Load percentage + if (statusLine) statusLine += ' | '; + if (upsLoad) { + const loadColor = getPercentageColor(upsLoad, true); + let loadStyle = loadColor ? ('color: ' + loadColor + ';') : ''; + statusLine += 'Load: ' + upsLoad + '%'; + } else { + statusLine += 'Load: N/A'; + } + + // Runtime + if (statusLine) statusLine += ' | '; + if (batteryRuntime) { + const runtime = parseInt(batteryRuntime); + const runtimeLowThreshold = batteryRuntimeLow ? parseInt(batteryRuntimeLow) : 600; + let runtimeColor = null; + if (runtime <= runtimeLowThreshold / 2) runtimeColor = '#d9534f'; + else if (runtime <= runtimeLowThreshold) runtimeColor = '#f0ad4e'; + let runtimeStyle = runtimeColor ? ('color: ' + runtimeColor + ';') : ''; + statusLine += 'Runtime: ' + formatRuntime(runtime) + ''; + } else { + statusLine += 'Runtime: N/A'; + } + + // Input voltage + if (statusLine) statusLine += ' | '; + if (inputVoltage) { + statusLine += 'Input: ' + parseFloat(inputVoltage).toFixed(0) + 'V'; + } else { + statusLine += 'Input: N/A'; + } + + // Calculate actual watt usage + if (statusLine) statusLine += ' | '; + let actualWattage = null; + if (upsLoad && upsRealPowerNominal) { + const load = parseFloat(upsLoad); + const nominal = parseFloat(upsRealPowerNominal); + if (!isNaN(load) && !isNaN(nominal)) { + actualWattage = Math.round((load / 100) * nominal); + } + } + + // Real power (calculated watt usage) + if (actualWattage !== null) { + statusLine += 'Output: ' + actualWattage + 'W'; + } else { + statusLine += 'Output: N/A'; + } + + // Append battery MFD + last test to the same line (single-line UPS summary) + statusLine += ' | Battery MFD: ' + (batteryMfrDate || 'N/A'); + if (testResult && !testResult.toLowerCase().includes('no test')) { + const testColor = testResult.toLowerCase().includes('passed') ? null : '#d9534f'; + let testStyle = testColor ? ('color: ' + testColor + ';') : ''; + statusLine += ' | Test: ' + testResult + ''; + } else { + statusLine += ' | Test: N/A'; + } + + // Build UPS display with model on left, details on right + let upsHtml = ''; + upsHtml += '' + (upsModel || upsKey) + ''; + upsHtml += '' + statusLine + ''; + upsHtml += ''; + + allDisplayItems.push(upsHtml); + }); + + // Format the final output for all UPS devices + return '
' + allDisplayItems.join('') + '
'; + } + }, + { + xtype: 'box', + colspan: 2, + padding: '15 0 5 0', + html: '
System
', + }, + { + colspan: 2, + title: gettext('Kernel Version'), + printBar: false, + // TODO: remove with next major and only use newish current-kernel textfield + multiField: true, + //textField: 'current-kernel', + renderer: ({ data }) => { + if (!data['current-kernel']) { + return data.kversion; + } + let kernel = data['current-kernel']; + let buildDate = kernel.version.match(/\((.+)\)\s*$/)?.[1] ?? 'unknown'; + return `${kernel.sysname} ${kernel.release} (${buildDate})`; + }, + value: '', + }, + { + colspan: 2, + title: gettext('Boot Mode'), + printBar: false, + textField: 'boot-info', + renderer: (boot) => { + if (boot.mode === 'legacy-bios') { + return 'Legacy BIOS'; + } else if (boot.mode === 'efi') { + return `EFI${boot.secureboot ? ' (Secure Boot)' : ''}`; + } + return Proxmox.Utils.unknownText; + }, + value: '', + }, + { + itemId: 'version', + colspan: 2, + printBar: false, + title: gettext('Manager Version'), + textField: 'pveversion', + value: '', + }, + { + itemId: 'pve_mod_version', + colspan: 2, + printBar: false, + title: gettext('Sensor Mod Version'), + textField: 'PveMod_Version', + value: '', + }, + { + itemId: 'sysinfo', + colspan: 2, + printBar: false, + title: gettext('Information'), + textField: 'PveMod_systemInfo', + renderer: function(value) { + if (value === null || value === undefined) { + return ''; + } + return value; + } + }, + ], + + updateTitle: function () { + var me = this; + var uptime = Proxmox.Utils.render_uptime(me.getRecordValue('uptime')); + me.setTitle(me.pveSelNode.data.node + ' (' + gettext('Uptime') + ': ' + uptime + ')'); + }, + + initComponent: function () { + let me = this; + + let stateProvider = Ext.state.Manager.getProvider(); + let repoLink = stateProvider.encodeHToken({ + view: 'server', + rid: `node/${me.pveSelNode.data.node}`, + ltab: 'tasks', + nodetab: 'aptrepositories', + }); + + me.items.push({ + xtype: 'pmxNodeInfoRepoStatus', + itemId: 'repositoryStatus', + product: 'Proxmox VE', + repoLink: `#${repoLink}`, + }); + + me.callParent(); + }, +}); + +Ext.define('pve-rrd-gpu', { + extend: 'Ext.data.Model', + fields: [ + 'freq_req', 'freq_act', 'rc6', + 'power_gpu', 'power_pkg', + 'render_busy', 'blitter_busy', 'video_busy', 'videnh_busy', + 'gpu_util', 'mem_util', 'mem_used', 'mem_total', + 'power_draw', 'power_limit', 'temp_gpu', 'fan_speed', + { type: 'date', dateFormat: 'timestamp', name: 'time' }, + ], +}); + +Ext.define('PVE.data.GpuRRDStore', { + extend: 'Proxmox.data.RRDStore', + alias: 'store.pveGpuRRDStore', + + model: 'pve-rrd-gpu', + card: undefined, + + setRRDUrl: function(timeframe, cf) { + var me = this; + if (!me.rrdurl) { return; } + if (!timeframe) { timeframe = me.timeframe; } + if (!cf) { cf = me.cf; } + me.proxy.url = me.rrdurl + + '?card=' + encodeURIComponent(me.card) + + '&timeframe=' + timeframe + + '&cf=' + cf; + }, +}); + +Ext.define('PVE.node.GpuRRD', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveNodeGpuRRD', + + layout: 'fit', + title: 'GPU', + + initComponent: function() { + var me = this; + + var nodename = me.nodename; + var card = me.card || 'card0'; + var baseurl = '/api2/json/nodes/' + nodename + '/gpurrddata'; + var isNvidia = card.indexOf('nvidia') === 0; + + var store = Ext.create('PVE.data.GpuRRDStore', { + rrdurl: baseurl, + card: card, + }); + + var items; + if (isNvidia) { + items = [ + { + xtype: 'proxmoxRRDChart', + title: 'GPU & Memory Utilization', + fields: ['gpu_util', 'mem_util'], + fieldTitles: ['GPU %', 'Memory %'], + unit: 'percent', + store: store, + }, + { + xtype: 'proxmoxRRDChart', + title: 'Memory Usage (MiB)', + fields: ['mem_used', 'mem_total'], + fieldTitles: ['Used', 'Total'], + store: store, + }, + { + xtype: 'proxmoxRRDChart', + title: 'Power Draw (W)', + fields: ['power_draw', 'power_limit'], + fieldTitles: ['Draw', 'Limit'], + store: store, + }, + { + xtype: 'proxmoxRRDChart', + title: 'Temperature & Fan', + fields: ['temp_gpu', 'fan_speed'], + fieldTitles: ['Temp (°C)', 'Fan %'], + store: store, + }, + ]; + } else { + items = [ + { + xtype: 'proxmoxRRDChart', + title: 'GPU Frequency (MHz)', + fields: ['freq_req', 'freq_act'], + fieldTitles: ['Requested', 'Actual'], + store: store, + }, + { + xtype: 'proxmoxRRDChart', + title: 'Engine Busy', + fields: ['render_busy', 'blitter_busy', 'video_busy', 'videnh_busy'], + fieldTitles: ['Render/3D %', 'Blitter %', 'Video %', 'VideoEnh %'], + unit: 'percent', + store: store, + }, + { + xtype: 'proxmoxRRDChart', + title: 'Power (W)', + fields: ['power_gpu', 'power_pkg'], + fieldTitles: ['GPU', 'Package'], + store: store, + }, + { + xtype: 'proxmoxRRDChart', + title: 'RC6 Residency', + fields: ['rc6'], + fieldTitles: ['RC6 %'], + unit: 'percent', + store: store, + }, + ]; + } + + Ext.apply(me, { + items: [{ + xtype: 'container', + layout: { + type: 'vbox', + align: 'stretch', + }, + items: items, + }], + }); + + me.callParent(); + + me.on('activate', function() { store.startUpdate(); }); + me.on('deactivate', function() { store.stopUpdate(); }); + me.on('destroy', function() { store.stopUpdate(); }); + }, +}); + +Ext.define('PVE.node.Summary', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveNodeSummary', + + scrollable: true, + bodyPadding: 5, + + showVersions: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + + var view = Ext.createWidget('component', { + autoScroll: true, + id: 'pkgversions', + padding: 5, + style: { + 'white-space': 'pre', + 'font-family': 'monospace', + }, + }); + + var win = Ext.create('Ext.window.Window', { + title: gettext('Package versions'), + width: 600, + height: 600, + layout: 'fit', + modal: true, + items: [view], + buttons: [ + { + xtype: 'button', + iconCls: 'fa fa-clipboard', + handler: function (button) { + window + .getSelection() + .selectAllChildren(document.getElementById('pkgversions')); + document.execCommand('copy'); + }, + text: gettext('Copy'), + }, + { + text: gettext('Ok'), + handler: function () { + this.up('window').close(); + }, + }, + ], + }); + + Proxmox.Utils.API2Request({ + waitMsgTarget: me, + url: `/nodes/${nodename}/apt/versions`, + method: 'GET', + failure: function (response, opts) { + win.close(); + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + success: function (response, opts) { + win.show(); + let text = ''; + Ext.Array.each(response.result.data, function (rec) { + let version = 'not correctly installed'; + let pkg = rec.Package; + if (rec.OldVersion && rec.CurrentState === 'Installed') { + version = rec.OldVersion; + } + if (rec.RunningKernel) { + text += `${pkg}: ${version} (running kernel: ${rec.RunningKernel})\n`; + } else if (rec.ManagerVersion) { + text += `${pkg}: ${version} (running version: ${rec.ManagerVersion})\n`; + } else { + text += `${pkg}: ${version}\n`; + } + }); + + view.update(Ext.htmlEncode(text)); + }, + }); + }, + + updateRepositoryStatus: function () { + let me = this; + let repoStatus = me.nodeStatus.down('#repositoryStatus'); + + let nodename = me.pveSelNode.data.node; + + Proxmox.Utils.API2Request({ + url: `/nodes/${nodename}/apt/repositories`, + method: 'GET', + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: (response) => + repoStatus.setRepositoryInfo(response.result.data['standard-repos']), + }); + + Proxmox.Utils.API2Request({ + url: `/nodes/${nodename}/subscription`, + method: 'GET', + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: function (response, opts) { + const res = response.result; + const subscription = res?.data?.status.toLowerCase() === 'active'; + repoStatus.setSubscriptionStatus(subscription); + }, + }); + }, + + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + if (!me.statusStore) { + throw 'no status storage specified'; + } + + var rstore = me.statusStore; + + var version_btn = new Ext.Button({ + text: gettext('Package versions'), + handler: function () { + Proxmox.Utils.checked_command(function () { + me.showVersions(); + }); + }, + }); + + var rrdstore = Ext.create('Proxmox.data.RRDStore', { + rrdurl: '/api2/json/nodes/' + nodename + '/rrddata', + model: 'pve-rrd-node', + }); + + var gpurrdstore = Ext.create('PVE.data.GpuRRDStore', { + rrdurl: '/api2/json/nodes/' + nodename + '/gpurrddata', + card: 'card0', + }); + + let nodeStatus = Ext.create('PVE.node.StatusView', { + xtype: 'pveNodeStatus', + rstore: rstore, + width: 770, + pveSelNode: me.pveSelNode, + }); + + Ext.apply(me, { + tbar: [version_btn, '->', { xtype: 'proxmoxRRDTypeSelector' }], + nodeStatus: nodeStatus, + items: [ + { + xtype: 'container', + itemId: 'itemcontainer', + layout: 'column', + minWidth: 700, + defaults: { + minHeight: 360, + padding: 5, + columnWidth: 1, + }, + items: [ + nodeStatus, + { + xtype: 'proxmoxRRDChart', + title: gettext('CPU Usage'), + fields: ['cpu', 'iowait'], + fieldTitles: [gettext('CPU usage'), gettext('IO delay')], + unit: 'percent', + store: rrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('Server Load'), + fields: ['loadavg'], + fieldTitles: [gettext('Load average')], + store: rrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('Memory usage'), + fields: [ + { + yField: 'memtotal', + title: gettext('Total'), + tooltip: { + trackMouse: true, + renderer: function (toolTip, record, item) { + let value = record.get('memtotal'); + + if (value === null) { + toolTip.setHtml(gettext('No Data')); + } else { + let total = Proxmox.Utils.format_size(value); + let time = new Date(record.get('time')); + + let avail = record.get('memavailable'); + let availText = ''; + if (Ext.isNumeric(avail)) { + let v = Proxmox.Utils.format_size(avail); + availText = ` (${gettext('Available')}: ${v})`; + } + + toolTip.setHtml( + `${gettext('Total')}: ${total}${availText}
${time}`, + ); + } + }, + }, + }, + { + yField: 'memused', + title: gettext('Used'), + tooltip: { + trackMouse: true, + renderer: function (toolTip, record, item) { + let value = record.get('memused'); + + if (value === null) { + toolTip.setHtml(gettext('No Data')); + } else { + let total = Proxmox.Utils.format_size(value); + let time = new Date(record.get('time')); + + let arc = record.get('arcsize'); + let arcText = ''; + if (Ext.isNumeric(arc) && arc > 1024 * 1024) { + let v = Proxmox.Utils.format_size(value - arc); + arcText = ` (${gettext('Without ZFS ARC')}: ${v})`; + } + + toolTip.setHtml( + `${gettext('Used')}: ${total}${arcText}
${time}`, + ); + } + }, + }, + }, + 'arcsize', + { + type: 'line', + fill: false, + yField: 'memavailable', + title: gettext('Available'), + style: { + lineWidth: 2.5, + opacity: 1, + }, + }, + ], + fieldTitles: [ + gettext('Total'), + gettext('Used'), + gettext('ZFS ARC'), + gettext('Available'), + ], + colors: ['#94ae0a', '#115fa6', '#24AD9A', '#bbde0d'], + unit: 'bytes', + powerOfTwo: true, + store: rrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('Network Traffic'), + fields: ['netin', 'netout'], + fieldTitles: [gettext('Incoming'), gettext('Outgoing')], + store: rrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('CPU Pressure Stall'), + fieldTitles: ['Some'], + fields: ['pressurecpusome'], + colors: ['#FFD13E', '#A61120'], + store: rrdstore, + unit: 'percent', + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('IO Pressure Stall'), + fieldTitles: ['Some', 'Full'], + fields: ['pressureiosome', 'pressureiofull'], + colors: ['#FFD13E', '#A61120'], + store: rrdstore, + unit: 'percent', + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('Memory Pressure Stall'), + fieldTitles: ['Some', 'Full'], + fields: ['pressurememorysome', 'pressurememoryfull'], + colors: ['#FFD13E', '#A61120'], + store: rrdstore, + unit: 'percent', + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('GPU Frequency (MHz)'), + fields: ['freq_req', 'freq_act'], + fieldTitles: [gettext('Requested'), gettext('Actual')], + store: gpurrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('GPU Engine Busy'), + fields: ['render_busy', 'blitter_busy', 'video_busy', 'videnh_busy'], + fieldTitles: [gettext('Render/3D'), gettext('Blitter'), gettext('Video'), gettext('VideoEnh')], + unit: 'percent', + store: gpurrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('GPU Power (W)'), + fields: ['power_gpu', 'power_pkg'], + fieldTitles: [gettext('GPU'), gettext('Package')], + store: gpurrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('GPU RC6 Residency'), + fields: ['rc6'], + fieldTitles: [gettext('RC6 %')], + unit: 'percent', + store: gpurrdstore, + }, + ], + listeners: { + resize: function (panel) { + Proxmox.Utils.updateColumns(panel); + }, + }, + }, + ], + listeners: { + activate: function () { + rstore.setInterval(1000); + rstore.startUpdate(); + rrdstore.startUpdate(); + gpurrdstore.startUpdate(); + }, + destroy: function () { + rstore.setInterval(5000); + rrdstore.stopUpdate(); + gpurrdstore.stopUpdate(); + }, + }, + }); + + me.updateRepositoryStatus(); + + me.callParent(); + + let sp = Ext.state.Manager.getProvider(); + me.mon(sp, 'statechange', function (provider, key, value) { + if (key !== 'summarycolumns') { + return; + } + Proxmox.Utils.updateColumns(me.getComponent('itemcontainer')); + }); + }, +}); \ No newline at end of file diff --git a/pve-mod-gui-sensors.sh b/pve-mod-gui-sensors.sh index da7f9df..d7beb34 100644 --- a/pve-mod-gui-sensors.sh +++ b/pve-mod-gui-sensors.sh @@ -4,20 +4,12 @@ # ################### Configuration ############# - -# Display configuration for HDD, NVME, CPU -# Set to 0 to disable line breaks -# Note: use these settings only if the displayed layout is broken -CPU_ITEMS_PER_ROW=0 -NVME_ITEMS_PER_ROW=0 -HDD_ITEMS_PER_ROW=0 - # Known CPU sensor names. They can be full or partial but should ensure unambiguous identification. # Should new ones be added, also update logic in configure() function. KNOWN_CPU_SENSORS=("coretemp-isa-" "k10temp-pci-") # Overwrite default backup location -BACKUP_DIR="" +BACKUP_DIR="/root/PVE-MOD2/Backup" ##################### DO NOT EDIT BELOW ####################### # Only to be used to debug on other systems. Save the "sensor -j" output into a json file. @@ -38,7 +30,12 @@ JSON_EXPORT_FILENAME="sensorsdata.json" # File paths PVE_MANAGER_LIB_JS_FILE="/usr/share/pve-manager/js/pvemanagerlib.js" +PVE_MOD_JS_SOURCE_FILE="$SCRIPT_CWD/PveMod_PveNodeStatusView.js" +PVE_MOD_JS_TARGET_FILE="/usr/share/pve-manager/js/PveMod_PveNodeStatusView.js" NODES_PM_FILE="/usr/share/perl5/PVE/API2/Nodes.pm" +PVE_SENSOR_INFO_MOD_FILE="/usr/share/perl5/PVE/API2/PveMod_SensorInfo.pm" +PVE_SENSOR_INFO_SOURCE_FILE="$SCRIPT_CWD/PveMod_SensorInfo.pm" +GPU_RRD_DIR="/var/lib/rrdcached/db/pve-mod-gpu" #region message tools # Section header (bold) @@ -117,6 +114,7 @@ function install_packages { fi } +# Main configuration function to detect sensors and set up parameters function configure { SENSORS_DETECTED=false local sensorsOutput @@ -127,7 +125,7 @@ function configure { install_packages - #### Collect lm-sensors output #### + #### Collect sensor data #### #region sensors collection if [ "$DEBUG_REMOTE" = true ]; then warn "Remote debugging is used. Sensor readings from dump file $DEBUG_JSON_FILE will be used." @@ -146,7 +144,7 @@ function configure { #endregion sensors collection #### CPU #### - #region cpu setup + #region CPU setup msgb "\n=== Detecting CPU temperature sensors ===" ENABLE_CPU=false local cpuList="" @@ -195,12 +193,123 @@ function configure { warn "No CPU temperature sensors found." fi #endregion cpu setup + + #### Graphics #### + #region Graphics setup + msgb "\n=== Detecting Graphics information ===" + + #region intel GPU setup + # Check for Intel GPU - ensure intel_gpu_top is installed + if command -v intel_gpu_top &>/dev/null; then + # detect all intel cards using intel_gpu_top -L. Show them line by line + local intelCards + + # Get the output from intel_gpu_top -L, skip empty lines + intelCards=$(intel_gpu_top -L 2>/dev/null | grep -E '^card[0-9]+' || true) + + if [[ -n "$intelCards" ]]; then + local cardCount=$(echo "$intelCards" | wc -l) + echo "Intel GPU(s) detected ($cardCount):" + echo "$intelCards" | while IFS= read -r line; do + # Extract card name, GPU model, and PCI info + if [[ $line =~ ^(card[0-9]+)[[:space:]]+(.+)[[:space:]]+pci:(.+)$ ]]; then + local cardName="${BASH_REMATCH[1]}" + local gpuModel="${BASH_REMATCH[2]}" + local pciInfo="${BASH_REMATCH[3]}" + + echo " - Card: $cardName" + echo " Model: $gpuModel" + echo " PCI: $pciInfo" + else + # Fallback: just show the line as-is + echo " $line" + fi + done + ENABLE_INTEL_GPU_INFO=true + ENABLE_GPU_INFO=true + else + warn "No Intel GPUs detected by intel_gpu_top." + ENABLE_INTEL_GPU_INFO=false + fi + else + warn "intel_gpu_top command not found. Skipping Intel GPU information detection." + ENABLE_INTEL_GPU_INFO=false + fi + #endregion intel GPU setup + + #region NVIDIA GPU setup + # Check for NVIDIA GPU - ensure nvidia-smi is installed + if command -v nvidia-smi &>/dev/null; then + # detect all NVIDIA cards using nvidia-smi -L + local nvidiaCards + + # Get the output from nvidia-smi -L (lists GPUs) + nvidiaCards=$(nvidia-smi -L 2>/dev/null || true) + + if [[ -n "$nvidiaCards" ]]; then + local cardCount=$(echo "$nvidiaCards" | wc -l) + echo "NVIDIA GPU(s) detected ($cardCount):" + echo "$nvidiaCards" | while IFS= read -r line; do + # Expected format: GPU 0: NVIDIA GeForce RTX 3080 (UUID: GPU-xxxxx) + if [[ $line =~ ^GPU\ ([0-9]+):\ (.+)\ \(UUID:\ (.+)\)$ ]]; then + local gpuIndex="${BASH_REMATCH[1]}" + local gpuModel="${BASH_REMATCH[2]}" + local gpuUUID="${BASH_REMATCH[3]}" + + echo " - GPU $gpuIndex" + echo " Model: $gpuModel" + echo " UUID: $gpuUUID" + else + # Fallback: just show the line as-is + echo " $line" + fi + done + ENABLE_NVIDIA_GPU_INFO=true + ENABLE_GPU_INFO=true + else + warn "No NVIDIA GPUs detected by nvidia-smi." + ENABLE_NVIDIA_GPU_INFO=false + fi + else + warn "nvidia-smi command not found. Skipping NVIDIA GPU information detection." + ENABLE_NVIDIA_GPU_INFO=false + fi + #endregion NVIDIA GPU setup + + #region AMD GPU setup + # not implemented yet + #endregion AMD GPU setup + + #endregion Graphics setup + + #### GPU Historical Data #### + #region gpu history setup + ENABLE_GPU_HISTORY=false + if [[ "$ENABLE_GPU_INFO" == true ]]; then + msgb "\n=== GPU Historical Data ===" + local choiceGpuHistory + choiceGpuHistory=$(ask "Store historical GPU data for graphs? (y/N)") + case "$choiceGpuHistory" in + [yY]) + ENABLE_GPU_HISTORY=true + info "Historical GPU data will be stored." + ;; + [nN]|"") + info "Historical GPU data will not be stored." + ;; + *) + warn "Invalid selection. Historical GPU data will not be stored." + ;; + esac + fi + #endregion gpu history setup #### RAM #### #region ram setup + local ramList ramCount msgb "\n=== Detecting RAM temperature sensors ===" - local ramList=$(echo "$sanitisedSensorsOutput" | grep -o '"SODIMM[^"]*"' | sed 's/"//g' | paste -sd, -) - local ramCount=$(grep -c '"SODIMM[^"]*"' <<<"$sanitisedSensorsOutput") + ramList=$(echo "$sanitisedSensorsOutput" | grep -o '"SODIMM[^"]*"' | sed 's/"//g' | paste -sd, -) + ramCount=$(grep -c '"SODIMM[^"]*"' <<<"$sanitisedSensorsOutput") if [ "$ramCount" -gt 0 ]; then info "Detected RAM sensors ($ramCount): $ramList" @@ -386,46 +495,24 @@ function install_mod { perform_backup #### Insert information retrieval code #### - msgb "\n=== Inserting information retrieval code ===" - insert_node_info - - #### Temperature helper parameters #### - msgb "\n=== Creating temperature conversion helper ===" - HELPERCTORPARAMS=$([[ "$TEMP_UNIT" = "F" ]] && \ - echo '{srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.FAHRENHEIT}' || \ - echo '{srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.CELSIUS}') - info "Temperature helper configured for $TEMP_UNIT." - - #### Expand StatusView space #### - expand_statusview_space - - #### Insert temperature helper #### - generate_and_insert_temp_helper + msgb "\n=== Installing sensor info module ===" + install_sensor_monitor_module + insert_sensor_monitor_into_pve + insert_system_info_into_pve - #### Generate and insert widgets #### - msgb "\n=== Making visual adjustments ===" + ## Historical GPU data ## + if [[ "$ENABLE_GPU_HISTORY" == true ]]; then + install_gpu_history - generate_and_insert_widget "$ENABLE_SYSTEM_INFO" "generate_system_info" "system_info" - generate_and_insert_widget "$ENABLE_UPS" "generate_ups_widget" "ups" - generate_and_insert_widget "$ENABLE_HDD_TEMP" "generate_hdd_widget" "hdd" - generate_and_insert_widget "$ENABLE_NVME_TEMP" "generate_nvme_widget" "nvme" + # todo - if [[ "$ENABLE_HDD_TEMP" = true || "$ENABLE_NVME_TEMP" = true ]]; then - generate_drive_header - info "Drive headers added." - fi - - generate_and_insert_widget "$ENABLE_FAN_SPEED" "generate_fan_widget" "fan" - generate_and_insert_widget "$ENABLE_RAM_TEMP" "generate_ram_widget" "ram" - generate_and_insert_widget "$ENABLE_CPU" "generate_cpu_widget" "cpu" + # add nodes.pm index list — add gpumeta after gpurrddata: - #### Visual separation #### - add_visual_separator - info "Added visual separator for modified items." + fi - #### Node summary #### - setup_node_summary_container - info "Node summary box moved into its own container." + #### Install UI modification module #### + msgb "\n=== Installing UI modification module ===" + install_node_status_view_module msgb "\n=== Finalizing installation ===" @@ -462,99 +549,76 @@ sanitize_sensors_output() { ' | python3 -m json.tool 2>/dev/null || echo "$input" } -#region node info insertion -# Main insertion routine -insert_node_info() { - local output_file="$NODES_PM_FILE" - - collect_sensors_output "$output_file" - - if [[ $ENABLE_UPS == true ]]; then - collect_ups_output "$output_file" - fi - - if [[ $ENABLE_SYSTEM_INFO == true ]]; then - collect_system_info "$output_file" +#region Sensor Monitor Module Installation +# Install and configure the Sensor Monitor Perl module +install_sensor_monitor_module() { + local intel_enabled nvidia_enabled ups_enabled sensors_mode + # Check if source file exists + if [[ ! -f "$PVE_SENSOR_INFO_SOURCE_FILE" ]]; then + err "Source file not found: $PVE_SENSOR_INFO_SOURCE_FILE" fi -} -# Collect lm-sensors data -collect_sensors_output() { - local output_file="$1" - local sensorsCmd + # Copy the module file + cp "$PVE_SENSOR_INFO_SOURCE_FILE" "$PVE_SENSOR_INFO_MOD_FILE" || err "Failed to copy $PVE_SENSOR_INFO_SOURCE_FILE to $PVE_SENSOR_INFO_MOD_FILE" + info "Copied Sensor Monitor module to $PVE_SENSOR_INFO_MOD_FILE" - if [[ $DEBUG_REMOTE == true ]]; then - sensorsCmd="cat \"$DEBUG_JSON_FILE\"" + # Convert boolean flags to Perl format (1 or 0) + intel_enabled=$([[ "$ENABLE_INTEL_GPU_INFO" = true ]] && echo 1 || echo 0) + nvidia_enabled=$([[ "$ENABLE_NVIDIA_GPU_INFO" = true ]] && echo 1 || echo 0) + ups_enabled=$([[ "$ENABLE_UPS" = true ]] && echo 1 || echo 0) + sensors_mode=$([[ "$DEBUG_REMOTE" = true ]] && echo 1 || echo 0) + + # Determine UPS device name + local ups_device="${upsConnection:-ups@localhost}" + + # Update configuration in the installed module + sed -i " + # Update GPU configuration + /intel_enabled =>/ s/=> [01],/=> $intel_enabled,/ + /nvidia_enabled =>/ s/=> [01],/=> $nvidia_enabled,/ + + # Update UPS configuration + /enabled =>/ { + /ups => {/,/},/ { + /enabled =>/ s/=> [01],/=> $ups_enabled,/ + } + } + /device_name =>/ s|=> '[^']*',|=> '$ups_device',| + " "$PVE_SENSOR_INFO_MOD_FILE" + + if [[ $? -eq 0 ]]; then + info "Sensor Monitor module configured successfully." else - # Note: sensors -f (Fahrenheit) breaks fan speeds - sensorsCmd="sensors -j 2>/dev/null" + warn "Failed to configure Sensor Monitor module settings." fi +} +#endregion Sensor Monitor Module Installation - # Remember to reflect this in sanitize_sensors_output() - #region sensors heredoc +#region node info insertion +# Main insertion routine +insert_sensor_monitor_into_pve() { + #region PveSensorInfoMod heredoc sed -i '/my \$dinfo = df('\''\/'\'', 1);/i\ - # Collect sensor data from lm-sensors\ - $res->{sensorsOutput} = `'"$sensorsCmd"'`;\ - \ - # Sanitize JSON output to handle common lm-sensors parsing issues\ - # Replace ERROR lines with placeholder values\ - $res->{sensorsOutput} =~ s/ERROR:.+\\s(\\w+):\\s(.+)/\\"$1\\": 0.000,/g;\ - $res->{sensorsOutput} =~ s/ERROR:.+\\s(\\w+)!/\\"$1\\": 0.000,/g;\ - \ - # Remove trailing commas before closing braces\ - $res->{sensorsOutput} =~ s/,\\s*(\})/$1/g;\ - \ - # Replace NaN values with null for valid JSON\ - $res->{sensorsOutput} =~ s/\\bNaN\\b/null/g;\ - \ - # Fix duplicate SODIMM keys by appending temperature sensor number with a space - handle both pretty and one-line JSON\ - # Example: "SODIMM":{"temp3_input":34.0} becomes "SODIMM 3":{"temp3_input":34.0}\ - $res->{sensorsOutput} =~ s/"SODIMM"\\s*:\\s*\\{\\s*"temp(\\d+)_input"/"SODIMM $1": {\\n "temp$1_input"/g;\ - \ - # Fix duplicate fans keys by appending fan number with a space - handle both pretty and one-line JSON\ - # Example: "Processor Fan":{"fan2_input":1000,...} → "Processor Fan 2":{"fan2_input":1000,...}\ - $res->{sensorsOutput} =~ s/"([^"]+)"\\s*:\\s*\{\\s*"fan(\\d+)_input"/"$1 $2": {\\n "fan$2_input"/g;\ - \ - # Format JSON output properly (workaround for lm-sensors >3.6.0 issues)\ - $res->{sensorsOutput} =~ /^(.*)$/s;\ - $res->{sensorsOutput} = `echo \\Q$1\\E | python3 -m json.tool 2>/dev/null || echo \\Q$1\E`;\ + # Collect sensor data from PveMod_SensorInfo\ + # Bad practice to add use here, but cleaner implementation would require several extensive modifications.\ + use PVE::API2::PVEMod_SensorInfo;\ + $res->{PveMod_JsonSensorInfo} = PVE::API2::PVEMod_SensorInfo::get_sensors_info();\ + $res->{PveMod_graphicsInfo} = PVE::API2::PVEMod_SensorInfo::get_pve_mod_version();\ + $res->{PveMod_upsInfo} = PVE::API2::PVEMod_SensorInfo::get_ups_info();\ ' "$NODES_PM_FILE" - #endregion sensors heredoc - info "Sensors' retriever added to \"$output_file\"." -} - -# Collect UPS data -collect_ups_output() { - local output_file="$1" - local ups_cmd - - if [[ $DEBUG_REMOTE == true ]]; then - ups_cmd="cat \"$DEBUG_UPS_FILE\"" - else - ups_cmd="upsc \"$upsConnection\" 2>/dev/null" - fi - - # region ups heredoc - sed -i "/my \$dinfo = df('\/', 1);/i\\ - # Collect UPS status information\\ - sub get_upsc {\\ - my \$cmd = '$ups_cmd';\\ - my \$output = \`\\\$cmd\`;\\ - return \$output;\\ - }\\ - \$res->{upsc} = get_upsc();\\ -" "$NODES_PM_FILE" - # endregion ups heredoc - - info "UPS retriever added to \"$output_file\"." + #endregion PveSensorInfoMod heredoc + info "Sensor data retriever added to \"$NODES_PM_FILE\"." } - # Collect system information -collect_system_info() { +insert_system_info_into_pve() { local output_file="$1" local systemInfoCmd + if [[ $ENABLE_SYSTEM_INFO == false ]]; then + return + fi + systemInfoCmd=$(dmidecode -t "${SYSTEM_INFO_TYPE}" \ | awk -F': ' '/Manufacturer|Product Name|Serial Number/ {print $1": "$2}' \ | awk '{$1=$1};1' \ @@ -564,1000 +628,138 @@ collect_system_info() { #region system info heredoc sed -i "/my \$dinfo = df('\/', 1);/i\\ # Add system information to response\\ - \$res->{systemInfo} = \"$(echo "$systemInfoCmd")\";\\ + \$res->{pveMod_sensorInfo_systemInfo} = \"$(echo "$systemInfoCmd")\";\\ " "$NODES_PM_FILE" #endregion system info heredoc info "System information retriever added to \"$output_file\"." } #endregion node info insertion -#region widget generation functions -# Helper function to insert widget after thermal items -insert_widget_after_thermal() { - local widget_file="$1" - sed -i "/^Ext.define('PVE.node.StatusView',/ { - :a - /items:/!{N;ba;} - :b - /'cpus.*},/!{N;bb;} - r $widget_file - }" "$PVE_MANAGER_LIB_JS_FILE" -} - -# Helper function to generate widget and insert it -generate_and_insert_widget() { - local enable_flag="$1" - local generator_func="$2" - local widget_name="$3" - - if [ "$enable_flag" = true ]; then - local temp_js_file="/tmp/${widget_name}_widget.js" - "$generator_func" "$temp_js_file" - insert_widget_after_thermal "$temp_js_file" - rm "$temp_js_file" - info "Inserted $widget_name widget." - fi -} - -# Function to generate drive header -generate_drive_header() { - if [ "$ENABLE_NVME_TEMP" = true ] || [ "$ENABLE_HDD_TEMP" = true ]; then - local temp_js_file="/tmp/drive_header.js" - #region drive header heredoc - cat > "$temp_js_file" <<'EOF' - { - xtype: 'box', - colspan: 2, - html: gettext('Drive(s)'), - }, -EOF -#endregion drive header heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate drive header code" >&2 - exit 1 - fi - - insert_widget_after_thermal "$temp_js_file" - rm "$temp_js_file" - fi -} - -# Function to expand space and modify StatusView properties -expand_statusview_space() { - msgb "\n=== Expanding StatusView space ===" - - # Apply multiple modifications to the StatusView definition - sed -i "/Ext.define('PVE\.node\.StatusView'/,/\},/ { - s/\(bodyPadding:\) '[^']*'/\1 '20 15 20 15'/ - s/height: [0-9]\+/minHeight: 360,\n\tflex: 1,\n\tcollapsible: true,\n\ttitleCollapse: true/ - s/\(tableAttrs:.*$\)/trAttrs: \{ valign: 'top' \},\n\t\1/ - }" "$PVE_MANAGER_LIB_JS_FILE" - - if [[ $? -ne 0 ]]; then - echo "Error: Failed to expand StatusView space" >&2 - exit 1 +#region UI Module Installation +# Install the UI modification module +install_node_status_view_module() { + # Check if source file exists + if [[ ! -f "$PVE_MOD_JS_SOURCE_FILE" ]]; then + err "Source file not found: $PVE_MOD_JS_SOURCE_FILE" fi - - info "Expanded space in \"$PVE_MANAGER_LIB_JS_FILE\"." -} -# Function to move node summary into its own container -setup_node_summary_container() { - # Move the node summary box into its own container - local temp_js_file="/tmp/summary_container.js" - #region summary container heredoc - cat > "$temp_js_file" <<'EOF' -{ - xtype: 'container', - itemId: 'summarycontainer', - layout: 'column', - minWidth: 700, - defaults: { - minHeight: 350, - padding: 5, - columnWidth: 1, - }, - items: [ - nodeStatus, - ] -}, -EOF -#endregion summary container heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate summary container code" >&2 - exit 1 - fi - - # Insert the new container after finding the nodeStatus and items pattern - sed -i "/^\s*nodeStatus: nodeStatus,/ { - :a - /items: \[/ !{N;ba;} - r $temp_js_file - }" "$PVE_MANAGER_LIB_JS_FILE" - - rm "$temp_js_file" - - # Deactivate the original box instance - sed -i "/^\s*nodeStatus: nodeStatus,/ { - :a - /itemId: 'itemcontainer',/ !{N;ba;} - n; - :b - /nodeStatus,/ !{N;bb;} - s/nodeStatus/\/\/nodeStatus/ - }" "$PVE_MANAGER_LIB_JS_FILE" - - if [[ $? -ne 0 ]]; then - echo "Error: Failed to deactivate original nodeStatus instance" >&2 - exit 1 - fi -} + # Copy the JavaScript module to PVE manager directory + cp "$PVE_MOD_JS_SOURCE_FILE" "$PVE_MOD_JS_TARGET_FILE" || err "Failed to copy $PVE_MOD_JS_SOURCE_FILE to $PVE_MOD_JS_TARGET_FILE" + info "Copied UI module to $PVE_MOD_JS_TARGET_FILE" -# Function to add visual spacing separator after the last widget -add_visual_separator() { - # Check for the presence of items in the reverse order of display - local lastItemId="" - - if [ "$ENABLE_UPS" = true ]; then - lastItemId="upsc" - elif [ "$ENABLE_HDD_TEMP" = true ]; then - lastItemId="thermalHdd" - elif [ "$ENABLE_NVME_TEMP" = true ]; then - lastItemId="thermalNvme" - elif [ "$ENABLE_FAN_SPEED" = true ]; then - lastItemId="speedFan" + # Comment out the original PVE.node.StatusView definition in pvemanagerlib.js + # This allows our custom module to provide the new definition + if grep -q "^Ext.define('PVE.node.StatusView'," "$PVE_MANAGER_LIB_JS_FILE" 2>/dev/null; then + # Find the start of the definition and comment it out until the matching closing brace + sed -i "/^Ext\.define('PVE\.node\.StatusView',/,/^});/s|^|// |" "$PVE_MANAGER_LIB_JS_FILE" + info "Commented out original StatusView definition in pvemanagerlib.js" else - lastItemId="thermalCpu" + warn "Original StatusView definition not found in expected format in pvemanagerlib.js" fi - if [ -n "$lastItemId" ]; then - local temp_js_file="/tmp/visual_separator.js" - - #region visual spacing heredoc - cat > "$temp_js_file" <<'EOF' - { - xtype: 'box', - colspan: 2, - padding: '0 0 20 0', - }, -EOF -#endregion visual spacing heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate visual separator code" >&2 - exit 1 - fi - - # Insert after the specific lastItemId (different pattern than thermal) - sed -i "/^Ext.define('PVE.node.StatusView',/ { - :a; - /^.*{.*'$lastItemId'.*},/!{N;ba;} - r $temp_js_file - }" "$PVE_MANAGER_LIB_JS_FILE" - - rm "$temp_js_file" + # Comment out the original PVE.node.Summary definition in pvemanagerlib.js + if grep -q "^Ext.define('PVE.node.Summary'," "$PVE_MANAGER_LIB_JS_FILE" 2>/dev/null; then + sed -i "/^Ext\.define('PVE\.node\.Summary',/,/^});/s|^|// |" "$PVE_MANAGER_LIB_JS_FILE" + info "Commented out original Summary definition in pvemanagerlib.js" + else + warn "Original Summary definition not found in expected format in pvemanagerlib.js" fi -} -# Function to generate system info widget -generate_system_info() { - #region system info heredoc - cat > "$1" <<'EOF' - { - itemId: 'sysinfo', - colspan: 2, - printBar: false, - title: gettext('System Information'), - textField: 'systemInfo', - renderer: function(value){ - return value; - } - }, -EOF - #endregion system info heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate system info code" >&2 - exit 1 + # Add a dynamic script loader to load our custom module + # Insert before the commented-out Ext.define to load it + if ! grep -q "PveMod_PveNodeStatusView.js" "$PVE_MANAGER_LIB_JS_FILE" 2>/dev/null; then + # Use ExtJS Loader to dynamically load our custom module + sed -i "/^\/\/ Ext\.define('PVE\.node\.StatusView',/i\\ +// Load custom PVE.node.StatusView from external module\\ +Ext.Loader.loadScript({\\ + url: '/pve2/js/PveMod_PveNodeStatusView.js',\\ + onLoad: function() { },\\ + onError: function() { console.error('Failed to load PveMod_PveNodeStatusView.js'); }\\ +});\\ +" "$PVE_MANAGER_LIB_JS_FILE" + info "Added dynamic loader for custom UI module in pvemanagerlib.js" + else + info "Custom UI module loader already present in pvemanagerlib.js" fi } - -# Function to generate and insert temperature conversion helper class -generate_and_insert_temp_helper() { - local temp_js_file="/tmp/temp_helper.js" - - msgb "\n=== Inserting temperature helper ===" - - #region temp helper heredoc - cat > "$temp_js_file" <<'EOF' -Ext.define('PVE.mod.TempHelper', { - //singleton: true, - - requires: ['Ext.util.Format'], - - statics: { - CELSIUS: 0, - FAHRENHEIT: 1 - }, - - srcUnit: null, - dstUnit: null, - - isValidUnit: function (unit) { - return ( - Ext.isNumber(unit) && (unit === this.self.CELSIUS || unit === this.self.FAHRENHEIT) - ); - }, - - constructor: function (config) { - this.srcUnit = config && this.isValidUnit(config.srcUnit) ? config.srcUnit : this.self.CELSIUS; - this.dstUnit = config && this.isValidUnit(config.dstUnit) ? config.dstUnit : this.self.CELSIUS; - }, - - toFahrenheit: function (tempCelsius) { - return Ext.isNumber(tempCelsius) - ? tempCelsius * 9 / 5 + 32 - : NaN; - }, - - toCelsius: function (tempFahrenheit) { - return Ext.isNumber(tempFahrenheit) - ? (tempFahrenheit - 32) * 5 / 9 - : NaN; - }, - - getTemp: function (value) { - if (this.srcUnit !== this.dstUnit) { - switch (this.srcUnit) { - case this.self.CELSIUS: - switch (this.dstUnit) { - case this.self.FAHRENHEIT: - return this.toFahrenheit(value); - - default: - Ext.raise({ - msg: - 'Unsupported destination temperature unit: ' + this.dstUnit, - }); - } - case this.self.FAHRENHEIT: - switch (this.dstUnit) { - case this.self.CELSIUS: - return this.toCelsius(value); - - default: - Ext.raise({ - msg: - 'Unsupported destination temperature unit: ' + this.dstUnit, - }); - } - default: - Ext.raise({ - msg: 'Unsupported source temperature unit: ' + this.srcUnit, - }); - } - } else { - return value; - } - }, - - getUnit: function(plainText) { - switch (this.dstUnit) { - case this.self.CELSIUS: - return plainText !== true ? '°C' : '\'C'; - - case this.self.FAHRENHEIT: - return plainText !== true ? '°F' : '\'F'; - - default: - Ext.raise({ - msg: 'Unsupported destination temperature unit: ' + this.srcUnit, - }); - } - }, +#endregion UI Module Installation + + +#region historical GPU data +# Install GPU historical data storage: RRD directory + API endpoint in Nodes.pm +install_gpu_history() { + msgb "\n=== Installing GPU historical data support ===" + + # Create and configure the RRD directory + mkdir -p "$GPU_RRD_DIR" || err "Failed to create GPU RRD directory: $GPU_RRD_DIR" + chown www-data:www-data "$GPU_RRD_DIR" || err "Failed to set ownership on: $GPU_RRD_DIR" + info "GPU RRD directory ready: $GPU_RRD_DIR" + + # Register gpurrddata in the node method list + sed -i "s/{ name => 'rrddata' },/{ name => 'rrddata' },\n { name => 'gpurrddata' },/" "$NODES_PM_FILE" \ + || err "Failed to register gpurrddata method in $NODES_PM_FILE" + + # Insert the gpurrddata API method definition after the rrddata method + sed -i "/\"pve-node-9\.0\/\$param->{node}\", \$param->{timeframe}, \$param->{cf},/{ +n +n +a\\ +\\ +__PACKAGE__->register_method({\\ + name => 'gpurrddata',\\ + path => 'gpurrddata',\\ + method => 'GET',\\ + protected => 1,\\ + proxyto => 'node',\\ + permissions => {\\ + check => ['perm', '/nodes/{node}', ['Sys.Audit']],\\ + },\\ + description => \"Read GPU RRD statistics\",\\ + parameters => {\\ + additionalProperties => 0,\\ + properties => {\\ + node => get_standard_option('pve-node'),\\ + card => {\\ + description => \"The GPU card identifier (e.g. card0, nvidia0).\",\\ + type => 'string',\\ + pattern => '[a-zA-Z0-9]+',\\ + },\\ + timeframe => {\\ + description => \"Specify the time frame you are interested in.\",\\ + type => 'string',\\ + enum => ['hour', 'day', 'week', 'month', 'year', 'decade'],\\ + },\\ + cf => {\\ + description => \"The RRD consolidation function\",\\ + type => 'string',\\ + enum => ['AVERAGE', 'MAX'],\\ + optional => 1,\\ + },\\ + },\\ + },\\ + returns => {\\ + type => \"array\",\\ + items => {\\ + type => \"object\",\\ + properties => {},\\ + },\\ + },\\ + code => sub {\\ + my (\$param) = \@_;\\ + my \$nodename = PVE::INotify::nodename();\\ + my \$card = \$param->{card};\\ + die \"invalid card name\\n\" unless \$card =~ /^[a-zA-Z0-9]+\$/;\\ + return PVE::RRD::create_rrd_data(\\ + \"pve-mod-gpu/\$nodename/\$card\", \$param->{timeframe}, \$param->{cf},\\ + );\\ + },\\ }); -EOF - #endregion temp helper heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate temp helper code" >&2 - exit 1 - fi - - sed -i "/^Ext.define('PVE.node.StatusView'/e cat /tmp/temp_helper.js" "$PVE_MANAGER_LIB_JS_FILE" - rm "$temp_js_file" +}" "$NODES_PM_FILE" \ + || err "Failed to insert gpurrddata method in $NODES_PM_FILE" - info "Temperature helper inserted successfully." + info "GPU historical data API endpoint added to $NODES_PM_FILE" } - -# Function to generate CPU widget -generate_cpu_widget() { - #region cpu widget heredoc - # use subshell to allow variable expansion - ( - export CPU_ITEMS_PER_ROW - export CPU_TEMP_TARGET - export HELPERCTORPARAMS - - cat <<'EOF' | envsubst '$CPU_ITEMS_PER_ROW $CPU_TEMP_TARGET $HELPERCTORPARAMS' > "$1" - { - itemId: 'thermalCpu', - colspan: 2, - printBar: false, - title: gettext('CPU Thermal State'), - iconCls: 'fa fa-fw fa-thermometer-half', - textField: 'sensorsOutput', - renderer: function(value){ - // sensors configuration - const cpuTempHelper = Ext.create('PVE.mod.TempHelper', $HELPERCTORPARAMS); - // display configuration - const itemsPerRow = $CPU_ITEMS_PER_ROW; - // --- - let objValue; - try { - objValue = JSON.parse(value) || {}; - } catch(e) { - objValue = {}; - } - const cpuKeysI = Object.keys(objValue).filter(item => String(item).startsWith('coretemp-isa-')).sort(); - const cpuKeysA = Object.keys(objValue).filter(item => String(item).startsWith('k10temp-pci-')).sort(); - const bINTEL = cpuKeysI.length > 0 ? true : false; - const INTELPackagePrefix = '$CPU_TEMP_TARGET' == 'Core' ? 'Core ' : 'Package id'; - const INTELPackageCaption = '$CPU_TEMP_TARGET' == 'Core' ? 'Core' : 'Package'; - let AMDPackagePrefix = 'Tccd'; - let AMDPackageCaption = 'CCD'; - - if (cpuKeysA.length > 0) { - let bTccd = false; - let bTctl = false; - let bTdie = false; - let bCpuCoreTemp = false; - cpuKeysA.forEach((cpuKey, cpuIndex) => { - let items = objValue[cpuKey]; - bTccd = Object.keys(items).findIndex(item => { return String(item).startsWith('Tccd'); }) >= 0; - bTctl = Object.keys(items).findIndex(item => { return String(item).startsWith('Tctl'); }) >= 0; - bTdie = Object.keys(items).findIndex(item => { return String(item).startsWith('Tdie'); }) >= 0; - bCpuCoreTemp = Object.keys(items).findIndex(item => { return String(item) === 'CPU Core Temp'; }) >= 0; - }); - if (bTccd && '$CPU_TEMP_TARGET' == 'Core') { - AMDPackagePrefix = 'Tccd'; - AMDPackageCaption = 'ccd'; - } else if (bCpuCoreTemp && '$CPU_TEMP_TARGET' == 'Package') { - AMDPackagePrefix = 'CPU Core Temp'; - AMDPackageCaption = 'CPU Core Temp'; - } else if (bTdie) { - AMDPackagePrefix = 'Tdie'; - AMDPackageCaption = 'die'; - } else if (bTctl) { - AMDPackagePrefix = 'Tctl'; - AMDPackageCaption = 'ctl'; - } else { - AMDPackagePrefix = 'temp'; - AMDPackageCaption = 'Temp'; - } - } - - const cpuKeys = bINTEL ? cpuKeysI : cpuKeysA; - const cpuItemPrefix = bINTEL ? INTELPackagePrefix : AMDPackagePrefix; - const cpuTempCaption = bINTEL ? INTELPackageCaption : AMDPackageCaption; - const formatTemp = bINTEL ? '0' : '0.0'; - const cpuCount = cpuKeys.length; - let temps = []; - - cpuKeys.forEach((cpuKey, cpuIndex) => { - let cpuTemps = []; - const items = objValue[cpuKey]; - const itemKeys = Object.keys(items).filter(item => { - if ('$CPU_TEMP_TARGET' == 'Core') { - // In Core mode: only show individual cores/CCDs, exclude overall CPU temp - return String(item).includes(cpuItemPrefix) || String(item).startsWith('Tccd'); - } else { - // In Package mode: show overall CPU temp and package-level readings - return String(item).includes(cpuItemPrefix) || String(item) === 'CPU Core Temp'; - } - }); - - itemKeys.forEach((coreKey) => { - try { - let tempVal = NaN, tempMax = NaN, tempCrit = NaN; - Object.keys(items[coreKey]).forEach((secondLevelKey) => { - if (secondLevelKey.endsWith('_input')) { - tempVal = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); - } else if (secondLevelKey.endsWith('_max')) { - tempMax = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); - } else if (secondLevelKey.endsWith('_crit')) { - tempCrit = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); - } - }); - - if (!isNaN(tempVal)) { - let tempStyle = ''; - if (!isNaN(tempMax) && tempVal >= tempMax) { - tempStyle = 'color: #FFC300; font-weight: bold;'; - } - if (!isNaN(tempCrit) && tempVal >= tempCrit) { - tempStyle = 'color: red; font-weight: bold;'; - } - - let tempStr = ''; - - // Enhanced parsing for AMD temperatures - if (coreKey.startsWith('Tccd')) { - let tempIndex = coreKey.match(/Tccd(\d+)/); - if (tempIndex !== null && tempIndex.length > 1) { - tempIndex = tempIndex[1]; - tempStr = `${cpuTempCaption} ${tempIndex}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } else { - tempStr = `${cpuTempCaption}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } - } - // Handle CPU Core Temp (single overall temperature) - else if (coreKey === 'CPU Core Temp') { - tempStr = `${cpuTempCaption}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } - // Enhanced parsing for Intel cores (P-Core, E-Core, regular Core) - else { - let tempIndex = coreKey.match(/(?:P\s+Core|E\s+Core|Core)\s*(\d+)/); - if (tempIndex !== null && tempIndex.length > 1) { - tempIndex = tempIndex[1]; - let coreType = coreKey.startsWith('P Core') ? 'P Core' : - coreKey.startsWith('E Core') ? 'E Core' : - cpuTempCaption; - tempStr = `${coreType} ${tempIndex}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } else { - // fallback for CPUs which do not have a core index - let coreType = coreKey.startsWith('P Core') ? 'P Core' : - coreKey.startsWith('E Core') ? 'E Core' : - cpuTempCaption; - tempStr = `${coreType}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } - } - - cpuTemps.push(tempStr); - } - } catch (e) { /*_*/ } - }); - - if(cpuTemps.length > 0) { - temps.push(cpuTemps); - } - }); - - let result = ''; - temps.forEach((cpuTemps, cpuIndex) => { - const strCoreTemps = cpuTemps.map((strTemp, index, arr) => { - return strTemp + (index + 1 < arr.length ? (itemsPerRow > 0 && (index + 1) % itemsPerRow === 0 ? '
' : ' | ') : ''); - }) - if(strCoreTemps.length > 0) { - result += (cpuCount > 1 ? `CPU ${cpuIndex+1}: ` : '') + strCoreTemps.join('') + (cpuIndex < cpuCount ? '
' : ''); - } - }); - - return '
' + (result.length > 0 ? result : 'N/A') + '
'; - } - }, -EOF - ) - #endregion cpu widget heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate cpu widget code" >&2 - exit 1 - fi -} - -# Function to generate nvme widget -generate_nvme_widget() { - #region nvme widget heredoc - # use subshell to allow variable expansion - ( - export HELPERCTORPARAMS - export NVME_ITEMS_PER_ROW - cat <<'EOF' | envsubst '$HELPERCTORPARAMS $NVME_ITEMS_PER_ROW' > "$1" - { - itemId: 'thermalNvme', - colspan: 2, - printBar: false, - title: gettext('NVMe Thermal State'), - iconCls: 'fa fa-fw fa-thermometer-half', - textField: 'sensorsOutput', - renderer: function(value) { - // sensors configuration - const addressPrefix = "nvme-pci-"; - const sensorName = "Composite"; - const tempHelper = Ext.create('PVE.mod.TempHelper', $HELPERCTORPARAMS); - // display configuration - const itemsPerRow = $NVME_ITEMS_PER_ROW; - // --- - let objValue; - try { - objValue = JSON.parse(value) || {}; - } catch(e) { - objValue = {}; - } - const nvmeKeys = Object.keys(objValue).filter(item => String(item).startsWith(addressPrefix)).sort(); - let temps = []; - nvmeKeys.forEach((nvmeKey, index) => { - try { - let tempVal = NaN, tempMax = NaN, tempCrit = NaN; - Object.keys(objValue[nvmeKey][sensorName]).forEach((secondLevelKey) => { - if (secondLevelKey.endsWith('_input')) { - tempVal = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); - } else if (secondLevelKey.endsWith('_max')) { - tempMax = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); - } else if (secondLevelKey.endsWith('_crit')) { - tempCrit = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); - } - }); - if (!isNaN(tempVal)) { - let tempStyle = ''; - if (!isNaN(tempMax) && tempVal >= tempMax) { - tempStyle = 'color: #FFC300; font-weight: bold;'; - } - if (!isNaN(tempCrit) && tempVal >= tempCrit) { - tempStyle = 'color: red; font-weight: bold;'; - } - const tempStr = `Drive ${index + 1}: ${Ext.util.Format.number(tempVal, '0.0')}${tempHelper.getUnit()}`; - temps.push(tempStr); - } - } catch(e) { /*_*/ } - }); - const result = temps.map((strTemp, index, arr) => { return strTemp + (index + 1 < arr.length ? ((index + 1) % itemsPerRow === 0 ? '
' : ' | ') : ''); }); - return '
' + (result.length > 0 ? result.join('') : 'N/A') + '
'; - } - }, -EOF - ) - #endregion nvme widget heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate nvme widget code" >&2 - exit 1 - fi -} - -# Function to generate Fan widget -generate_fan_widget() { - #region fan widget heredoc - # use subshell to allow variable expansion - ( - export DISPLAY_ZERO_SPEED_FANS - cat <<'EOF' | envsubst '$DISPLAY_ZERO_SPEED_FANS' > "$1" - { - xtype: 'box', - colspan: 2, - html: gettext('Cooling'), - }, - { - itemId: 'speedFan', - colspan: 2, - printBar: false, - title: gettext('Fan Speed(s)'), - iconCls: 'fa fa-fw fa-snowflake-o', - textField: 'sensorsOutput', - renderer: function(value) { - // --- - let objValue; - try { - objValue = JSON.parse(value) || {}; - } catch(e) { - objValue = {}; - } - - // Recursive function to find fan keys and values - function findFanKeys(obj, fanKeys, parentKey = null) { - Object.keys(obj).forEach(key => { - const value = obj[key]; - if (typeof value === 'object' && value !== null) { - // If the value is an object, recursively call the function - findFanKeys(value, fanKeys, key); - } else if (/^fan[0-9]+(_input)?$/.test(key)) { - if ($DISPLAY_ZERO_SPEED_FANS != true && value === 0) { - // Skip this fan if DISPLAY_ZERO_SPEED_FANS is false and value is 0 - return; - } - // If the key matches the pattern, add the parent key and value to the fanKeys array - fanKeys.push({ key: parentKey, value: value }); - } - }); - } - - let speeds = []; - // Loop through the parent keys - Object.keys(objValue).forEach(parentKey => { - const parentObj = objValue[parentKey]; - // Array to store fan keys and values - const fanKeys = []; - // Call the recursive function to find fan keys and values - findFanKeys(parentObj, fanKeys); - // Sort the fan keys - fanKeys.sort(); - // Process each fan key and value - fanKeys.forEach(({ key: fanKey, value: fanSpeed }) => { - try { - const fan = fanKey.charAt(0).toUpperCase() + fanKey.slice(1); // Capitalize the first letter of fanKey - speeds.push(`${fan}: ${fanSpeed} RPM`); - } catch(e) { - console.error(`Error retrieving fan speed for ${fanKey} in ${parentKey}:`, e); // Debug: Log specific error - } - }); - }); - return '
' + (speeds.length > 0 ? speeds.join(' | ') : 'N/A') + '
'; - } - }, -EOF - ) - #endregion fan widget heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate fan widget code" >&2 - exit 1 - fi -} - -# Function to generate UPS widget -generate_hdd_widget() { - #region hdd widget heredoc - # use subshell to allow variable expansion - ( - export HELPERCTORPARAMS - export HDD_ITEMS_PER_ROW - cat <<'EOF' | envsubst '$HDD_ITEMS_PER_ROW $HELPERCTORPARAMS' > "$1" - { - itemId: 'thermalHdd', - colspan: 2, - printBar: false, - title: gettext('HDD/SSD Thermal State'), - iconCls: 'fa fa-fw fa-thermometer-half', - textField: 'sensorsOutput', - renderer: function(value) { - // sensors configuration - const addressPrefix = "drivetemp-scsi-"; - const sensorName = "temp1"; - const tempHelper = Ext.create('PVE.mod.TempHelper', $HELPERCTORPARAMS); - // display configuration - const itemsPerRow = $HDD_ITEMS_PER_ROW; - // --- - let objValue; - try { - objValue = JSON.parse(value) || {}; - } catch(e) { - objValue = {}; - } - const drvKeys = Object.keys(objValue).filter(item => String(item).startsWith(addressPrefix)).sort(); - let temps = []; - drvKeys.forEach((drvKey, index) => { - try { - let tempVal = NaN, tempMax = NaN, tempCrit = NaN; - Object.keys(objValue[drvKey][sensorName]).forEach((secondLevelKey) => { - if (secondLevelKey.endsWith('_input')) { - tempVal = tempHelper.getTemp(parseFloat(objValue[drvKey][sensorName][secondLevelKey])); - } else if (secondLevelKey.endsWith('_max')) { - tempMax = tempHelper.getTemp(parseFloat(objValue[drvKey][sensorName][secondLevelKey])); - } else if (secondLevelKey.endsWith('_crit')) { - tempCrit = tempHelper.getTemp(parseFloat(objValue[drvKey][sensorName][secondLevelKey])); - } - }); - if (!isNaN(tempVal)) { - let tempStyle = ''; - if (!isNaN(tempMax) && tempVal >= tempMax) { - tempStyle = 'color: #FFC300; font-weight: bold;'; - } - if (!isNaN(tempCrit) && tempVal >= tempCrit) { - tempStyle = 'color: red; font-weight: bold;'; - } - const tempStr = `Drive ${index + 1}: ${Ext.util.Format.number(tempVal, '0.0')}${tempHelper.getUnit()}`; - temps.push(tempStr); - } - } catch(e) { /*_*/ } - }); - const result = temps.map((strTemp, index, arr) => { return strTemp + (index + 1 < arr.length ? ((index + 1) % itemsPerRow === 0 ? '
' : ' | ') : ''); }); - return '
' + (result.length > 0 ? result.join('') : 'N/A') + '
'; - } - }, -EOF - ) - #endregion hdd widget heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate hhd widget code" >&2 - exit 1 - fi -} - -# Function to generate RAM widget -generate_ram_widget() { - #region ram widget heredoc - # use subshell to allow variable expansion - ( - export HELPERCTORPARAMS - cat <<'EOF' | envsubst '$HELPERCTORPARAMS' > "$1" - { - xtype: 'box', - colspan: 2, - html: gettext('RAM'), - }, - { - itemId: 'thermalRam', - colspan: 2, - printBar: false, - title: gettext('Thermal State'), - iconCls: 'fa fa-fw fa-thermometer-half', - textField: 'sensorsOutput', - renderer: function(value) { - const cpuTempHelper = Ext.create('PVE.mod.TempHelper', $HELPERCTORPARAMS); - - let objValue; - try { - objValue = JSON.parse(value) || {}; - } catch(e) { - objValue = {}; - } - - // Recursive function to find ram keys and values - function findRamKeys(obj, ramKeys, parentKey = null) { - Object.keys(obj).forEach(key => { - const value = obj[key]; - if (typeof value === 'object' && value !== null) { - // If the value is an object, recursively call the function - findRamKeys(value, ramKeys, key); - } else if (/^temp\d+_input$/.test(key) && parentKey && parentKey.startsWith("SODIMM")) { - if (value !== 0) { - ramKeys.push({ key: parentKey, value: value}); - } - } - }); - } - - let ramTemps = []; - // Loop through the parent keys - Object.keys(objValue).forEach(parentKey => { - const parentObj = objValue[parentKey]; - // Array to store ram keys and values - const ramKeys = []; - // Call the recursive function to find ram keys and values - findRamKeys(parentObj, ramKeys); - // Sort the ramKeys keys - ramKeys.sort(); - // Process each ram key and value - ramKeys.forEach(({ key: ramKey, value: ramTemp }) => { - try { - ramTemps.push(`${ramKey}: ${ramTemp}${cpuTempHelper.getUnit()}`); - } catch(e) { - console.error(`Error retrieving Ram Temp for ${ramTemps} in ${parentKey}:`, e); // Debug: Log specific error - } - }); - }); - return '
' + (ramTemps.length > 0 ? ramTemps.join(' | ') : 'N/A') + '
'; - } - }, -EOF - ) - #endregion ram widget heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate ram widget code" >&2 - exit 1 - fi -} - -# Function to generate UPS widget -generate_ups_widget() { - #region UPS widget heredoc - cat > "$1" <<'EOF' - { - xtype: 'box', - colspan: 2, - html: gettext('UPS'), - }, - { - itemId: 'upsc', - colspan: 2, - printBar: false, - title: gettext('Device'), - iconCls: 'fa fa-fw fa-battery-three-quarters', - textField: 'upsc', - renderer: function(value) { - let objValue = {}; - try { - // Parse the UPS data - if (typeof value === 'string') { - const lines = value.split('\n'); - lines.forEach(line => { - const colonIndex = line.indexOf(':'); - if (colonIndex > 0) { - const key = line.substring(0, colonIndex).trim(); - const val = line.substring(colonIndex + 1).trim(); - objValue[key] = val; - } - }); - } else if (typeof value === 'object') { - objValue = value || {}; - } - } catch(e) { - objValue = {}; - } - - // If objValue is null or empty, return N/A - if (!objValue || Object.keys(objValue).length === 0) { - return '
N/A
'; - } - - // Helper function to get status color - // Returns a CSS color string for non-default states, or null for default (no inline color) - function getStatusColor(status) { - if (!status) return '#999'; - const statusUpper = status.toUpperCase(); - if (statusUpper.includes('OL')) return null; // default (no explicit color) - if (statusUpper.includes('OB')) return '#d9534f'; // Red for on battery - if (statusUpper.includes('LB')) return '#d9534f'; // Red for low battery - return '#f0ad4e'; // Orange for other states - } - - // Helper function to get load/charge color - // Returns null for default/good values so no inline style is emitted - function getPercentageColor(value, isLoad = false) { - if (!value || isNaN(value)) return '#999'; - const num = parseFloat(value); - if (isLoad) { - if (num >= 80) return '#d9534f'; // Red for high load - if (num >= 60) return '#f0ad4e'; // Orange for medium load - return null; // default (no explicit color) - } else { - // For battery charge - if (num <= 20) return '#d9534f'; // Red for low charge - if (num <= 50) return '#f0ad4e'; // Orange for medium charge - return null; // default (no explicit color) - } - } - - // Helper function to format runtime - function formatRuntime(seconds) { - if (!seconds || isNaN(seconds)) return 'N/A'; - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins}m ${secs}s`; - } - - // Extract key UPS information - const batteryCharge = objValue['battery.charge']; - const batteryRuntime = objValue['battery.runtime']; - const inputVoltage = objValue['input.voltage']; - const upsLoad = objValue['ups.load']; - const upsStatus = objValue['ups.status']; - const upsModel = objValue['ups.model'] || objValue['device.model']; - const testResult = objValue['ups.test.result']; - const batteryChargeLow = objValue['battery.charge.low']; - const batteryRuntimeLow = objValue['battery.runtime.low']; - const upsRealPowerNominal = objValue['ups.realpower.nominal']; - const batteryMfrDate = objValue['battery.mfr.date']; - - // Build the status display - let displayItems = []; - - // First line: Model info (no explicit color for default) - let modelLine = ''; - if (upsModel) { - modelLine = `${upsModel}`; - } else { - modelLine = `N/A`; - } - displayItems.push(modelLine); - - // Main status line with all metrics - let statusLine = ''; - - // Status - if (upsStatus) { - const statusUpper = upsStatus.toUpperCase(); - let statusText = 'Unknown'; - let statusColor = '#f0ad4e'; - - if (statusUpper.includes('OL')) { - statusText = 'Online'; - statusColor = null; // default (no explicit color) - } else if (statusUpper.includes('OB')) { - statusText = 'On Battery'; - statusColor = '#d9534f'; // Red for on battery - } else if (statusUpper.includes('LB')) { - statusText = 'Low Battery'; - statusColor = '#d9534f'; // Red for low battery - } else { - statusText = upsStatus; - statusColor = '#f0ad4e'; // Orange for unknown status - } - - let statusStyle = statusColor ? ('color: ' + statusColor + ';') : ''; - statusLine += 'Status: ' + statusText + ''; - } else { - statusLine += 'Status: N/A'; - } - - // Battery charge - if (statusLine) statusLine += ' | '; - if (batteryCharge) { - const chargeColor = getPercentageColor(batteryCharge, false); - let chargeStyle = chargeColor ? ('color: ' + chargeColor + ';') : ''; - statusLine += 'Battery: ' + batteryCharge + '%'; - } else { - statusLine += 'Battery: N/A'; - } - - // Load percentage - if (statusLine) statusLine += ' | '; - if (upsLoad) { - const loadColor = getPercentageColor(upsLoad, true); - let loadStyle = loadColor ? ('color: ' + loadColor + ';') : ''; - statusLine += 'Load: ' + upsLoad + '%'; - } else { - statusLine += 'Load: N/A'; - } - - // Runtime - if (statusLine) statusLine += ' | '; - if (batteryRuntime) { - const runtime = parseInt(batteryRuntime); - const runtimeLowThreshold = batteryRuntimeLow ? parseInt(batteryRuntimeLow) : 600; - let runtimeColor = null; - if (runtime <= runtimeLowThreshold / 2) runtimeColor = '#d9534f'; // Red if less than half of low threshold - else if (runtime <= runtimeLowThreshold) runtimeColor = '#f0ad4e'; // Orange if at low threshold - let runtimeStyle = runtimeColor ? ('color: ' + runtimeColor + ';') : ''; - statusLine += 'Runtime: ' + formatRuntime(runtime) + ''; - } else { - statusLine += 'Runtime: N/A'; - } - - // Input voltage - if (statusLine) statusLine += ' | '; - if (inputVoltage) { - statusLine += 'Input: ' + parseFloat(inputVoltage).toFixed(0) + 'V'; - } else { - statusLine += 'Input: N/A'; - } - - // Calculate actual watt usage - if (statusLine) statusLine += ' | '; - let actualWattage = null; - if (upsLoad && upsRealPowerNominal) { - const load = parseFloat(upsLoad); - const nominal = parseFloat(upsRealPowerNominal); - if (!isNaN(load) && !isNaN(nominal)) { - actualWattage = Math.round((load / 100) * nominal); - } - } - - // Real power (calculated watt usage) - if (actualWattage !== null) { - statusLine += 'Output: ' + actualWattage + 'W'; - } else { - statusLine += 'Output: N/A'; - } - - displayItems.push(statusLine); - - // Combined battery and test line - let batteryTestLine = ''; - if (batteryMfrDate) { - batteryTestLine += 'Battery MFD: ' + batteryMfrDate + ''; - } else { - batteryTestLine += 'Battery MFD: N/A'; - } - - if (testResult && !testResult.toLowerCase().includes('no test')) { - const testColor = testResult.toLowerCase().includes('passed') ? null : '#d9534f'; - let testStyle = testColor ? ('color: ' + testColor + ';') : ''; - batteryTestLine += ' | Test: ' + testResult + ''; - } else { - batteryTestLine += ' | Test: N/A'; - } - - displayItems.push(batteryTestLine); - - // Format the final output - return '
' + displayItems.join('
') + '
'; - } - }, -EOF - #endregion UPS widget heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate UPS widget code" >&2 - exit 1 - fi -} - -#endregion widget generation functions +#endregion historical GPU data # Function to uninstall the modification function uninstall_mod { @@ -1565,7 +767,7 @@ function uninstall_mod { check_root_privileges - if [[ -z $(grep -e "$res->{sensorsOutput}" "$NODES_PM_FILE") ]] && [[ -z $(grep -e "$res->{systemInfo}" "$NODES_PM_FILE") ]]; then + if [[ -z $(grep -e "\$res->{PveMod_SensorInfo_JSON}" "$NODES_PM_FILE") ]] && [[ -z $(grep -e "\$res->{systemInfo}" "$NODES_PM_FILE") ]]; then err "Mod is not installed." fi @@ -1576,7 +778,7 @@ function uninstall_mod { local latest_nodes_pm=$(find "$BACKUP_DIR" -name "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 - # Remove the latest Nodes.pm file + # Restore the latest Nodes.pm file msgb "Restoring latest Nodes.pm from backup: $latest_nodes_pm to \"$NODES_PM_FILE\"." cp "$latest_nodes_pm" "$NODES_PM_FILE" info "Restored Nodes.pm successfully." @@ -1584,11 +786,11 @@ function uninstall_mod { warn "No Nodes.pm backup files found." fi - # Find the latest pvemanagerlib.js file using the find command + # Restore original pvemanagerlib.js (uncomment the StatusView definition and remove loader) local latest_pvemanagerlibjs=$(find "$BACKUP_DIR" -name "pvemanagerlib.js.*" -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n 1 | awk '{print $2}') if [ -n "$latest_pvemanagerlibjs" ]; then - # Remove the latest pvemanagerlib.js file + # Restore the latest pvemanagerlib.js file msgb "Restoring latest pvemanagerlib.js from backup: $latest_pvemanagerlibjs to \"$PVE_MANAGER_LIB_JS_FILE\"." cp "$latest_pvemanagerlibjs" "$PVE_MANAGER_LIB_JS_FILE" info "Restored pvemanagerlib.js successfully." @@ -1596,8 +798,41 @@ function uninstall_mod { warn "No pvemanagerlib.js backup files found." fi - if [ -n "$latest_nodes_pm" ] || [ -n "$latest_pvemanagerlibjs" ]; then - # At least one of the variables is not empty, restart the proxy + # Remove UI module files + if [ -f "$PVE_MOD_JS_TARGET_FILE" ]; then + msgb "Removing UI module: $PVE_MOD_JS_TARGET_FILE" + rm "$PVE_MOD_JS_TARGET_FILE" + info "Removed UI module successfully." + else + warn "UI module file not found: $PVE_MOD_JS_TARGET_FILE" + fi + + # Remove Sensor Info Perl module + local latest_sensor_info_pm=$(find "$BACKUP_DIR" -name "PveMod_SensorInfo.pm.*" -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n 1 | awk '{print $2}') + + if [ -n "$latest_sensor_info_pm" ]; then + # Restore the latest PveMod_SensorInfo.pm file (if there's a backup) + msgb "Restoring latest PveMod_SensorInfo.pm from backup: $latest_sensor_info_pm to \"$PVE_SENSOR_INFO_MOD_FILE\"." + cp "$latest_sensor_info_pm" "$PVE_SENSOR_INFO_MOD_FILE" + info "Restored PveMod_SensorInfo.pm successfully." + elif [ -f "$PVE_SENSOR_INFO_MOD_FILE" ]; then + # No backup found but file exists, remove it + msgb "No PveMod_SensorInfo.pm backup found. Removing installed module: $PVE_SENSOR_INFO_MOD_FILE" + rm "$PVE_SENSOR_INFO_MOD_FILE" + info "Removed PveMod_SensorInfo.pm successfully." + else + warn "No PveMod_SensorInfo.pm backup files found and module not installed." + fi + + # Remove GPU RRD directory (contains historical GPU data — destroyed permanently on uninstall) + if [[ -d "$GPU_RRD_DIR" ]]; then + msgb "Removing GPU RRD directory: $GPU_RRD_DIR" + rm -rf "$GPU_RRD_DIR" + info "Removed GPU RRD directory." + fi + + if [ -n "$latest_nodes_pm" ] || [ -n "$latest_pvemanagerlibjs" ] || [ -f "$PVE_MOD_JS_TARGET_FILE" ] || [ -n "$latest_sensor_info_pm" ] || [ -f "$PVE_SENSOR_INFO_MOD_FILE" ]; then + # At least one file was modified, restart the proxy restart_proxy fi @@ -1606,9 +841,11 @@ function uninstall_mod { # Function to check if the modification is installed check_mod_installation() { - if [[ -n $(grep -F '$res->{sensorsOutput}' "$NODES_PM_FILE") ]] && \ - [[ -n $(grep -F '$res->{systemInfo}' "$NODES_PM_FILE") ]] && \ - [[ -n $(grep -E "itemId: 'thermal[[:alnum:]]*'" "$PVE_MANAGER_LIB_JS_FILE") ]]; then + if [[ -n $(grep -F 'use PVE::API2::PveMod_SensorInfo' "$NODES_PM_FILE") ]] || \ + [[ -n $(grep -F 'use PVE::API2::PVEMod_SensorInfo' "$NODES_PM_FILE") ]] || \ + [[ -n $(grep -F '$res->{sensorsJSONOutput}' "$NODES_PM_FILE") ]] || \ + [[ -n $(grep -F '$res->{systemInfo}' "$NODES_PM_FILE") ]] || \ + [[ -f "$PVE_MOD_JS_TARGET_FILE" ]]; then err "Mod is already installed. Uninstall existing before installing." fi } @@ -1718,6 +955,11 @@ function perform_backup { create_backup_directory create_file_backup "$NODES_PM_FILE" "$timestamp" create_file_backup "$PVE_MANAGER_LIB_JS_FILE" "$timestamp" + + # Backup Sensor Info module if it exists + if [[ -f "$PVE_SENSOR_INFO_MOD_FILE" ]]; then + create_file_backup "$PVE_SENSOR_INFO_MOD_FILE" "$timestamp" + fi } # Process the arguments using a while loop and a case statement