diff --git a/src/PVENodeInfo/PVEMod_Collectors/Amd.pm b/src/PVENodeInfo/PVEMod_Collectors/Amd.pm new file mode 100644 index 0000000..7cacf04 --- /dev/null +++ b/src/PVENodeInfo/PVEMod_Collectors/Amd.pm @@ -0,0 +1,33 @@ +package PVE::PVEMod::Collector::Amd; + +use strict; +use warnings; +use Exporter 'import'; + +use PVE::PVEMod::Config qw($process_type); +use PVE::PVEMod::Utils qw(debug); + +our @EXPORT_OK = qw( + get_amd_gpu_devices + collector_for_amd_device +); + +# ============================================================================ +# AMD GPU — placeholders (not yet implemented) +# ============================================================================ + +sub get_amd_gpu_devices { + # TODO: Implement AMD GPU detection using rocminfo or rocm-smi + debug(__LINE__, "AMD GPU support not yet implemented"); + return (); +} + +sub collector_for_amd_device { + my ($device) = @_; + $process_type = 'collector'; + # TODO: Implement AMD GPU collector + debug(__LINE__, "AMD GPU collector not yet implemented"); + exit 0; +} + +1; diff --git a/src/PVENodeInfo/PVEMod_Collectors/Intel.pm b/src/PVENodeInfo/PVEMod_Collectors/Intel.pm new file mode 100644 index 0000000..988ec60 --- /dev/null +++ b/src/PVENodeInfo/PVEMod_Collectors/Intel.pm @@ -0,0 +1,179 @@ +package PVE::PVEMod::Collector::Intel; + +use strict; +use warnings; +use Exporter 'import'; + +use PVE::PVEMod::Config qw(%config $process_type $pve_mod_working_dir); +use PVE::PVEMod::Utils qw(debug check_executable setup_collector_signals safe_write_json); +use PVE::PVEMod::Store qw(update_intel_gpu_rrd); + +our @EXPORT_OK = qw( + get_intel_gpu_devices + collector_for_intel_device +); + +# ============================================================================ +# Intel GPU — device discovery +# ============================================================================ + +sub get_intel_gpu_devices { + my @devices = (); + + 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, $name, $path) = ($1, $2, $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; +} + +# ============================================================================ +# Intel GPU — data parsing +# ============================================================================ + +sub _parse_intel_gpu_line { + my ($line) = @_; + + # Expected format (whitespace-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 + + $line =~ s/^\s+|\s+$//g; + my @values = grep { $_ ne '' } split(/\s+/, $line); + + return unless @values >= 18; + + return { + 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 => {}, + }; +} + +# ============================================================================ +# Intel GPU — long-running collector +# ============================================================================ + +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; + 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"); + + 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; + }); + + debug(__LINE__, "About to open pipe to intel_gpu_top"); + my $intel_pull_interval = $config{intervals}{data_pull} * 1000; # 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 $node_name = "node0"; + + while (my $line = <$fh>) { + last if $shutdown; + chomp $line; + + next if $line =~ /MHz|IRQ|RC6|Power|RCS|BCS|VCS|VECS|req\s+act|^\s*$/; + + if ($line =~ /^\s*[\d\s\.]+$/) { + my $stats = _parse_intel_gpu_line($line); + + if ($stats) { + my $device_data = { + $node_name => { + name => $device->{name}, + device_path => $device->{path}, + drm_path => $device->{drm_path}, + stats => $stats, + } + }; + + 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; +} + +1; diff --git a/src/PVENodeInfo/PVEMod_Collectors/LmSensors.pm b/src/PVENodeInfo/PVEMod_Collectors/LmSensors.pm new file mode 100644 index 0000000..6372918 --- /dev/null +++ b/src/PVENodeInfo/PVEMod_Collectors/LmSensors.pm @@ -0,0 +1,417 @@ +package PVE::PVEMod::Collector::LmSensors; + +use strict; +use warnings; +use Exporter 'import'; + +use JSON; + +use PVE::PVEMod::Config qw(%config $process_type $sensors_state_file); +use PVE::PVEMod::Utils qw(debug check_executable setup_collector_signals read_sysfs); + +our @EXPORT_OK = qw( + collector_for_temperature_sensors +); + +# ============================================================================ +# Temperature Sensors — long-running collector +# ============================================================================ + +sub collector_for_temperature_sensors { + my ($device) = @_; + $process_type = 'collector'; + $0 = "collector-temperature-sensors"; + my %cache; + my $shutdown = 0; + setup_collector_signals('temperature-sensors', \$shutdown); + + while (!$shutdown) { + my $sensors_data = _get_temperature_sensors(\%cache); + + eval { + open my $ofh, '>', $sensors_state_file + or die "Failed to open $sensors_state_file: $!"; + print $ofh $sensors_data; + 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; +} + +# ============================================================================ +# Temperature Sensors — pipeline +# ============================================================================ + +sub _get_temperature_sensors { + my ($cache_ref) = @_; + + my $sensors_output; + + if ($config{debug}{lm_sensors_mode} && -f $config{debug}{lm_sensors_output_file}) { + debug(__LINE__, "Debug mode: reading lm-sensors data from $config{debug}{lm_sensors_output_file}"); + if (open my $fh, '<', $config{debug}{lm_sensors_output_file}) { + local $/; + $sensors_output = <$fh>; + close $fh; + debug(__LINE__, "Read lm-sensors data from debug file, length: " + . length($sensors_output) . " bytes"); + } else { + debug(__LINE__, "Failed to open debug file $config{debug}{lm_sensors_output_file}: $!"); + $sensors_output = '{}'; + } + } else { + $sensors_output = `sensors -j 2>/dev/null | python3 -m json.tool`; + debug(__LINE__, "Raw lm-sensors output collected from command"); + } + + debug(__LINE__, "Raw lm-sensors output collected"); + + my $data = _sanitize_sensors($sensors_output); + debug(__LINE__, "Sanitized lm-sensors output"); + + $data = _get_drive_names($data, $cache_ref); + debug(__LINE__, "Translated drive names in lm-sensors output"); + + $data = _get_cpu_name($data, $cache_ref); + debug(__LINE__, "Translated CPU names in lm-sensors output"); + + # Wrap in top-level key + my $sensors_json; + eval { $sensors_json = decode_json($data); }; + if ($@) { + debug(__LINE__, "Failed to parse final lm-sensors JSON: $@"); + return $data; + } + + $data = JSON->new->pretty->encode({ "PVE MOD lm-sensors Enhanced" => $sensors_json }); + + return $data; +} + +# ============================================================================ +# Sanitize raw lm-sensors JSON +# ============================================================================ + +sub _sanitize_sensors { + my ($sensors_output) = @_; + + $sensors_output =~ s/ERROR:.+\s(\w+):\s(.+)/\"$1\": 0.000,/g; + $sensors_output =~ s/ERROR:.+\s(\w+)!/\"$1\": 0.000,/g; + $sensors_output =~ s/,\s*(})/$1/g; + $sensors_output =~ s/\bNaN\b/null/g; + + # Fix duplicate SODIMM keys: "SODIMM":{"temp3_input":34.0} → "SODIMM3":{...} + $sensors_output =~ + s/\"SODIMM\":\{\"temp(\d+)_input\"/\"SODIMM$1\":\{\"temp$1_input\"/g; + + return $sensors_output; +} + +# ============================================================================ +# Enrich lm-sensors data with drive device info +# ============================================================================ + +sub _get_drive_names { + my ($sensors_output, $cache_ref) = @_; + $cache_ref //= {}; + + my $sensors_data; + eval { $sensors_data = decode_json($sensors_output); }; + if ($@) { + debug(__LINE__, "Failed to parse sensors JSON: $@"); + return $sensors_output; + } + + my @entries = grep { + /^drivetemp-scsi-/ || /^drivetemp-nvme-/ || /^nvme-pci-/ + } keys %{$sensors_data}; + + debug(__LINE__, "Found " . scalar(@entries) . " drive entries in lm-sensors output"); + + my @drive_names; + + foreach my $entry (@entries) { + my ($dev_path, $model, $serial) = ("unknown", "unknown", "unknown"); + + 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 { + # ----- 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 (e.g. "0600") to pattern (e.g. "0000:06:00") + my $pci_pattern; + if ($pci_addr =~ /^([0-9a-f]{2})([0-9a-f]{2})$/i) { + 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 { + $pci_pattern = $pci_addr; + } + + my $found = 0; + 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) { + 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"); + $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"); + last; + } + debug(__LINE__, + "NVMe device $nvme_dev did not match PCI pattern $pci_pattern"); + } + } + + # Fallback: scan /sys/class/block + 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"; + (my $nvme_ctrl = $block_dev) =~ s/n\d+$//; + $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"); + last; + } + } + } + + unless ($found) { + debug(__LINE__, + "Could not find device for nvme-pci-$pci_addr (pattern: $pci_pattern)"); + } + } else { + next; + } + + $cache_ref->{$entry} = { + device_path => $dev_path, + model => $model, + serial => $serial, + }; + + debug(__LINE__, "Drive: $entry -> $dev_path (Model: $model, Serial: $serial)"); + } + + push @drive_names, [$entry, $dev_path, $model, $serial]; + } + + foreach my $drive_entry (@drive_names) { + my ($original_name, $dev_path, $model, $serial) = @$drive_entry; + 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"); + } + } + + return JSON->new->pretty->canonical->encode($sensors_data); +} + +# ============================================================================ +# Enrich lm-sensors data with CPU model info +# ============================================================================ + +sub _get_cpu_name { + my ($sensors_output, $cache_ref) = @_; + $cache_ref //= {}; + + my $sensors_data; + eval { $sensors_data = decode_json($sensors_output); }; + if ($@) { + debug(__LINE__, "Failed to parse sensors JSON: $@"); + return $sensors_output; + } + + 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"); + + 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 { + # ----- Intel coretemp ----- + if ($entry =~ /^coretemp-isa-(\d+)/) { + 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; + + 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; + my $pci_pattern = $pci_addr; + + if ($pci_addr =~ /^([0-9a-f]{2})([0-9a-f]{2})$/i) { + 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"); + } + + 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/) { + $pkg = 0; + + 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_ref->{$entry} = { model => $cpu_model, package => $pkg }; + debug(__LINE__, "CPU: $entry -> Package $pkg (Model: $cpu_model)"); + } + + 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"); + } + } + + return JSON->new->pretty->canonical->encode($sensors_data); +} + +# ============================================================================ +# CPU model lookup helper +# ============================================================================ + +sub _cpu_model_by_package { + my ($pkg) = @_; + + if (open my $fh, '<', '/proc/cpuinfo') { + my $current_pkg = -1; + my $model_name = "unknown"; + + while (my $line = <$fh>) { + chomp $line; + + if ($line =~ /^physical id\s+:\s+(\d+)/) { + $current_pkg = $1; + } + + if ($line =~ /^model name\s+:\s+(.+)$/) { + $model_name = $1; + $model_name =~ s/^\s+|\s+$//g; + + if ($current_pkg == $pkg) { + close($fh); + return $model_name; + } + } + } + close($fh); + + return $model_name if $model_name ne "unknown"; + } + + return "unknown"; +} + +1; diff --git a/src/PVENodeInfo/PVEMod_Collectors/Nvidia.pm b/src/PVENodeInfo/PVEMod_Collectors/Nvidia.pm new file mode 100644 index 0000000..42508c7 --- /dev/null +++ b/src/PVENodeInfo/PVEMod_Collectors/Nvidia.pm @@ -0,0 +1,212 @@ +package PVE::PVEMod::Collector::Nvidia; + +use strict; +use warnings; +use Exporter 'import'; + +use PVE::PVEMod::Config qw(%config $process_type $pve_mod_working_dir); +use PVE::PVEMod::Utils qw(debug check_executable setup_collector_signals safe_write_json parse_csv_line); +use PVE::PVEMod::Store qw(update_nvidia_gpu_rrd); + +our @EXPORT_OK = qw( + get_nvidia_gpu_devices + collector_for_nvidia_devices +); + +# ============================================================================ +# NVIDIA GPU — device discovery +# ============================================================================ + +sub get_nvidia_gpu_devices { + my @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++; + next if $line_num == 1 || /^\s*$/; + 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 { + if (open my $fh, '-|', 'nvidia-smi --query-gpu=index,name --format=csv') { + my $line_num = 0; + while (<$fh>) { + chomp; + $line_num++; + next if $line_num == 1 || /^\s*$/; + 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; +} + +# ============================================================================ +# NVIDIA GPU — data parsing +# ============================================================================ + +sub _parse_nvidia_gpu_line { + my ($line) = @_; + + # Expected CSV format: + # index, name, temperature.gpu, utilization.gpu, utilization.memory, + # memory.used, memory.total, power.draw, power.limit, fan.speed + + my @values = parse_csv_line($line, 10); + return unless @values; + + return { + 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 => "%", + }, + }; +} + +# ============================================================================ +# NVIDIA GPU — stat collection and write +# ============================================================================ + +sub _get_and_write_nvidia_stats { + my ($devices) = @_; + my @all_stats; + + if ($config{debug}{nvidia_mode} && -f $config{debug}{nvidia_output_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++; + next if $line_num == 1 || /^\s*$/; + 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 { + unless (check_executable('/usr/bin/nvidia-smi', 'NVIDIA')) { + debug(__LINE__, "nvidia-smi not available, cannot collect stats"); + return 0; + } + + 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++; + next if $line_num == 1 || /^\s*$/; + my $stats = _parse_nvidia_gpu_line($_); + push @all_stats, $stats if $stats; + } + close $fh; + } + } + + foreach my $stats (@all_stats) { + my $device_index = $stats->{index}; + + unless ($device_index =~ /^(\d+)$/) { + debug(__LINE__, "Invalid device index: $device_index, skipping"); + next; + } + $device_index = $1; # untainted + + my $node_name = "gpu$device_index"; + my $device_state_file = "$pve_mod_working_dir/stats-nvidia$device_index.json"; + + my $device_name = $stats->{name}; + foreach my $dev (@$devices) { + if ($dev->{index} == $device_index) { + $device_name = $dev->{name}; + last; + } + } + + my $device_data = { + $node_name => { + name => $device_name, + index => $device_index, + stats => $stats, + } + }; + + 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); +} + +# ============================================================================ +# NVIDIA GPU — long-running collector (all devices in one process) +# ============================================================================ + +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)"); + + my $shutdown = 0; + setup_collector_signals('nvidia-all', \$shutdown); + + while (!$shutdown) { + _get_and_write_nvidia_stats($devices); + sleep $config{intervals}{data_pull} unless $shutdown; + } + + debug(__LINE__, "NVIDIA collector shutting down"); + exit 0; +} + +1; diff --git a/src/PVENodeInfo/PVEMod_Collectors/Ups.pm b/src/PVENodeInfo/PVEMod_Collectors/Ups.pm new file mode 100644 index 0000000..261713f --- /dev/null +++ b/src/PVENodeInfo/PVEMod_Collectors/Ups.pm @@ -0,0 +1,115 @@ +package PVE::PVEMod::Collector::Ups; + +use strict; +use warnings; +use Exporter 'import'; + +use JSON; + +use PVE::PVEMod::Config qw($process_type $ups_state_file); +use PVE::PVEMod::Utils qw(debug setup_collector_signals); + +our @EXPORT_OK = qw( + collector_for_ups +); + +# ============================================================================ +# UPS — long-running collector +# ============================================================================ + +sub collector_for_ups { + my ($device) = @_; + $process_type = 'collector'; + $0 = "collector-ups-$device->{ups_name}"; + debug(__LINE__, "UPS collector started"); + + my $shutdown = 0; + setup_collector_signals("ups-$device->{ups_name}", \$shutdown); + + while (!$shutdown) { + my $ups_data = _get_ups_status($device->{ups_name}); + + eval { + open my $ofh, '>', $ups_state_file + or die "Failed to open $ups_state_file: $!"; + print $ofh $ups_data; + close $ofh; + debug(__LINE__, "Wrote UPS data to $ups_state_file"); + }; + if ($@) { + debug(__LINE__, "Error writing UPS data: $@"); + } + + sleep 1 unless $shutdown; # $config{intervals}{data_pull} + } + + debug(__LINE__, "UPS collector shutting down"); + exit 0; +} + +# ============================================================================ +# UPS — status query +# ============================================================================ + +sub _get_ups_status { + my ($ups_name) = @_; + + debug(__LINE__, "Collecting UPS status for $ups_name"); + + my $output = `/usr/bin/upsc $ups_name 2>/dev/null`; + + 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" }); + } + + my $ups_data = _parse_upsc_output($output); + + unless (keys %$ups_data) { + debug(__LINE__, "No data received from upsc for $ups_name"); + return encode_json({ error => "No data from UPS $ups_name" }); + } + + return JSON->new->pretty->canonical->encode({ $ups_name => $ups_data }); +} + +# ============================================================================ +# UPS — output parser +# ============================================================================ + +sub _parse_upsc_output { + my ($output) = @_; + + my $ups_data = {}; + + debug(__LINE__, "Parsing upsc output"); + + eval { + foreach my $line (split /\n/, $output) { + next if $line =~ /^\s*$/; + next if $line =~ /^Init SSL/; + + if ($line =~ /^([^:]+):\s*(.*)$/) { + my ($key, $value) = ($1, $2); + $key =~ s/^\s+|\s+$//g; + $value =~ s/^\s+|\s+$//g; + + # Coerce numeric values + 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; +} + +1; diff --git a/src/PVENodeInfo/PVEMod_Collectors/systemInformation.pm b/src/PVENodeInfo/PVEMod_Collectors/systemInformation.pm new file mode 100644 index 0000000..de43892 --- /dev/null +++ b/src/PVENodeInfo/PVEMod_Collectors/systemInformation.pm @@ -0,0 +1,96 @@ +package PVE::PVEMod::Collector::SystemInformation; + +use strict; +use warnings; +use Exporter 'import'; + +use PVE::PVEMod::Config qw(%config); +use PVE::PVEMod::Utils qw(debug); + +our @EXPORT_OK = qw( + get_system_information_data +); + +# ============================================================================ +# System Information — one-time dmidecode call +# ============================================================================ + +sub get_system_information_data { + unless ($config{system_info}{enabled}) { + debug(__LINE__, "System information collection is disabled"); + return {}; + } + + my $raw_type = $config{system_info}{type}; + + # Taint-safe: only allow type 1 (System) or 2 (Baseboard/Motherboard) + my $type; + if (defined $raw_type && $raw_type =~ /^([12])$/) { + $type = $1; + } else { + debug(__LINE__, "Invalid system_info type '${\($raw_type // 'undef')}', defaulting to 1"); + $type = 1; + } + + debug(__LINE__, "Collecting system information via dmidecode -t $type"); + + return _get_system_info($type); +} + +# ============================================================================ +# Internal — run dmidecode and parse output +# ============================================================================ + +sub _get_system_info { + my ($type) = @_; + + my $output = `/usr/sbin/dmidecode -t $type 2>/dev/null`; + + unless (defined $output && length($output) > 0) { + debug(__LINE__, "No output from dmidecode -t $type"); + return {}; + } + + my %fields; + my @field_order; + + for my $line (split /\n/, $output) { + if ($line =~ /^\s+(Manufacturer|Product Name|Serial Number):\s*(.+)$/) { + my ($key, $value) = ($1, $2); + $value =~ s/^\s+|\s+$//g; + + my $field_key = lc($key); + $field_key =~ s/ /_/g; + + unless (exists $fields{$field_key}) { + push @field_order, $field_key; + $fields{$field_key} = $value; + } + } + } + + unless (%fields) { + debug(__LINE__, "No recognised fields found in dmidecode output"); + return {}; + } + + # Build display string: "Manufacturer: X | Product Name: Y | Serial Number: Z" + my %pretty_key = ( + manufacturer => 'Manufacturer', + product_name => 'Product Name', + serial_number => 'Serial Number', + ); + + my @parts; + for my $key (@field_order) { + my $label = $pretty_key{$key} // $key; + push @parts, "$label: $fields{$key}"; + } + $fields{display_string} = join(' | ', @parts); + + debug(__LINE__, "System information: $fields{display_string}"); + + return \%fields; +} + +1; diff --git a/src/PVENodeInfo/PVEMod_Config.pm b/src/PVENodeInfo/PVEMod_Config.pm new file mode 100644 index 0000000..dbc215b --- /dev/null +++ b/src/PVENodeInfo/PVEMod_Config.pm @@ -0,0 +1,91 @@ +package PVE::PVEMod::Config; + +use strict; +use warnings; +use Exporter 'import'; + +our @EXPORT_OK = qw( + %config + $DEBUG_ENABLED $VERSION $process_type + $pve_mod_working_dir $stats_dir $state_file + $sensors_state_file $ups_state_file + $pve_mod_worker_lock $startup_lock + $RRD_SOCKET $RRD_BASE +); + +# ============================================================================ +# Debug / Version +# ============================================================================ + +our $DEBUG_ENABLED = 1; +our $VERSION = '1.0'; + +# Runtime process-type tag — set to 'worker' or 'collector' after fork. +# Each forked child gets its own copy of this variable. +our $process_type = 'main'; # 'main', 'worker', or 'collector' + +# ============================================================================ +# Configuration +# ============================================================================ + +our %config = ( + gpu => { + intel_enabled => 1, + amd_enabled => 0, + nvidia_enabled => 0, + }, + debug => { + log_enabled => 0, + log_file => '/tmp/pve-mod-debug.log', + nvidia_mode => 1, + nvidia_devices_file => '/tmp/nvidia-smi-devices.csv', + nvidia_output_file => '/tmp/nvidia-smi-output.csv', + intel_mode => 0, + intel_devices_file => '/tmp/intel-gpu-devices.json', + amd_mode => 0, + amd_devices_file => '/tmp/amd-gpu-devices.json', + ups_mode => 0, + ups_output_file => '/tmp/ups-output.json', + lm_sensors_mode => 0, + lm_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 + }, + lm_sensors => { + enabled => 1, + }, + ups => { + enabled => 1, + device_name => 'ups@192.168.3.2', + }, + system_info => { + enabled => 1, + type => 1, # 1 = System (dmidecode -t 1), 2 = Baseboard/Motherboard (dmidecode -t 2) + }, + paths => { + working_dir => '/run/pveproxy/pve-mod', + }, +); + +# ============================================================================ +# Derived paths +# ============================================================================ + +our $pve_mod_working_dir = $config{paths}{working_dir}; +our $stats_dir = $pve_mod_working_dir; +our $state_file = "$pve_mod_working_dir/stats.json"; +our $sensors_state_file = "$pve_mod_working_dir/sensors.json"; +our $ups_state_file = "$pve_mod_working_dir/ups.json"; +our $pve_mod_worker_lock = "$pve_mod_working_dir/pve_mod_worker.lock"; +our $startup_lock = "$pve_mod_working_dir/startup.lock"; + +# ============================================================================ +# RRD paths +# ============================================================================ + +our $RRD_SOCKET = '/var/run/rrdcached.sock'; +our $RRD_BASE = '/var/lib/rrdcached/db/pve-mod-gpu'; + +1; diff --git a/src/PVENodeInfo/PVEMod_ProcessManager.pm b/src/PVENodeInfo/PVEMod_ProcessManager.pm new file mode 100644 index 0000000..741717f --- /dev/null +++ b/src/PVENodeInfo/PVEMod_ProcessManager.pm @@ -0,0 +1,469 @@ +package PVE::PVEMod::ProcessManager; + +use strict; +use warnings; +use Exporter 'import'; + +use POSIX qw(WNOHANG); +use File::Path qw(remove_tree); + +use PVE::PVEMod::Config qw( + %config $process_type + $pve_mod_working_dir $state_file + $pve_mod_worker_lock $startup_lock +); +use PVE::PVEMod::Utils qw( + debug is_process_alive read_lock_pid + acquire_exclusive_lock ensure_pve_mod_directory_exists + check_executable startup_message +); + +use PVE::PVEMod::Collector::Intel qw(get_intel_gpu_devices collector_for_intel_device); +use PVE::PVEMod::Collector::Nvidia qw(get_nvidia_gpu_devices collector_for_nvidia_devices); +use PVE::PVEMod::Collector::Amd qw(get_amd_gpu_devices collector_for_amd_device); +use PVE::PVEMod::Collector::LmSensors qw(collector_for_temperature_sensors); +use PVE::PVEMod::Collector::Ups qw(collector_for_ups); + +our @EXPORT_OK = qw( + pve_mod_starter + notify_pve_mod_worker +); + +# Collector registry — only populated inside the worker process. +# Each forked child has its own copy; the parent never accesses this after forking. +my %collectors = (); + +# ============================================================================ +# Public API (called from SensorInfo) +# ============================================================================ + +# Ensures the worker is running. Starts it if necessary (double-checked locking). +sub pve_mod_starter { + debug(__LINE__, "Checking if pve_mod_worker is already running"); + if (_worker_lock_file_exists()) { + 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."); + + startup_message(); + ensure_pve_mod_directory_exists(); + + 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 acquiring lock + if (_worker_lock_file_exists()) { + debug(__LINE__, "Worker started by another process while we waited for lock"); + close($startup_fh); + unlink($startup_lock); + return "already running"; + } + + print $startup_fh "$$\n"; + $startup_fh->flush(); + debug(__LINE__, "Wrote PID $$ to startup lock"); + + _pve_mod_worker(); + + unlink($startup_lock); + debug(__LINE__, "Released startup lock"); + debug(__LINE__, "pve_mod_worker started successfully, returning"); +} + +# Sends SIGUSR1 to the worker to reset the inactivity timer. +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+)$/) { + 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 { + 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: $!"); + } +} + +# ============================================================================ +# Worker process management +# ============================================================================ + +sub _worker_lock_file_exists { + return -f $pve_mod_worker_lock; +} + +# Forks the worker process and records its PID in the lock file. +sub _pve_mod_worker { + debug(__LINE__, "_pve_mod_worker called"); + + 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 + $0 = "pve_mod_worker_controller"; + debug(__LINE__, "Child process forked, calling _pve_mod_keep_alive"); + _pve_mod_keep_alive(); + exit(0); + } + + # Parent — update lock file with real child PID + 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"); +} + +# ============================================================================ +# Worker keep-alive loop +# ============================================================================ + +sub _pve_mod_keep_alive { + $process_type = 'worker'; + debug(__LINE__, "pve_mod_worker process started with PID $$"); + + my $last_activity = time(); + + $SIG{USR1} = sub { + $last_activity = time(); + debug(__LINE__, "Activity ping received"); + }; + + $SIG{CHLD} = sub { + while ((my $pid = waitpid(-1, WNOHANG)) > 0) { + my $exit_status = $? >> 8; + debug(__LINE__, "Child process $pid exited with status $exit_status"); + + 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); + }; + + debug(__LINE__, "Worker starting all collectors"); + _initialise_sensors_collector(); + _initialise_graphics_collectors(); + _initialise_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); + } + + debug(__LINE__, "pve_mod_worker loop exited unexpectedly!"); +} + +# ============================================================================ +# Collector startup helpers (called from worker loop) +# ============================================================================ + +sub _initialise_sensors_collector { + return unless $config{lm_sensors}{enabled}; + return unless check_executable('/usr/bin/sensors', 'lm-sensors', + $config{debug}{lm_sensors_mode}, + $config{debug}{lm_sensors_output_file}); + + debug(__LINE__, "Starting lm-sensors collector"); + _start_collector('sensors', 'sensors', + \&collector_for_temperature_sensors, + { name => 'sensors' }); +} + +sub _initialise_ups_collector { + unless ($config{ups}{enabled} && $config{ups}{device_name}) { + debug(__LINE__, "UPS collection disabled/invalid in config, skipping"); + return; + } + return unless check_executable('/usr/bin/upsc', 'UPS', + $config{debug}{ups_mode}, + $config{debug}{ups_output_file}); + + debug(__LINE__, "Starting UPS collector: $config{ups}{device_name}"); + _start_collector('ups', 'ups', \&collector_for_ups, + { ups_name => $config{ups}{device_name} }); +} + +sub _initialise_graphics_collectors { + unless ($config{gpu}{intel_enabled} + || $config{gpu}{amd_enabled} + || $config{gpu}{nvidia_enabled}) { + debug(__LINE__, "No GPU types enabled, skipping collector startup"); + return; + } + + debug(__LINE__, "Starting graphics collectors"); + + my (@all_devices, @all_types, @all_collector_subs); + my @nvidia_devices; + + # Intel (each GPU has its own collector) + if ($config{gpu}{intel_enabled} && check_executable('/usr/bin/intel_gpu_top', 'Intel', + $config{debug}{intel_mode}, + $config{debug}{intel_devices_file})) { + my @intel_devices = get_intel_gpu_devices(); + for my $device (@intel_devices) { + push @all_devices, $device; + push @all_types, 'intel'; + push @all_collector_subs, \&collector_for_intel_device; + } + } + + # AMD (each GPU has its own collector) + if ($config{gpu}{amd_enabled} && check_executable('/usr/bin/rocm-smi', 'AMD', + $config{debug}{amd_mode}, + $config{debug}{amd_devices_file})) { + my @amd_devices = get_amd_gpu_devices(); + for my $device (@amd_devices) { + push @all_devices, $device; + push @all_types, 'amd'; + push @all_collector_subs, \&collector_for_amd_device; + } + } + + # NVIDIA (all GPUs collected together in one collector due to nvidia-smi design) + if ($config{gpu}{nvidia_enabled} && check_executable('/usr/bin/nvidia-smi', 'NVIDIA', + $config{debug}{nvidia_mode}, + $config{debug}{nvidia_devices_file})) { + @nvidia_devices = get_nvidia_gpu_devices(); + } + + debug(__LINE__, + "Detected: " + . scalar(grep { $_ eq 'intel' } @all_types) . " Intel, " + . scalar(grep { $_ eq 'amd' } @all_types) . " AMD, " + . scalar(@nvidia_devices) . " NVIDIA"); + + my $started_count = 0; + + # Start individual collectors for Intel and AMD devices + 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; + } + + # NVIDIA — single collector for all GPUs + if (@nvidia_devices) { + my $pid = _start_collector('nvidia-all', 'nvidia', + \&collector_for_nvidia_devices, + \@nvidia_devices); + $started_count++ if $pid; + } + + debug(__LINE__, + "Started/verified $started_count graphics collector(s)"); +} + +# ============================================================================ +# Generic collector start/stop +# ============================================================================ + +sub _start_collector { + my ($collector_name, $collector_type, $collector_sub, $device) = @_; + + debug(__LINE__, "Starting $collector_type collector: $collector_name"); + + 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}; + } + } + + my $pid = _start_child_collector($collector_name, $collector_sub, $device); + + unless ($pid) { + debug(__LINE__, "Failed to start $collector_type collector '$collector_name'"); + return undef; + } + + $collectors{$collector_name} = $pid; + debug(__LINE__, + "Registered $collector_type collector '$collector_name' with PID $pid"); + + 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_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) { + $process_type = 'collector'; + debug(__LINE__, "In child process for $collector_name"); + $0 = "collector-$collector_name"; + $collector_sub->($device); + exit(0); + } + + debug(__LINE__, "Forked child PID $pid for $collector_name"); + return $pid; +} + +sub _stop_child_collectors { + debug(__LINE__, "Stopping all collectors"); + + 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"); + } + } + + 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); + } + + foreach my $pid (@pids) { + if (kill(0, $pid)) { + debug(__LINE__, "Force killing collector process $pid"); + kill('KILL', $pid); + } + } + } + + %collectors = (); + debug(__LINE__, "Cleared collector registry"); + + if (-f $state_file) { + unlink $state_file or debug(__LINE__, "Failed to remove $state_file: $!"); + } + + 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 block — only the worker process performs cleanup +# ============================================================================ + +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"); + } else { + debug(__LINE__, "Main process END block: no cleanup needed"); + } +} + +1; diff --git a/src/PVENodeInfo/PVEMod_Store.pm b/src/PVENodeInfo/PVEMod_Store.pm new file mode 100644 index 0000000..436aa59 --- /dev/null +++ b/src/PVENodeInfo/PVEMod_Store.pm @@ -0,0 +1,166 @@ +package PVE::PVEMod::Store; + +use strict; +use warnings; +use Exporter 'import'; + +use File::Path qw(make_path); +use PVE::INotify; +use RRDs; + +use PVE::PVEMod::Config qw($RRD_SOCKET $RRD_BASE); +use PVE::PVEMod::Utils qw(debug); + +our @EXPORT_OK = qw( + get_nodename + gpu_rrd_path + update_intel_gpu_rrd + update_nvidia_gpu_rrd +); + +# ============================================================================ +# Node name +# ============================================================================ + +sub get_nodename { + return PVE::INotify::nodename(); +} + +# ============================================================================ +# RRD path helper +# ============================================================================ + +sub gpu_rrd_path { + my ($card) = @_; + return "$RRD_BASE/" . get_nodename() . "/$card"; +} + +# ============================================================================ +# Intel GPU RRD +# ============================================================================ + +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; +} + +# ============================================================================ +# NVIDIA GPU RRD +# ============================================================================ + +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; +} + +1; diff --git a/src/PVENodeInfo/PVEMod_Utils.pm b/src/PVENodeInfo/PVEMod_Utils.pm new file mode 100644 index 0000000..80f7faa --- /dev/null +++ b/src/PVENodeInfo/PVEMod_Utils.pm @@ -0,0 +1,274 @@ +package PVE::PVEMod::Utils; + +use strict; +use warnings; +use Exporter 'import'; + +use JSON; +use Fcntl qw(O_CREAT O_EXCL O_WRONLY); + +use PVE::PVEMod::Config qw($DEBUG_ENABLED $VERSION $pve_mod_working_dir %config); + +my $debug_log_fh; + +our @EXPORT_OK = qw( + debug + read_sysfs + is_process_alive + read_lock_pid + acquire_exclusive_lock + ensure_pve_mod_directory_exists + check_executable + pve_mod_hello + setup_collector_signals + safe_write_json + safe_read_json + parse_csv_line +); + +# ============================================================================ +# Debug +# ============================================================================ + +# debug function showing line number and call chain +# Usage: debug(__LINE__, "message") +sub debug { + return unless $DEBUG_ENABLED; + + my ($line, $message) = @_; + + 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/.*:://; + + my $output; + if (defined $sub2) { + $sub2 =~ s/.*:://; + $output = "[$sub2 -> $sub1:$line] $message\n"; + } else { + $output = "[$sub1:$line] $message\n"; + } + + warn $output; + + if ($config{debug}{log_enabled} && !defined $debug_log_fh) { + if (open(my $fh, '>>', $config{debug}{log_file})) { + $fh->autoflush(1); + $debug_log_fh = $fh; + } else { + warn "[debug] Failed to open log file $config{debug}{log_file}: $!\n"; + } + } + print $debug_log_fh $output if defined $debug_log_fh; +} + +# ============================================================================ +# File / Process helpers +# ============================================================================ + +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; + $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; + + if (sysopen($fh, $lock_path, O_CREAT|O_EXCL|O_WRONLY, 0644)) { + debug(__LINE__, "Acquired $purpose on first try"); + return $fh; + } + + 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); + } + + 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"); + } +} + +# Returns 1 if executable exists, or debug mode is active with a debug file present. +# Returns 0 otherwise. +sub check_executable { + my ($exec_path, $type, $debug_mode_enabled, $debug_file) = @_; + + 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; + } + } + + 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 TERM/INT signal handlers for collector processes. +# $shutdown_ref is a scalar ref that will be set to 1 on signal. +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; + }; +} + +# ============================================================================ +# JSON helpers +# ============================================================================ + +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; +} + +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; +} + +# ============================================================================ +# CSV helper +# ============================================================================ + +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; +} + +1; diff --git a/src/PVENodeInfo/PveMod_SensorInfo.pm b/src/PVENodeInfo/PveMod_SensorInfo.pm new file mode 100644 index 0000000..d0b71fc --- /dev/null +++ b/src/PVENodeInfo/PveMod_SensorInfo.pm @@ -0,0 +1,215 @@ +package PVE::API2::PVEMod_SensorInfo; + +use strict; +use warnings; + +use PVE::PVEMod::Config qw(%config $VERSION $stats_dir $sensors_state_file $ups_state_file); +use PVE::PVEMod::Utils qw(debug safe_read_json); +use PVE::PVEMod::ProcessManager qw(pve_mod_starter notify_pve_mod_worker); +use PVE::PVEMod::Collector::SystemInformation qw(get_system_information_data); + +# Per-endpoint state caches (module-level, reset on worker restart) +my $graphics_cache = { data => {}, mtime => 0 }; +my $sensors_cache = { data => '{}', mtime => 0 }; +my $ups_cache = { data => '{}', mtime => 0 }; +my $system_info_cache = undef; + + +# ============================================================================ +# Internal helpers +# ============================================================================ + +sub _read_state_file_cached { + my ($files, $cache_ref, $reader, $empty_fallback) = @_; + + # Normalize scalar path to single-element arrayref + my @filepaths = ref($files) eq 'ARRAY' ? @$files : ($files); + + # Find newest mtime across all files + my $newest_mtime = 0; + my $any_exist = 0; + foreach my $fp (@filepaths) { + my @st = stat($fp); + if (@st) { + $any_exist = 1; + $newest_mtime = $st[9] if $st[9] > $newest_mtime; + } + } + + unless ($any_exist) { + debug(__LINE__, "No state files exist: " . join(', ', @filepaths)); + return $cache_ref->{data} // $empty_fallback; + } + + if ($newest_mtime == $cache_ref->{mtime} && defined $cache_ref->{data}) { + debug(__LINE__, "State files unchanged, returning cached data"); + return $cache_ref->{data}; + } + + my $data; + if (ref($reader) eq 'CODE') { + $data = $reader->(\@filepaths); + } else { + $data = safe_read_json($filepaths[0], $reader); + } + + if (!defined $data) { + debug(__LINE__, "Failed to read state file(s): " . join(', ', @filepaths)); + return $cache_ref->{data} // $empty_fallback; + } + + $cache_ref->{data} = $data; + $cache_ref->{mtime} = $newest_mtime; + return $cache_ref->{data}; +} + +sub _merge_graphics_files { + my ($filepaths) = @_; + + my $merged = { + Graphics => { + Intel => {}, + NVIDIA => {}, + AMD => {}, + } + }; + + foreach my $filepath (@$filepaths) { + my ($file) = $filepath =~ m{([^/]+)$}; + debug(__LINE__, "Reading device file: $filepath"); + + my $device_data = safe_read_json($filepath, 0); + if (!$device_data) { + debug(__LINE__, "Failed to read/parse $filepath"); + next; + } + + my $device_type = ($file =~ /^stats-card/) ? 'Intel' + : ($file =~ /^stats-nvidia/) ? 'NVIDIA' + : 'AMD'; + + 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"); + } + } + + return $merged; +} + +sub _load_graphics_data { + # Build filename patterns for enabled GPU types + my @patterns; + push @patterns, 'card\d+' if $config{gpu}{intel_enabled}; + push @patterns, 'nvidia\d+' if $config{gpu}{nvidia_enabled}; + push @patterns, 'amd\d+' if $config{gpu}{amd_enabled}; + + unless (@patterns) { + debug(__LINE__, "No GPU types enabled in config"); + return $graphics_cache->{data}; + } + + my $pattern = join('|', @patterns); + + # Find device stat files for enabled GPU types + my $dh; + unless (opendir($dh, $stats_dir)) { + debug(__LINE__, "Failed to open stats directory: $stats_dir: $!"); + return $graphics_cache->{data}; + } + + my @stat_files = grep { /^stats-(?:$pattern)\.json$/ } readdir($dh); + closedir($dh); + + unless (@stat_files) { + debug(__LINE__, "No device stat files found in $stats_dir"); + return $graphics_cache->{data}; + } + + debug(__LINE__, "Found " . scalar(@stat_files) . " device stat file(s): " . join(', ', @stat_files)); + + my @filepaths = map { "$stats_dir/$_" } @stat_files; + + my $data = _read_state_file_cached( + \@filepaths, + $graphics_cache, + \&_merge_graphics_files, + { Graphics => { Intel => {}, NVIDIA => {}, AMD => {} } } + ); + + my $intel_count = scalar(keys %{$data->{Graphics}{Intel} // {}}); + my $nvidia_count = scalar(keys %{$data->{Graphics}{NVIDIA} // {}}); + my $amd_count = scalar(keys %{$data->{Graphics}{AMD} // {}}); + debug(__LINE__, "Returning $intel_count Intel + $nvidia_count NVIDIA + $amd_count AMD device node(s)"); + + return $data; +} + +# ============================================================================ +# API calls +# ============================================================================ + +sub get_graphic_info { + debug(__LINE__, "get_graphic_info called"); + + # Start PVE Mod + pve_mod_starter(); + + my $data = _load_graphics_data(); + + # Notify pve_mod_worker of activity + notify_pve_mod_worker(); + + return $data; +} + +sub get_sensors_info { + debug(__LINE__, "get_sensors_info called"); + + # Start PVE Mod + pve_mod_starter(); + + my $data = _read_state_file_cached($sensors_state_file, $sensors_cache, 1, '{}'); + + # Notify pve_mod_worker of activity + notify_pve_mod_worker(); + + return $data; +} + +sub get_ups_info { + debug(__LINE__, "get_ups_info called"); + + # Start PVE Mod + pve_mod_starter(); + + my $data = _read_state_file_cached($ups_state_file, $ups_cache, 1, '{}'); + + # Notify pve_mod_worker of activity + notify_pve_mod_worker(); + + return $data; +} + +sub get_pve_mod_version { + debug(__LINE__, "get_pve_mod_version called"); + # Notify pve_mod_worker of activity + notify_pve_mod_worker(); + + return $VERSION; +} + +sub get_system_information { + debug(__LINE__, "get_system_information called"); + + if (defined $system_info_cache) { + debug(__LINE__, "Returning cached system information"); + return $system_info_cache; + } + + $system_info_cache = get_system_information_data(); + + return $system_info_cache; +} + +1; diff --git a/src/PVENodeInfo/PveMod_pvemanagerlib.js b/src/PVENodeInfo/PveMod_pvemanagerlib.js new file mode 100644 index 0000000..39504d5 --- /dev/null +++ b/src/PVENodeInfo/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/src/PVENodeInfo/pve-mod-gui-sensors-v2.sh b/src/PVENodeInfo/pve-mod-gui-sensors-v2.sh new file mode 100644 index 0000000..3773ab6 --- /dev/null +++ b/src/PVENodeInfo/pve-mod-gui-sensors-v2.sh @@ -0,0 +1,1032 @@ +#!/usr/bin/env bash +# +# This bash script installs a modification to the Proxmox Virtual Environment (PVE) web user interface (UI) to display sensors information. +# + +################### Configuration ############# +# 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="/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. +# Information will be loaded for script configuration and presented in Proxmox. + +# DEV NOTE: lm-sensors version >3.6.0 breakes properly formatted JSON output using 'sensors -j'. This implements a workaround using uses a python3 for formatting + +DEBUG_REMOTE=false +DEBUG_JSON_FILE="/tmp/sensordata.json" +DEBUG_UPS_FILE="/tmp/upsc.txt" + +# This script's working directory +SCRIPT_CWD="$(dirname "$(readlink -f "$0")")" + +# Debug location +JSON_EXPORT_DIRECTORY="$SCRIPT_CWD" +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/PveSensors/PveMod_pvemanagerlib.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/PveSensors/PveMod_SensorInfo.pm" +GPU_RRD_DIR="/var/lib/rrdcached/db/pve-mod-gpu" + +# New modular architecture paths +PVEMOD_SOURCES_DIR="$SCRIPT_CWD/PveSensors" +PVEMOD_INSTALL_DIR="/usr/share/perl5/PVE/PVEMod" +PVEMOD_COLLECTOR_DIR="$PVEMOD_INSTALL_DIR/Collector" +PVEMOD_CONFIG_FILE="$PVEMOD_INSTALL_DIR/Config.pm" + +#region message tools +# Section header (bold) +function msgb() { + local message="$1" + echo -e "\e[1m${message}\e[0m" +} + +# Info (green) +function info() { + local message="$1" + echo -e "\e[0;32m[info] ${message}\e[0m" +} + +# Warning (yellow) +function warn() { + local message="$1" + echo -e "\e[0;33m[warning] ${message}\e[0m" +} + +# Error (red) +function err() { + local message="$1" + echo -e "\e[0;31m[error] ${message}\e[0m" + exit 1 +} + +# Prompts (cyan or bold) +function ask() { + local prompt="$1" + local response + read -p $'\n\e[1;36m'"${prompt}:"$'\e[0m ' response + echo "$response" +} +#endregion message tools + +# Function to display usage information +function usage { + msgb "\nUsage:\n$0 [install | uninstall | save-sensors-data]\n" + exit 1 +} + +# System checks +function check_root_privileges() { + [[ $EUID -eq 0 ]] || err "This script must be run as root. Please run it with 'sudo $0'." + info "Root privileges verified." +} + +# Check if a tool is installed; if not, offer to install it via apt. +# Usage: _check_or_install_tool +# Returns 0 if the tool is available (or successfully installed), 1 otherwise. +function _check_or_install_tool { + local cmd="$1" + local pkg="$2" + local description="$3" + + if command -v "$cmd" &>/dev/null; then + info "$description is installed." + return 0 + fi + + local choice + choice=$(ask "$description is not installed. Would you like to install it? (y/N)") + case "$choice" in + [yY]) + apt-get update + apt-get install -y "$pkg" + if command -v "$cmd" &>/dev/null; then + info "$description installed successfully." + return 0 + else + warn "$description installation failed. The section will be skipped." + return 1 + fi + ;; + *) + info "Skipping $description installation." + return 1 + ;; + esac +} + +# Check if nvidia-smi is available. NVIDIA GPU monitoring requires the NVIDIA drivers +# to be installed manually; this function does not attempt installation via apt. +# Returns 0 if nvidia-smi is available, 1 otherwise. +function _check_nvidia_tool { + if command -v nvidia-smi &>/dev/null; then + info "nvidia-smi is installed." + return 0 + fi + warn "nvidia-smi not found. NVIDIA GPU monitoring requires the NVIDIA drivers to be installed." + info "Refer to your system or Proxmox documentation for NVIDIA driver installation." + return 1 +} + +# Main configuration function to detect sensors and set up parameters +function configure { + SENSORS_DETECTED=false + local sensorsOutput + local sanitisedSensorsOutput + local upsOutput + local modelName + local upsConnection + + local lm_sensors_ok=false + _check_or_install_tool sensors lm-sensors "lm-sensors" && lm_sensors_ok=true + + if [[ "$lm_sensors_ok" == true ]]; then + #### 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." + warn "Remote debugging is used. UPS readings from dump file $DEBUG_UPS_FILE will be used." + sensorsOutput=$(cat "$DEBUG_JSON_FILE") + else + sensorsOutput=$(sensors -j 2>/dev/null) + fi + + # Apply lm-sensors sanitization + sanitisedSensorsOutput=$(sanitize_sensors_output "$sensorsOutput") + + if [ $? -ne 0 ]; then + err "Sensor output error.\n\nCommand output:\n${sanitisedSensorsOutput}\n\nExiting..." + fi + #endregion sensors collection + + #### CPU #### + #region CPU setup + msgb "\n=== Detecting CPU temperature sensors ===" + ENABLE_CPU=false + local cpuList="" + local cpuCount=0 + + # Find all CPU sensors that match known patterns + for pattern in "${KNOWN_CPU_SENSORS[@]}"; do + found_sensors=$(echo "$sanitisedSensorsOutput" | grep -o "\"${pattern}[^\"]*\"" | sed 's/"//g') + if [ -n "$found_sensors" ]; then + while read -r sensor; do + if [ -n "$sensor" ]; then + cpuCount=$((cpuCount + 1)) + if [ -z "$cpuList" ]; then + cpuList="$sensor" + else + cpuList="$cpuList,$sensor" + fi + ENABLE_CPU=true + fi + done <<< "$found_sensors" + fi + done + + if [ "$ENABLE_CPU" = true ]; then + info "Detected CPU sensors ($cpuCount): $cpuList" + SENSORS_DETECTED=true + while true; do + local choice=$(ask "Display temperatures for all cores [C] or average per CPU [a] (some newer AMD variants support per die)? (C/a)") + case "$choice" in + [cC]|"") + CPU_TEMP_TARGET="Core" + info "Temperatures will be displayed for all cores." + break + ;; + [aA]) + CPU_TEMP_TARGET="Package" + info "An average temperature will be displayed per CPU." + break + ;; + *) + warn "Invalid input, please choose C or a." + ;; + esac + done + else + warn "No CPU temperature sensors found." + fi + #endregion cpu setup + + #### RAM #### + #region ram setup + local ramList ramCount + msgb "\n=== Detecting RAM temperature sensors ===" + 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" + ENABLE_RAM_TEMP=true + SENSORS_DETECTED=true + else + warn "No RAM temperature sensors found." + ENABLE_RAM_TEMP=false + fi + #endregion ram setup + + #### HDD/SSD #### + #region hdd setup + msgb "\n=== Detecting HDD/SSD temperature sensors ===" + local hddList=($(echo "$sanitisedSensorsOutput" | grep -o '"drivetemp-scsi[^"]*"' | sed 's/"//g')) + if [ ${#hddList[@]} -gt 0 ]; then + info "Detected HDD/SSD sensors (${#hddList[@]}): $(IFS=,; echo "${hddList[*]}")" + ENABLE_HDD_TEMP=true + SENSORS_DETECTED=true + else + warn "No HDD/SSD temperature sensors found." + ENABLE_HDD_TEMP=false + fi + #endregion hdd setup + + #### NVMe #### + #region nvme setup + msgb "\n=== Detecting NVMe temperature sensors ===" + local nvmeList=($(echo "$sanitisedSensorsOutput" | grep -o '"nvme[^"]*"' | sed 's/"//g')) + if [ ${#nvmeList[@]} -gt 0 ]; then + info "Detected NVMe sensors (${#nvmeList[@]}): $(IFS=,; echo "${nvmeList[*]}")" + ENABLE_NVME_TEMP=true + SENSORS_DETECTED=true + else + warn "No NVMe temperature sensors found." + ENABLE_NVME_TEMP=false + fi + #endregion nvme setup + + #### Fans #### + #region fan setup + msgb "\n=== Detecting fan speed sensors ===" + + local fanList="" + local fanCount=0 + + # Find all fan names that have fan*_input entries + fanList=$(echo "$sanitisedSensorsOutput" | grep -B2 '"fan[0-9]\+_input"' | grep '".*": {' | sed 's/.*"\([^"]*\)": {.*/\1/' | sort -u | paste -sd, -) + fanCount=$(grep -c 'fan[0-9]\+_input' <<<"$sanitisedSensorsOutput") + + if [ "$fanCount" -gt 0 ]; then + info "Detected fan speed sensors ($fanCount): $fanList" + ENABLE_FAN_SPEED=true + SENSORS_DETECTED=true + + local choice + choice=$(ask "Display fans reporting zero speed? (Y/n)") + case "$choice" in + [yY]|"") + DISPLAY_ZERO_SPEED_FANS=true + info "Zero-speed fans will be displayed." + ;; + [nN]) + DISPLAY_ZERO_SPEED_FANS=false + info "Only active fans will be displayed." + ;; + *) + warn "Invalid input. Defaulting to show zero-speed fans." + DISPLAY_ZERO_SPEED_FANS=true + ;; + esac + else + warn "No fan speed sensors found." + ENABLE_FAN_SPEED=false + fi + #endregion fan setup + + #### Temperature Units #### + #region temp unit setup + msgb "\n=== Display temperature ===" + if [ "$SENSORS_DETECTED" = true ]; then + local unit=$(ask "Display temperatures in Celsius [C] or Fahrenheit [f]? (C/f)") + case "$unit" in + [cC]|"") + TEMP_UNIT="C" + info "Using Celsius." + ;; + [fF]) + TEMP_UNIT="F" + info "Using Fahrenheit." + ;; + *) + warn "Invalid selection. Defaulting to Celsius." + TEMP_UNIT="C" + ;; + esac + fi + #endregion temp unit setup + + fi #end lm-sensors block 1 + + #### Graphics #### + #region Graphics setup + msgb "\n=== Detecting Graphics information ===" + + #region intel GPU setup + if _check_or_install_tool intel_gpu_top igt-gpu-tools "Intel GPU tools (igt-gpu-tools)"; 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 + ENABLE_INTEL_GPU_INFO=false + fi + #endregion intel GPU setup + + #region NVIDIA GPU setup + if _check_nvidia_tool; 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 + ENABLE_NVIDIA_GPU_INFO=false + fi + #endregion NVIDIA GPU setup + + #region AMD GPU setup + if _check_or_install_tool radeontop radeontop "AMD GPU tools (radeontop)"; then + # AMD GPU detection is not yet implemented + ENABLE_AMD_GPU_INFO=false + else + ENABLE_AMD_GPU_INFO=false + fi + #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 + + #### UPS #### + #region ups setup + local choiceUPS=$(ask "Enable UPS information? (y/N)") + case "$choiceUPS" in + [yY]) + if [ "$DEBUG_REMOTE" = true ]; then + upsOutput=$(cat "$DEBUG_UPS_FILE") + info "Remote debugging: UPS readings from $DEBUG_UPS_FILE" + upsConnection="DEBUG_UPS" + else + upsConnection=$(ask "Enter UPS connection (e.g., upsname[@hostname[:port]])") + if ! command -v upsc &>/dev/null; then + err "The 'upsc' command is not available. Install 'nut-client'." + fi + upsOutput=$(upsc "$upsConnection" 2>&1) + fi + + if echo "$upsOutput" | grep -q "device.model:"; then + modelName=$(echo "$upsOutput" | grep "device.model:" | cut -d':' -f2- | xargs) + ENABLE_UPS=true + info "Connected to UPS model: $modelName at $upsConnection." + else + warn "Failed to connect to UPS at '$upsConnection'." + ENABLE_UPS=false + fi + ;; + [nN]|"") + ENABLE_UPS=false + info "UPS information will not be displayed." + ;; + *) + warn "Invalid selection. UPS info will not be displayed." + ENABLE_UPS=false + ;; + esac + #endregion ups setup + + #### System Info #### + #region system info setup + msgb "\n=== Detecting System Information ===" + for i in 1 2; do + echo "type ${i})" + dmidecode -t "$i" | awk -F': ' '/Manufacturer|Product Name|Serial Number/ {print $1": "$2}' + done + local choiceSysInfo=$(ask "Enable system information? (1/2/n)") + case "$choiceSysInfo" in + [1]|"") + ENABLE_SYSTEM_INFO=true + SYSTEM_INFO_TYPE=1 + info "System information will be displayed." + ;; + [2]) + ENABLE_SYSTEM_INFO=true + SYSTEM_INFO_TYPE=2 + info "Motherboard information will be displayed." + ;; + [nN]) + ENABLE_SYSTEM_INFO=false + info "System information will NOT be displayed." + ;; + *) + warn "Invalid selection. Defaulting to system information." + ENABLE_SYSTEM_INFO=true + SYSTEM_INFO_TYPE=1 + ;; + esac + #endregion system info setup + + #### Final Check #### + #region final check + if [ "$SENSORS_DETECTED" = false ] && [ "$ENABLE_GPU_INFO" != true ] && [ "$ENABLE_UPS" = false ] && [ "$ENABLE_SYSTEM_INFO" = false ]; then + err "No sensors detected, no GPU info, no UPS and no system info enabled. Exiting." + fi + #endregion final check +} + + +# Function to install the modification +function install_mod { + msgb "\n=== Preparing mod installation ===" + check_root_privileges + check_mod_installation + configure + perform_backup + + #### Insert information retrieval code #### + msgb "\n=== Installing sensor info module ===" + install_sensor_monitor_module + insert_sensor_monitor_into_pve + + ## Historical GPU data ## + if [[ "$ENABLE_GPU_HISTORY" == true ]]; then + install_gpu_history + + # todo + + # add nodes.pm index list — add gpumeta after gpurrddata: + + fi + + #### Install UI modification module #### + msgb "\n=== Installing UI modification module ===" + install_node_status_view_module + + msgb "\n=== Finalizing installation ===" + + restart_proxy + info "Installation completed." + ask "Clear the browser cache to ensure all changes are visualized. (any key to continue)" +} + +# Sanitize sensors output to handle common lm-sensors parsing issues +sanitize_sensors_output() { + local input="$1" + + # Pipe the text into Perl: + # -0777 → "slurp mode": read the entire stream as one string so + # regexes can match across line breaks. + # -pe → loop over input, applying the script (-e) and printing. + # Apply python3 json.tool for proper formatting and validation + echo "$input" | perl -0777 -pe ' + # Replace ERROR lines with placeholder values + s/ERROR:.+\s(\w+):\s(.+)/"$1": 0.000,/g; + s/ERROR:.+\s(\w+)!/"$1": 0.000,/g; + + # Remove trailing commas before closing braces + s/,\s*(\})/$1/g; + + # Replace NaN values with null + s/\bNaN\b/null/g; + + # Fix duplicate SODIMM keys - handle both pretty and one-line JSON + s/"SODIMM"\s*:\s*\{\s*"temp(\d+)_input"/"SODIMM $1": {\n "temp$1_input"/g; + + # Fix duplicate fan keys - handle both pretty and one-line JSON + s/"([^"]*Fan[^"]*)"\s*:\s*\{\s*"fan(\d+)_input"/"$1 $2": {\n "fan$2_input"/g; + ' | python3 -m json.tool 2>/dev/null || echo "$input" +} + +#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 API facade source file exists + if [[ ! -f "$PVE_SENSOR_INFO_SOURCE_FILE" ]]; then + err "Source file not found: $PVE_SENSOR_INFO_SOURCE_FILE" + fi + + # Create PVEMod module directories + mkdir -p "$PVEMOD_INSTALL_DIR" || err "Failed to create directory: $PVEMOD_INSTALL_DIR" + mkdir -p "$PVEMOD_COLLECTOR_DIR" || err "Failed to create directory: $PVEMOD_COLLECTOR_DIR" + info "PVEMod module directories ready." + + # Copy the API facade module + 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 API facade to $PVE_SENSOR_INFO_MOD_FILE" + + # Copy the new modular support files + local -a module_files=( + "PVEMod_Config.pm:$PVEMOD_INSTALL_DIR/Config.pm" + "PVEMod_Utils.pm:$PVEMOD_INSTALL_DIR/Utils.pm" + "PVEMod_Store.pm:$PVEMOD_INSTALL_DIR/Store.pm" + "PVEMod_ProcessManager.pm:$PVEMOD_INSTALL_DIR/ProcessManager.pm" + "PVEMod_Collectors/Intel.pm:$PVEMOD_COLLECTOR_DIR/Intel.pm" + "PVEMod_Collectors/Nvidia.pm:$PVEMOD_COLLECTOR_DIR/Nvidia.pm" + "PVEMod_Collectors/LmSensors.pm:$PVEMOD_COLLECTOR_DIR/LmSensors.pm" + "PVEMod_Collectors/Amd.pm:$PVEMOD_COLLECTOR_DIR/Amd.pm" + "PVEMod_Collectors/Ups.pm:$PVEMOD_COLLECTOR_DIR/Ups.pm" + "PVEMod_Collectors/systemInformation.pm:$PVEMOD_COLLECTOR_DIR/SystemInformation.pm" + ) + for entry in "${module_files[@]}"; do + local src="$PVEMOD_SOURCES_DIR/${entry%%:*}" + local dst="${entry##*:}" + if [[ ! -f "$src" ]]; then + err "Module source file not found: $src" + fi + cp "$src" "$dst" || err "Failed to copy $src to $dst" + info "Copied $(basename "$src") to $dst" + done + + # 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) + system_info_enabled=$([[ "$ENABLE_SYSTEM_INFO" = true ]] && echo 1 || echo 0) + system_info_type="${SYSTEM_INFO_TYPE:-1}" + + # Determine UPS device name + local ups_device="${upsConnection:-ups@localhost}" + + # Patch configuration values in Config.pm (the canonical config location) + sed -i " + /intel_enabled =>/ s/=> [01],/=> $intel_enabled,/ + /nvidia_enabled =>/ s/=> [01],/=> $nvidia_enabled,/ + /enabled =>/ s/=> [01],/=> $ups_enabled,/ + /device_name =>/ s|=> '[^']*',|=> '$ups_device',| + " "$PVEMOD_CONFIG_FILE" + # Patch system_info config block + sed -i " + /system_info/,/}/{/enabled =>/ s/=> [01],/=> $system_info_enabled,/} + /type[[:space:]]*=>/ s/=> [12],/=> $system_info_type,/ + " "$PVEMOD_CONFIG_FILE" + + if [[ $? -eq 0 ]]; then + info "Config.pm patched successfully." + else + warn "Failed to patch Config.pm settings." + fi +} +#endregion Sensor Monitor Module Installation + +#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 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();\ + $res->{pveMod_sensorInfo_systemInfo} = PVE::API2::PVEMod_SensorInfo::get_system_information();\ + ' "$NODES_PM_FILE" + #endregion PveSensorInfoMod heredoc + info "Sensor data retriever added to \"$NODES_PM_FILE\"." +} + +#endregion node info insertion + +#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 + + # 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" + + # 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 + warn "Original StatusView definition not found in expected format in pvemanagerlib.js" + fi + + # 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 + + # 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 +} +#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},\\ + );\\ + },\\ +}); +}" "$NODES_PM_FILE" \ + || err "Failed to insert gpurrddata method in $NODES_PM_FILE" + + info "GPU historical data API endpoint added to $NODES_PM_FILE" +} +#endregion historical GPU data + +# Function to uninstall the modification +function uninstall_mod { + msgb "=== Uninstalling Mod ===" + + check_root_privileges + + 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 + + set_backup_directory + info "Restoring modified files..." + + # Find the latest Nodes.pm file using the find command + 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 + # 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." + else + warn "No Nodes.pm backup files found." + fi + + # 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 + # 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." + else + warn "No pvemanagerlib.js backup files found." + fi + + # 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 PVEMod support modules (Config, Utils, Store, ProcessManager, Collectors) + if [[ -d "$PVEMOD_INSTALL_DIR" ]]; then + msgb "Removing PVEMod module directory: $PVEMOD_INSTALL_DIR" + rm -rf "$PVEMOD_INSTALL_DIR" + info "Removed PVEMod module directory." + 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 + + ask "Clear the browser cache to ensure all changes are visualized. (any key to continue)" +} + +# Function to check if the modification is installed +check_mod_installation() { + 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 +} + +function restart_proxy { + # Restart pveproxy + info "Restarting PVE proxy..." + systemctl restart pveproxy +} + +function save_sensors_data { + msgb "=== Saving Sensors Data ===" + + # Check if JSON_EXPORT_DIRECTORY exists and is writable + if [[ ! -d "$JSON_EXPORT_DIRECTORY" || ! -w "$JSON_EXPORT_DIRECTORY" ]]; then + err "Directory $JSON_EXPORT_DIRECTORY does not exist or is not writable. No file could be saved." + return + fi + + + # Check if command exists + if (command -v sensors &>/dev/null); then + # Save sensors output + local debug_save_filename="sensorsdata.json" + local filepath="${JSON_EXPORT_DIRECTORY}/${debug_save_filename}" + msgb "Sensors data will be saved in $filepath" + + # Prompt user for confirmation + local choiceContinue=$(ask "Do you wish to continue? (Y/n)") + case "$choiceContinue" in + [yY]|"") + echo "lm-sensors raw output:" >"$filepath" + sensorsOutput=$(sensors -j 2>/dev/null) + echo "$sensorsOutput" >>"$filepath" + echo -e "\n\nSanitised lm-sensors output:" >>"$filepath" + # Apply lm-sensors sanitization + sanitisedSensorsOutput=$(sanitize_sensors_output "$sensorsOutput") + echo "$sanitisedSensorsOutput" >>"$filepath" + info "Sensors data saved in $filepath." + ;; + *) + warn "Operation cancelled by user." + ;; + esac + else + err "Sensors is not installed. No file could be saved." + fi + echo +} + +function set_backup_directory { + # Check if the BACKUP_DIR variable is set, if not, use the default backup + if [[ -z "$BACKUP_DIR" ]]; then + # If not set, use the default backup directory, which is based on the home directory and PVE-MODS + BACKUP_DIR="$HOME/PVE-MODS" + info "Using default backup directory: $BACKUP_DIR" + else + # If set, ensure it is a valid directory + if [[ ! -d "$BACKUP_DIR" ]]; then + err "The specified backup directory does not exist: $BACKUP_DIR" + fi + info "Using custom backup directory: $BACKUP_DIR" + fi +} + +function create_backup_directory { + set_backup_directory + + # Create the backup directory if it does not exist + if [[ ! -d "$BACKUP_DIR" ]]; then + mkdir -p "$BACKUP_DIR" 2>/dev/null || { + err "Failed to create backup directory: $BACKUP_DIR. Please check permissions." + } + info "Created backup directory: $BACKUP_DIR" + else + info "Backup directory already exists: $BACKUP_DIR" + fi +} + +function create_file_backup() { + local source_file="$1" + local timestamp="$2" + local filename + + filename=$(basename "$source_file") + local backup_file="$BACKUP_DIR/${filename}.$timestamp" + + [[ -f "$source_file" ]] || err "Source file does not exist: $source_file" + [[ -r "$source_file" ]] || err "Cannot read source file: $source_file" + + cp "$source_file" "$backup_file" || err "Failed to create backup: $backup_file" + + # Verify backup integrity + if ! cmp -s "$source_file" "$backup_file"; then + err "Backup verification failed for: $backup_file" + fi + + info "Created backup: $backup_file" +} + +function perform_backup { + local timestamp + timestamp=$(date +%Y%m%d_%H%M%S) + + msgb "\n=== Creating backups of modified files ===" + + create_backup_directory + create_file_backup "$NODES_PM_FILE" "$timestamp" + create_file_backup "$PVE_MANAGER_LIB_JS_FILE" "$timestamp" + + # 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 +executed=0 +while [[ $# -gt 0 ]]; do + case "$1" in + install) + executed=$(($executed + 1)) + msgb "\nInstalling the Proxmox VE sensors display mod..." + install_packages + install_mod + ;; + uninstall) + executed=$(($executed + 1)) + msgb "\nUninstalling the Proxmox VE sensors display mod..." + uninstall_mod + ;; + save-sensors-data) + executed=$(($executed + 1)) + msgb "\nSaving current sensor readings in a file for debugging..." + save_sensors_data + ;; + esac + shift +done + +# If no arguments were provided or all arguments have been processed, print the usage message +if [[ $executed -eq 0 ]]; then + usage +fi \ No newline at end of file diff --git a/src/PVENodeInfo/readme.md b/src/PVENodeInfo/readme.md new file mode 100644 index 0000000..5c3effd --- /dev/null +++ b/src/PVENodeInfo/readme.md @@ -0,0 +1,3 @@ +## Draft code +This version of PVEMod is as draft version and may or may not be fully functional. +The installer is currently not working. \ No newline at end of file