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