chore: enforce LF line endings via .gitattributes (#206)

* tes5

* chore: enforce LF line endings via .gitattributes

---------

Co-authored-by: Meliox <na>
This commit is contained in:
Meliox 2026-06-11 22:47:53 +02:00 committed by GitHub
parent e05ff6f2fb
commit abe8dcfee8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 4628 additions and 4628 deletions

4
.gitattributes vendored
View File

@ -1,2 +1,2 @@
# All files must use LF
* text=auto eol=lf
# All files must use LF
* text=auto eol=lf

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,10 @@
# pve-mod :: nag_screen file manifest
# Maps files in this directory to their installation destinations.
# Format: <source> <destination> [permission]
# source - path relative to this files/ directory
# destination - path relative to the package root (no leading slash)
# permission - octal mode, optional (defaults to 644)
# Read by src/gen-rules.sh to generate the per-module debian install rules.
#
# The nag_screen mod ships no new files - it only patches existing Proxmox
# files - so this manifest is intentionally empty.
# pve-mod :: nag_screen file manifest
# Maps files in this directory to their installation destinations.
# Format: <source> <destination> [permission]
# source - path relative to this files/ directory
# destination - path relative to the package root (no leading slash)
# permission - octal mode, optional (defaults to 644)
# Read by src/gen-rules.sh to generate the per-module debian install rules.
#
# The nag_screen mod ships no new files - it only patches existing Proxmox
# files - so this manifest is intentionally empty.

View File

@ -1,3 +1,3 @@
# pve-mod :: nag_screen mod configuration
# The nag-screen mod has no tunable settings; this file is a placeholder
# kept for consistency with the per-mod conf.d layout.
# pve-mod :: nag_screen mod configuration
# The nag-screen mod has no tunable settings; this file is a placeholder
# kept for consistency with the per-mod conf.d layout.

View File

@ -1,5 +1,5 @@
# pve-mod :: nag_screen patch manifest
# Format: <patch-file> [section.key=value]
01-proxmoxlib-js-nagscreen.patch
02-index-html-tpl-mobilenag.patch
# pve-mod :: nag_screen patch manifest
# Format: <patch-file> [section.key=value]
01-proxmoxlib-js-nagscreen.patch
02-index-html-tpl-mobilenag.patch

View File

@ -1,33 +1,33 @@
package PVE::PVEMod::Collector::Amd;
use strict;
use warnings;
use Exporter 'import';
use PVE::PVEMod::Config qw($process_type);
use PVE::PVEMod::Utils qw(debug);
our @EXPORT_OK = qw(
get_amd_gpu_devices
collector_for_amd_device
);
# ============================================================================
# AMD GPU — placeholders (not yet implemented)
# ============================================================================
sub get_amd_gpu_devices {
# TODO: Implement AMD GPU detection using rocminfo or rocm-smi
debug(__LINE__, "AMD GPU support not yet implemented");
return ();
}
sub collector_for_amd_device {
my ($device) = @_;
$process_type = 'collector';
# TODO: Implement AMD GPU collector
debug(__LINE__, "AMD GPU collector not yet implemented");
exit 0;
}
1;
package PVE::PVEMod::Collector::Amd;
use strict;
use warnings;
use Exporter 'import';
use PVE::PVEMod::Config qw($process_type);
use PVE::PVEMod::Utils qw(debug);
our @EXPORT_OK = qw(
get_amd_gpu_devices
collector_for_amd_device
);
# ============================================================================
# AMD GPU — placeholders (not yet implemented)
# ============================================================================
sub get_amd_gpu_devices {
# TODO: Implement AMD GPU detection using rocminfo or rocm-smi
debug(__LINE__, "AMD GPU support not yet implemented");
return ();
}
sub collector_for_amd_device {
my ($device) = @_;
$process_type = 'collector';
# TODO: Implement AMD GPU collector
debug(__LINE__, "AMD GPU collector not yet implemented");
exit 0;
}
1;

View File

@ -1,179 +1,179 @@
package PVE::PVEMod::Collector::Intel;
use strict;
use warnings;
use Exporter 'import';
use PVE::PVEMod::Config qw(%config $process_type $pve_mod_working_dir);
use PVE::PVEMod::Utils qw(debug check_executable setup_collector_signals safe_write_json);
use PVE::PVEMod::Store qw(update_intel_gpu_rrd);
our @EXPORT_OK = qw(
get_intel_gpu_devices
collector_for_intel_device
);
# ============================================================================
# Intel GPU — device discovery
# ============================================================================
sub get_intel_gpu_devices {
my @devices = ();
debug(__LINE__, "Getting Intel GPU devices");
if (open my $fh, '-|', 'intel_gpu_top -L') {
while (<$fh>) {
chomp;
# Parse: "card0 Intel Alderlake_n (Gen12) pci:vendor=8086,device=46D0,card=0"
# or: "card0 Intel Alderlake_n (Gen12) pci:0000:00:02.0"
if (/^(card\d+)\s+(.+?)\s+(pci:[^\s]+)/) {
my ($card, $name, $path) = ($1, $2, $3);
push @devices, {
card => $card,
name => $name,
path => $path,
drm_path => "/dev/dri/$card",
};
debug(__LINE__, "Found Intel device: $card -> $name ($path)");
}
}
close $fh;
} else {
debug(__LINE__, "Failed to run intel_gpu_top -L: $!");
}
return @devices;
}
# ============================================================================
# Intel GPU — data parsing
# ============================================================================
sub _parse_intel_gpu_line {
my ($line) = @_;
# Expected format (whitespace-aligned columns):
# Freq MHz IRQ RC6 Power W RCS BCS VCS VECS
# req act /s % gpu pkg % se wa % se wa % se wa % se wa
# 0 0 0 0 0.00 7.47 0.00 0 0 0.00 0 0 0.00 0 0 0.00 0 0
$line =~ s/^\s+|\s+$//g;
my @values = grep { $_ ne '' } split(/\s+/, $line);
return unless @values >= 18;
return {
frequency => {
requested => $values[0] + 0.0,
actual => $values[1] + 0.0,
unit => "MHz",
},
interrupts => {
count => $values[2] + 0.0,
unit => "irq/s",
},
rc6 => {
value => $values[3] + 0.0,
unit => "%",
},
power => {
GPU => $values[4] + 0.0,
Package => $values[5] + 0.0,
unit => "W",
},
engines => {
'Render/3D' => {
busy => $values[6] + 0.0,
sema => $values[7] + 0.0,
wait => $values[8] + 0.0,
unit => "%",
},
Blitter => {
busy => $values[9] + 0.0,
sema => $values[10] + 0.0,
wait => $values[11] + 0.0,
unit => "%",
},
Video => {
busy => $values[12] + 0.0,
sema => $values[13] + 0.0,
wait => $values[14] + 0.0,
unit => "%",
},
VideoEnhance => {
busy => $values[15] + 0.0,
sema => $values[16] + 0.0,
wait => $values[17] + 0.0,
unit => "%",
},
},
clients => {},
};
}
# ============================================================================
# Intel GPU — long-running collector
# ============================================================================
sub collector_for_intel_device {
my ($device) = @_;
$process_type = 'collector';
$0 = "collector-gpu-intel-$device->{card}";
my $drm_dev = "drm:/dev/dri/$device->{card}";
my $intel_gpu_top_pid = undef;
my $device_state_file = "$pve_mod_working_dir/stats-$device->{card}.json";
debug(__LINE__, "Collector started for device: $drm_dev, writing to $device_state_file");
my $shutdown = 0;
setup_collector_signals($device->{card}, \$shutdown, sub {
kill 'TERM', $intel_gpu_top_pid
if defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0;
});
debug(__LINE__, "About to open pipe to intel_gpu_top");
my $intel_pull_interval = $config{intervals}{data_pull} * 1000; # milliseconds
$intel_gpu_top_pid = open(my $fh, '-|',
"intel_gpu_top -d $drm_dev -s $intel_pull_interval -l 2>&1");
unless (defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0) {
debug(__LINE__, "Failed to run intel_gpu_top for $drm_dev: $!");
exit 1;
}
debug(__LINE__, "Pipe opened successfully, PID=$intel_gpu_top_pid");
my $node_name = "node0";
while (my $line = <$fh>) {
last if $shutdown;
chomp $line;
next if $line =~ /MHz|IRQ|RC6|Power|RCS|BCS|VCS|VECS|req\s+act|^\s*$/;
if ($line =~ /^\s*[\d\s\.]+$/) {
my $stats = _parse_intel_gpu_line($line);
if ($stats) {
my $device_data = {
$node_name => {
name => $device->{name},
device_path => $device->{path},
drm_path => $device->{drm_path},
stats => $stats,
}
};
safe_write_json($device_state_file, $device_data);
update_intel_gpu_rrd($device->{card}, $stats);
}
}
}
close $fh;
debug(__LINE__, "Collector for $device->{card} shutting down");
exit 0;
}
1;
package PVE::PVEMod::Collector::Intel;
use strict;
use warnings;
use Exporter 'import';
use PVE::PVEMod::Config qw(%config $process_type $pve_mod_working_dir);
use PVE::PVEMod::Utils qw(debug check_executable setup_collector_signals safe_write_json);
use PVE::PVEMod::Store qw(update_intel_gpu_rrd);
our @EXPORT_OK = qw(
get_intel_gpu_devices
collector_for_intel_device
);
# ============================================================================
# Intel GPU — device discovery
# ============================================================================
sub get_intel_gpu_devices {
my @devices = ();
debug(__LINE__, "Getting Intel GPU devices");
if (open my $fh, '-|', 'intel_gpu_top -L') {
while (<$fh>) {
chomp;
# Parse: "card0 Intel Alderlake_n (Gen12) pci:vendor=8086,device=46D0,card=0"
# or: "card0 Intel Alderlake_n (Gen12) pci:0000:00:02.0"
if (/^(card\d+)\s+(.+?)\s+(pci:[^\s]+)/) {
my ($card, $name, $path) = ($1, $2, $3);
push @devices, {
card => $card,
name => $name,
path => $path,
drm_path => "/dev/dri/$card",
};
debug(__LINE__, "Found Intel device: $card -> $name ($path)");
}
}
close $fh;
} else {
debug(__LINE__, "Failed to run intel_gpu_top -L: $!");
}
return @devices;
}
# ============================================================================
# Intel GPU — data parsing
# ============================================================================
sub _parse_intel_gpu_line {
my ($line) = @_;
# Expected format (whitespace-aligned columns):
# Freq MHz IRQ RC6 Power W RCS BCS VCS VECS
# req act /s % gpu pkg % se wa % se wa % se wa % se wa
# 0 0 0 0 0.00 7.47 0.00 0 0 0.00 0 0 0.00 0 0 0.00 0 0
$line =~ s/^\s+|\s+$//g;
my @values = grep { $_ ne '' } split(/\s+/, $line);
return unless @values >= 18;
return {
frequency => {
requested => $values[0] + 0.0,
actual => $values[1] + 0.0,
unit => "MHz",
},
interrupts => {
count => $values[2] + 0.0,
unit => "irq/s",
},
rc6 => {
value => $values[3] + 0.0,
unit => "%",
},
power => {
GPU => $values[4] + 0.0,
Package => $values[5] + 0.0,
unit => "W",
},
engines => {
'Render/3D' => {
busy => $values[6] + 0.0,
sema => $values[7] + 0.0,
wait => $values[8] + 0.0,
unit => "%",
},
Blitter => {
busy => $values[9] + 0.0,
sema => $values[10] + 0.0,
wait => $values[11] + 0.0,
unit => "%",
},
Video => {
busy => $values[12] + 0.0,
sema => $values[13] + 0.0,
wait => $values[14] + 0.0,
unit => "%",
},
VideoEnhance => {
busy => $values[15] + 0.0,
sema => $values[16] + 0.0,
wait => $values[17] + 0.0,
unit => "%",
},
},
clients => {},
};
}
# ============================================================================
# Intel GPU — long-running collector
# ============================================================================
sub collector_for_intel_device {
my ($device) = @_;
$process_type = 'collector';
$0 = "collector-gpu-intel-$device->{card}";
my $drm_dev = "drm:/dev/dri/$device->{card}";
my $intel_gpu_top_pid = undef;
my $device_state_file = "$pve_mod_working_dir/stats-$device->{card}.json";
debug(__LINE__, "Collector started for device: $drm_dev, writing to $device_state_file");
my $shutdown = 0;
setup_collector_signals($device->{card}, \$shutdown, sub {
kill 'TERM', $intel_gpu_top_pid
if defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0;
});
debug(__LINE__, "About to open pipe to intel_gpu_top");
my $intel_pull_interval = $config{intervals}{data_pull} * 1000; # milliseconds
$intel_gpu_top_pid = open(my $fh, '-|',
"intel_gpu_top -d $drm_dev -s $intel_pull_interval -l 2>&1");
unless (defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0) {
debug(__LINE__, "Failed to run intel_gpu_top for $drm_dev: $!");
exit 1;
}
debug(__LINE__, "Pipe opened successfully, PID=$intel_gpu_top_pid");
my $node_name = "node0";
while (my $line = <$fh>) {
last if $shutdown;
chomp $line;
next if $line =~ /MHz|IRQ|RC6|Power|RCS|BCS|VCS|VECS|req\s+act|^\s*$/;
if ($line =~ /^\s*[\d\s\.]+$/) {
my $stats = _parse_intel_gpu_line($line);
if ($stats) {
my $device_data = {
$node_name => {
name => $device->{name},
device_path => $device->{path},
drm_path => $device->{drm_path},
stats => $stats,
}
};
safe_write_json($device_state_file, $device_data);
update_intel_gpu_rrd($device->{card}, $stats);
}
}
}
close $fh;
debug(__LINE__, "Collector for $device->{card} shutting down");
exit 0;
}
1;

View File

@ -1,417 +1,417 @@
package PVE::PVEMod::Collector::LmSensors;
use strict;
use warnings;
use Exporter 'import';
use JSON;
use PVE::PVEMod::Config qw(%config $process_type $sensors_state_file);
use PVE::PVEMod::Utils qw(debug check_executable setup_collector_signals read_sysfs);
our @EXPORT_OK = qw(
collector_for_temperature_sensors
);
# ============================================================================
# Temperature Sensors — long-running collector
# ============================================================================
sub collector_for_temperature_sensors {
my ($device) = @_;
$process_type = 'collector';
$0 = "collector-temperature-sensors";
my %cache;
my $shutdown = 0;
setup_collector_signals('temperature-sensors', \$shutdown);
while (!$shutdown) {
my $sensors_data = _get_temperature_sensors(\%cache);
eval {
open my $ofh, '>', $sensors_state_file
or die "Failed to open $sensors_state_file: $!";
print $ofh $sensors_data;
close $ofh;
debug(__LINE__, "Wrote temperature sensor data to $sensors_state_file");
};
if ($@) {
debug(__LINE__, "Error writing temperature sensor data: $@");
}
sleep $config{intervals}{data_pull} unless $shutdown;
}
debug(__LINE__, "Temperature sensor collector shutting down");
exit 0;
}
# ============================================================================
# Temperature Sensors — pipeline
# ============================================================================
sub _get_temperature_sensors {
my ($cache_ref) = @_;
my $sensors_output;
if ($config{debug}{lm_sensors_mode} && -f $config{debug}{lm_sensors_output_file}) {
debug(__LINE__, "Debug mode: reading lm-sensors data from $config{debug}{lm_sensors_output_file}");
if (open my $fh, '<', $config{debug}{lm_sensors_output_file}) {
local $/;
$sensors_output = <$fh>;
close $fh;
debug(__LINE__, "Read lm-sensors data from debug file, length: "
. length($sensors_output) . " bytes");
} else {
debug(__LINE__, "Failed to open debug file $config{debug}{lm_sensors_output_file}: $!");
$sensors_output = '{}';
}
} else {
$sensors_output = `sensors -j 2>/dev/null | python3 -m json.tool`;
debug(__LINE__, "Raw lm-sensors output collected from command");
}
debug(__LINE__, "Raw lm-sensors output collected");
my $data = _sanitize_sensors($sensors_output);
debug(__LINE__, "Sanitized lm-sensors output");
$data = _get_drive_names($data, $cache_ref);
debug(__LINE__, "Translated drive names in lm-sensors output");
$data = _get_cpu_name($data, $cache_ref);
debug(__LINE__, "Translated CPU names in lm-sensors output");
# Wrap in top-level key
my $sensors_json;
eval { $sensors_json = decode_json($data); };
if ($@) {
debug(__LINE__, "Failed to parse final lm-sensors JSON: $@");
return $data;
}
$data = JSON->new->pretty->encode({ "PVE MOD lm-sensors Enhanced" => $sensors_json });
return $data;
}
# ============================================================================
# Sanitize raw lm-sensors JSON
# ============================================================================
sub _sanitize_sensors {
my ($sensors_output) = @_;
$sensors_output =~ s/ERROR:.+\s(\w+):\s(.+)/\"$1\": 0.000,/g;
$sensors_output =~ s/ERROR:.+\s(\w+)!/\"$1\": 0.000,/g;
$sensors_output =~ s/,\s*(})/$1/g;
$sensors_output =~ s/\bNaN\b/null/g;
# Fix duplicate SODIMM keys: "SODIMM":{"temp3_input":34.0} → "SODIMM3":{...}
$sensors_output =~
s/\"SODIMM\":\{\"temp(\d+)_input\"/\"SODIMM$1\":\{\"temp$1_input\"/g;
return $sensors_output;
}
# ============================================================================
# Enrich lm-sensors data with drive device info
# ============================================================================
sub _get_drive_names {
my ($sensors_output, $cache_ref) = @_;
$cache_ref //= {};
my $sensors_data;
eval { $sensors_data = decode_json($sensors_output); };
if ($@) {
debug(__LINE__, "Failed to parse sensors JSON: $@");
return $sensors_output;
}
my @entries = grep {
/^drivetemp-scsi-/ || /^drivetemp-nvme-/ || /^nvme-pci-/
} keys %{$sensors_data};
debug(__LINE__, "Found " . scalar(@entries) . " drive entries in lm-sensors output");
my @drive_names;
foreach my $entry (@entries) {
my ($dev_path, $model, $serial) = ("unknown", "unknown", "unknown");
if (exists $cache_ref->{$entry}) {
my $cached = $cache_ref->{$entry};
$dev_path = $cached->{device_path};
$model = $cached->{model};
$serial = $cached->{serial};
debug(__LINE__, "Using cached drive info for $entry");
} else {
# ----- SCSI/SATA -----
if ($entry =~ /^drivetemp-scsi-(\d+)-(\d+)/) {
my ($host, $id) = ($1, $2);
my $scsi_path = "/sys/class/scsi_disk/$host:$id:0:0/device/block";
if (opendir(my $sdh, $scsi_path)) {
my @devs = grep { /^sd/ } readdir($sdh);
closedir($sdh);
if (@devs) {
$dev_path = "/dev/$devs[0]";
$model = read_sysfs("/sys/class/block/$devs[0]/device/model");
$serial = read_sysfs("/sys/class/block/$devs[0]/device/serial");
}
}
# ----- Numeric NVMe -----
} elsif ($entry =~ /^drivetemp-nvme-(\d+)/) {
my $nvme_index = $1;
$dev_path = "/dev/nvme${nvme_index}n1";
if (-e $dev_path) {
$model = read_sysfs("/sys/class/block/nvme${nvme_index}n1/device/model");
$serial = read_sysfs("/sys/class/block/nvme${nvme_index}n1/device/serial");
}
# ----- PCI-style NVMe -----
} elsif ($entry =~ /^nvme-pci-(\w+)/) {
my $pci_addr = $1;
# Convert short PCI address (e.g. "0600") to pattern (e.g. "0000:06:00")
my $pci_pattern;
if ($pci_addr =~ /^([0-9a-f]{2})([0-9a-f]{2})$/i) {
my ($bus, $dev) = ($1, $2);
$pci_pattern = sprintf("%04x:%02x:%02x", 0, hex($bus), hex($dev));
debug(__LINE__, "Converted PCI address $pci_addr to pattern $pci_pattern");
} else {
$pci_pattern = $pci_addr;
}
my $found = 0;
my $nvme_dir = "/sys/class/nvme";
debug(__LINE__,
"Searching for NVMe devices in $nvme_dir matching PCI pattern $pci_pattern");
if (opendir(my $ndh, $nvme_dir)) {
my @nvme_devs =
grep { /^nvme\d+$/ && -d "$nvme_dir/$_" } readdir($ndh);
closedir($ndh);
debug(__LINE__, "Found NVMe devices: " . join(", ", @nvme_devs));
foreach my $nvme_dev (@nvme_devs) {
my $device_link = readlink("$nvme_dir/$nvme_dev/device");
if ($device_link && $device_link =~ /$pci_pattern/) {
debug(__LINE__,
"NVMe device $nvme_dev matches PCI pattern $pci_pattern");
$dev_path = "/dev/${nvme_dev}n1";
$model = read_sysfs("$nvme_dir/$nvme_dev/model");
$serial = read_sysfs("$nvme_dir/$nvme_dev/serial");
$found = 1;
debug(__LINE__,
"Found NVMe device via /sys/class/nvme: $dev_path");
last;
}
debug(__LINE__,
"NVMe device $nvme_dev did not match PCI pattern $pci_pattern");
}
}
# Fallback: scan /sys/class/block
if (!$found && opendir(my $bdh, "/sys/class/block")) {
my @block_devs = grep { /^nvme\d+n\d+$/ } readdir($bdh);
closedir($bdh);
foreach my $block_dev (@block_devs) {
my $device_link =
readlink("/sys/class/block/$block_dev/device");
if ($device_link && $device_link =~ /$pci_pattern/) {
$dev_path = "/dev/$block_dev";
(my $nvme_ctrl = $block_dev) =~ s/n\d+$//;
$model = read_sysfs("/sys/class/nvme/$nvme_ctrl/model");
$serial = read_sysfs("/sys/class/nvme/$nvme_ctrl/serial");
$found = 1;
debug(__LINE__,
"Found NVMe device via /sys/class/block: $dev_path");
last;
}
}
}
unless ($found) {
debug(__LINE__,
"Could not find device for nvme-pci-$pci_addr (pattern: $pci_pattern)");
}
} else {
next;
}
$cache_ref->{$entry} = {
device_path => $dev_path,
model => $model,
serial => $serial,
};
debug(__LINE__, "Drive: $entry -> $dev_path (Model: $model, Serial: $serial)");
}
push @drive_names, [$entry, $dev_path, $model, $serial];
}
foreach my $drive_entry (@drive_names) {
my ($original_name, $dev_path, $model, $serial) = @$drive_entry;
if (exists $sensors_data->{$original_name}) {
$sensors_data->{$original_name}->{device_path} = $dev_path;
$sensors_data->{$original_name}->{model} = $model;
$sensors_data->{$original_name}->{serial} = $serial;
debug(__LINE__, "Enhanced $original_name with drive info");
}
}
return JSON->new->pretty->canonical->encode($sensors_data);
}
# ============================================================================
# Enrich lm-sensors data with CPU model info
# ============================================================================
sub _get_cpu_name {
my ($sensors_output, $cache_ref) = @_;
$cache_ref //= {};
my $sensors_data;
eval { $sensors_data = decode_json($sensors_output); };
if ($@) {
debug(__LINE__, "Failed to parse sensors JSON: $@");
return $sensors_output;
}
my @entries =
grep { /^coretemp-isa-/ || /^k10temp-pci-/ } keys %{$sensors_data};
debug(__LINE__, "Found " . scalar(@entries) . " CPU entries in sensors output");
foreach my $entry (@entries) {
my ($cpu_model, $pkg) = ("unknown", "unknown");
if (exists $cache_ref->{$entry}) {
my $cached = $cache_ref->{$entry};
$cpu_model = $cached->{model};
$pkg = $cached->{package};
debug(__LINE__, "Using cached CPU info for $entry");
} else {
# ----- Intel coretemp -----
if ($entry =~ /^coretemp-isa-(\d+)/) {
for my $hwmon (glob "/sys/class/hwmon/hwmon*") {
my $name = read_sysfs("$hwmon/name");
next unless $name eq 'coretemp';
my $dev = readlink("$hwmon/device");
next unless $dev;
if ($dev =~ /\.([0-9]+)$/) {
$pkg = $1;
$cpu_model = _cpu_model_by_package($pkg);
debug(__LINE__,
"Found Intel CPU: $entry -> Package $pkg, Model: $cpu_model");
last;
}
}
}
# ----- AMD k10temp -----
elsif ($entry =~ /^k10temp-pci-(\w+)/) {
my $pci_addr = $1;
my $pci_pattern = $pci_addr;
if ($pci_addr =~ /^([0-9a-f]{2})([0-9a-f]{2})$/i) {
my ($bus, $dev_func) = ($1, $2);
$pci_pattern =
sprintf("%04x:%02x:%02x", 0, hex($bus), hex($dev_func));
debug(__LINE__,
"Converted PCI address $pci_addr to pattern $pci_pattern");
}
for my $hwmon (glob "/sys/class/hwmon/hwmon*") {
my $name = read_sysfs("$hwmon/name");
next unless $name eq 'k10temp';
my $dev = readlink("$hwmon/device");
next unless $dev;
if ($dev =~ /$pci_pattern/ || $dev =~ /$pci_addr/) {
$pkg = 0;
if (opendir(my $dh, "/sys/devices/system/cpu")) {
my @cpus = grep { /^cpu\d+$/ } readdir($dh);
closedir($dh);
foreach my $cpu (@cpus) {
my $cpu_pkg = read_sysfs(
"/sys/devices/system/cpu/$cpu/topology/physical_package_id");
if ($cpu_pkg ne "unknown" && $cpu_pkg =~ /^\d+$/) {
$pkg = $cpu_pkg;
last;
}
}
}
$cpu_model = _cpu_model_by_package($pkg);
debug(__LINE__,
"Found AMD CPU: $entry -> Package $pkg, Model: $cpu_model");
last;
}
}
}
$cache_ref->{$entry} = { model => $cpu_model, package => $pkg };
debug(__LINE__, "CPU: $entry -> Package $pkg (Model: $cpu_model)");
}
if (exists $sensors_data->{$entry}) {
$sensors_data->{$entry}->{cpu_model} = $cpu_model;
$sensors_data->{$entry}->{cpu_package} = $pkg;
debug(__LINE__, "Enhanced $entry with CPU info");
}
}
return JSON->new->pretty->canonical->encode($sensors_data);
}
# ============================================================================
# CPU model lookup helper
# ============================================================================
sub _cpu_model_by_package {
my ($pkg) = @_;
if (open my $fh, '<', '/proc/cpuinfo') {
my $current_pkg = -1;
my $model_name = "unknown";
while (my $line = <$fh>) {
chomp $line;
if ($line =~ /^physical id\s+:\s+(\d+)/) {
$current_pkg = $1;
}
if ($line =~ /^model name\s+:\s+(.+)$/) {
$model_name = $1;
$model_name =~ s/^\s+|\s+$//g;
if ($current_pkg == $pkg) {
close($fh);
return $model_name;
}
}
}
close($fh);
return $model_name if $model_name ne "unknown";
}
return "unknown";
}
1;
package PVE::PVEMod::Collector::LmSensors;
use strict;
use warnings;
use Exporter 'import';
use JSON;
use PVE::PVEMod::Config qw(%config $process_type $sensors_state_file);
use PVE::PVEMod::Utils qw(debug check_executable setup_collector_signals read_sysfs);
our @EXPORT_OK = qw(
collector_for_temperature_sensors
);
# ============================================================================
# Temperature Sensors — long-running collector
# ============================================================================
sub collector_for_temperature_sensors {
my ($device) = @_;
$process_type = 'collector';
$0 = "collector-temperature-sensors";
my %cache;
my $shutdown = 0;
setup_collector_signals('temperature-sensors', \$shutdown);
while (!$shutdown) {
my $sensors_data = _get_temperature_sensors(\%cache);
eval {
open my $ofh, '>', $sensors_state_file
or die "Failed to open $sensors_state_file: $!";
print $ofh $sensors_data;
close $ofh;
debug(__LINE__, "Wrote temperature sensor data to $sensors_state_file");
};
if ($@) {
debug(__LINE__, "Error writing temperature sensor data: $@");
}
sleep $config{intervals}{data_pull} unless $shutdown;
}
debug(__LINE__, "Temperature sensor collector shutting down");
exit 0;
}
# ============================================================================
# Temperature Sensors — pipeline
# ============================================================================
sub _get_temperature_sensors {
my ($cache_ref) = @_;
my $sensors_output;
if ($config{debug}{lm_sensors_mode} && -f $config{debug}{lm_sensors_output_file}) {
debug(__LINE__, "Debug mode: reading lm-sensors data from $config{debug}{lm_sensors_output_file}");
if (open my $fh, '<', $config{debug}{lm_sensors_output_file}) {
local $/;
$sensors_output = <$fh>;
close $fh;
debug(__LINE__, "Read lm-sensors data from debug file, length: "
. length($sensors_output) . " bytes");
} else {
debug(__LINE__, "Failed to open debug file $config{debug}{lm_sensors_output_file}: $!");
$sensors_output = '{}';
}
} else {
$sensors_output = `sensors -j 2>/dev/null | python3 -m json.tool`;
debug(__LINE__, "Raw lm-sensors output collected from command");
}
debug(__LINE__, "Raw lm-sensors output collected");
my $data = _sanitize_sensors($sensors_output);
debug(__LINE__, "Sanitized lm-sensors output");
$data = _get_drive_names($data, $cache_ref);
debug(__LINE__, "Translated drive names in lm-sensors output");
$data = _get_cpu_name($data, $cache_ref);
debug(__LINE__, "Translated CPU names in lm-sensors output");
# Wrap in top-level key
my $sensors_json;
eval { $sensors_json = decode_json($data); };
if ($@) {
debug(__LINE__, "Failed to parse final lm-sensors JSON: $@");
return $data;
}
$data = JSON->new->pretty->encode({ "PVE MOD lm-sensors Enhanced" => $sensors_json });
return $data;
}
# ============================================================================
# Sanitize raw lm-sensors JSON
# ============================================================================
sub _sanitize_sensors {
my ($sensors_output) = @_;
$sensors_output =~ s/ERROR:.+\s(\w+):\s(.+)/\"$1\": 0.000,/g;
$sensors_output =~ s/ERROR:.+\s(\w+)!/\"$1\": 0.000,/g;
$sensors_output =~ s/,\s*(})/$1/g;
$sensors_output =~ s/\bNaN\b/null/g;
# Fix duplicate SODIMM keys: "SODIMM":{"temp3_input":34.0} → "SODIMM3":{...}
$sensors_output =~
s/\"SODIMM\":\{\"temp(\d+)_input\"/\"SODIMM$1\":\{\"temp$1_input\"/g;
return $sensors_output;
}
# ============================================================================
# Enrich lm-sensors data with drive device info
# ============================================================================
sub _get_drive_names {
my ($sensors_output, $cache_ref) = @_;
$cache_ref //= {};
my $sensors_data;
eval { $sensors_data = decode_json($sensors_output); };
if ($@) {
debug(__LINE__, "Failed to parse sensors JSON: $@");
return $sensors_output;
}
my @entries = grep {
/^drivetemp-scsi-/ || /^drivetemp-nvme-/ || /^nvme-pci-/
} keys %{$sensors_data};
debug(__LINE__, "Found " . scalar(@entries) . " drive entries in lm-sensors output");
my @drive_names;
foreach my $entry (@entries) {
my ($dev_path, $model, $serial) = ("unknown", "unknown", "unknown");
if (exists $cache_ref->{$entry}) {
my $cached = $cache_ref->{$entry};
$dev_path = $cached->{device_path};
$model = $cached->{model};
$serial = $cached->{serial};
debug(__LINE__, "Using cached drive info for $entry");
} else {
# ----- SCSI/SATA -----
if ($entry =~ /^drivetemp-scsi-(\d+)-(\d+)/) {
my ($host, $id) = ($1, $2);
my $scsi_path = "/sys/class/scsi_disk/$host:$id:0:0/device/block";
if (opendir(my $sdh, $scsi_path)) {
my @devs = grep { /^sd/ } readdir($sdh);
closedir($sdh);
if (@devs) {
$dev_path = "/dev/$devs[0]";
$model = read_sysfs("/sys/class/block/$devs[0]/device/model");
$serial = read_sysfs("/sys/class/block/$devs[0]/device/serial");
}
}
# ----- Numeric NVMe -----
} elsif ($entry =~ /^drivetemp-nvme-(\d+)/) {
my $nvme_index = $1;
$dev_path = "/dev/nvme${nvme_index}n1";
if (-e $dev_path) {
$model = read_sysfs("/sys/class/block/nvme${nvme_index}n1/device/model");
$serial = read_sysfs("/sys/class/block/nvme${nvme_index}n1/device/serial");
}
# ----- PCI-style NVMe -----
} elsif ($entry =~ /^nvme-pci-(\w+)/) {
my $pci_addr = $1;
# Convert short PCI address (e.g. "0600") to pattern (e.g. "0000:06:00")
my $pci_pattern;
if ($pci_addr =~ /^([0-9a-f]{2})([0-9a-f]{2})$/i) {
my ($bus, $dev) = ($1, $2);
$pci_pattern = sprintf("%04x:%02x:%02x", 0, hex($bus), hex($dev));
debug(__LINE__, "Converted PCI address $pci_addr to pattern $pci_pattern");
} else {
$pci_pattern = $pci_addr;
}
my $found = 0;
my $nvme_dir = "/sys/class/nvme";
debug(__LINE__,
"Searching for NVMe devices in $nvme_dir matching PCI pattern $pci_pattern");
if (opendir(my $ndh, $nvme_dir)) {
my @nvme_devs =
grep { /^nvme\d+$/ && -d "$nvme_dir/$_" } readdir($ndh);
closedir($ndh);
debug(__LINE__, "Found NVMe devices: " . join(", ", @nvme_devs));
foreach my $nvme_dev (@nvme_devs) {
my $device_link = readlink("$nvme_dir/$nvme_dev/device");
if ($device_link && $device_link =~ /$pci_pattern/) {
debug(__LINE__,
"NVMe device $nvme_dev matches PCI pattern $pci_pattern");
$dev_path = "/dev/${nvme_dev}n1";
$model = read_sysfs("$nvme_dir/$nvme_dev/model");
$serial = read_sysfs("$nvme_dir/$nvme_dev/serial");
$found = 1;
debug(__LINE__,
"Found NVMe device via /sys/class/nvme: $dev_path");
last;
}
debug(__LINE__,
"NVMe device $nvme_dev did not match PCI pattern $pci_pattern");
}
}
# Fallback: scan /sys/class/block
if (!$found && opendir(my $bdh, "/sys/class/block")) {
my @block_devs = grep { /^nvme\d+n\d+$/ } readdir($bdh);
closedir($bdh);
foreach my $block_dev (@block_devs) {
my $device_link =
readlink("/sys/class/block/$block_dev/device");
if ($device_link && $device_link =~ /$pci_pattern/) {
$dev_path = "/dev/$block_dev";
(my $nvme_ctrl = $block_dev) =~ s/n\d+$//;
$model = read_sysfs("/sys/class/nvme/$nvme_ctrl/model");
$serial = read_sysfs("/sys/class/nvme/$nvme_ctrl/serial");
$found = 1;
debug(__LINE__,
"Found NVMe device via /sys/class/block: $dev_path");
last;
}
}
}
unless ($found) {
debug(__LINE__,
"Could not find device for nvme-pci-$pci_addr (pattern: $pci_pattern)");
}
} else {
next;
}
$cache_ref->{$entry} = {
device_path => $dev_path,
model => $model,
serial => $serial,
};
debug(__LINE__, "Drive: $entry -> $dev_path (Model: $model, Serial: $serial)");
}
push @drive_names, [$entry, $dev_path, $model, $serial];
}
foreach my $drive_entry (@drive_names) {
my ($original_name, $dev_path, $model, $serial) = @$drive_entry;
if (exists $sensors_data->{$original_name}) {
$sensors_data->{$original_name}->{device_path} = $dev_path;
$sensors_data->{$original_name}->{model} = $model;
$sensors_data->{$original_name}->{serial} = $serial;
debug(__LINE__, "Enhanced $original_name with drive info");
}
}
return JSON->new->pretty->canonical->encode($sensors_data);
}
# ============================================================================
# Enrich lm-sensors data with CPU model info
# ============================================================================
sub _get_cpu_name {
my ($sensors_output, $cache_ref) = @_;
$cache_ref //= {};
my $sensors_data;
eval { $sensors_data = decode_json($sensors_output); };
if ($@) {
debug(__LINE__, "Failed to parse sensors JSON: $@");
return $sensors_output;
}
my @entries =
grep { /^coretemp-isa-/ || /^k10temp-pci-/ } keys %{$sensors_data};
debug(__LINE__, "Found " . scalar(@entries) . " CPU entries in sensors output");
foreach my $entry (@entries) {
my ($cpu_model, $pkg) = ("unknown", "unknown");
if (exists $cache_ref->{$entry}) {
my $cached = $cache_ref->{$entry};
$cpu_model = $cached->{model};
$pkg = $cached->{package};
debug(__LINE__, "Using cached CPU info for $entry");
} else {
# ----- Intel coretemp -----
if ($entry =~ /^coretemp-isa-(\d+)/) {
for my $hwmon (glob "/sys/class/hwmon/hwmon*") {
my $name = read_sysfs("$hwmon/name");
next unless $name eq 'coretemp';
my $dev = readlink("$hwmon/device");
next unless $dev;
if ($dev =~ /\.([0-9]+)$/) {
$pkg = $1;
$cpu_model = _cpu_model_by_package($pkg);
debug(__LINE__,
"Found Intel CPU: $entry -> Package $pkg, Model: $cpu_model");
last;
}
}
}
# ----- AMD k10temp -----
elsif ($entry =~ /^k10temp-pci-(\w+)/) {
my $pci_addr = $1;
my $pci_pattern = $pci_addr;
if ($pci_addr =~ /^([0-9a-f]{2})([0-9a-f]{2})$/i) {
my ($bus, $dev_func) = ($1, $2);
$pci_pattern =
sprintf("%04x:%02x:%02x", 0, hex($bus), hex($dev_func));
debug(__LINE__,
"Converted PCI address $pci_addr to pattern $pci_pattern");
}
for my $hwmon (glob "/sys/class/hwmon/hwmon*") {
my $name = read_sysfs("$hwmon/name");
next unless $name eq 'k10temp';
my $dev = readlink("$hwmon/device");
next unless $dev;
if ($dev =~ /$pci_pattern/ || $dev =~ /$pci_addr/) {
$pkg = 0;
if (opendir(my $dh, "/sys/devices/system/cpu")) {
my @cpus = grep { /^cpu\d+$/ } readdir($dh);
closedir($dh);
foreach my $cpu (@cpus) {
my $cpu_pkg = read_sysfs(
"/sys/devices/system/cpu/$cpu/topology/physical_package_id");
if ($cpu_pkg ne "unknown" && $cpu_pkg =~ /^\d+$/) {
$pkg = $cpu_pkg;
last;
}
}
}
$cpu_model = _cpu_model_by_package($pkg);
debug(__LINE__,
"Found AMD CPU: $entry -> Package $pkg, Model: $cpu_model");
last;
}
}
}
$cache_ref->{$entry} = { model => $cpu_model, package => $pkg };
debug(__LINE__, "CPU: $entry -> Package $pkg (Model: $cpu_model)");
}
if (exists $sensors_data->{$entry}) {
$sensors_data->{$entry}->{cpu_model} = $cpu_model;
$sensors_data->{$entry}->{cpu_package} = $pkg;
debug(__LINE__, "Enhanced $entry with CPU info");
}
}
return JSON->new->pretty->canonical->encode($sensors_data);
}
# ============================================================================
# CPU model lookup helper
# ============================================================================
sub _cpu_model_by_package {
my ($pkg) = @_;
if (open my $fh, '<', '/proc/cpuinfo') {
my $current_pkg = -1;
my $model_name = "unknown";
while (my $line = <$fh>) {
chomp $line;
if ($line =~ /^physical id\s+:\s+(\d+)/) {
$current_pkg = $1;
}
if ($line =~ /^model name\s+:\s+(.+)$/) {
$model_name = $1;
$model_name =~ s/^\s+|\s+$//g;
if ($current_pkg == $pkg) {
close($fh);
return $model_name;
}
}
}
close($fh);
return $model_name if $model_name ne "unknown";
}
return "unknown";
}
1;

View File

@ -1,212 +1,212 @@
package PVE::PVEMod::Collector::Nvidia;
use strict;
use warnings;
use Exporter 'import';
use PVE::PVEMod::Config qw(%config $process_type $pve_mod_working_dir);
use PVE::PVEMod::Utils qw(debug check_executable setup_collector_signals safe_write_json parse_csv_line);
use PVE::PVEMod::Store qw(update_nvidia_gpu_rrd);
our @EXPORT_OK = qw(
get_nvidia_gpu_devices
collector_for_nvidia_devices
);
# ============================================================================
# NVIDIA GPU — device discovery
# ============================================================================
sub get_nvidia_gpu_devices {
my @devices = ();
if ($config{debug}{nvidia_mode} && -f $config{debug}{nvidia_devices_file}) {
debug(__LINE__, "Debug mode: reading NVIDIA GPU devices from $config{debug}{nvidia_devices_file}");
if (open my $fh, '<', $config{debug}{nvidia_devices_file}) {
my $line_num = 0;
while (<$fh>) {
chomp;
$line_num++;
next if $line_num == 1 || /^\s*$/;
my @values = parse_csv_line($_, 2);
if (@values) {
push @devices, { index => $values[0], name => $values[1] };
debug(__LINE__, "Found NVIDIA GPU device (debug): $values[1] (index: $values[0])");
}
}
close $fh;
} else {
debug(__LINE__, "Failed to open debug file $config{debug}{nvidia_devices_file}: $!");
}
} else {
if (open my $fh, '-|', 'nvidia-smi --query-gpu=index,name --format=csv') {
my $line_num = 0;
while (<$fh>) {
chomp;
$line_num++;
next if $line_num == 1 || /^\s*$/;
my @values = parse_csv_line($_, 2);
if (@values) {
push @devices, { index => $values[0], name => $values[1] };
debug(__LINE__, "Found NVIDIA GPU device: $values[1] (index: $values[0])");
}
}
close $fh;
}
}
return @devices;
}
# ============================================================================
# NVIDIA GPU — data parsing
# ============================================================================
sub _parse_nvidia_gpu_line {
my ($line) = @_;
# Expected CSV format:
# index, name, temperature.gpu, utilization.gpu, utilization.memory,
# memory.used, memory.total, power.draw, power.limit, fan.speed
my @values = parse_csv_line($line, 10);
return unless @values;
return {
index => $values[0] + 0,
name => $values[1],
temperature => {
gpu => $values[2] + 0.0,
unit => "°C",
},
utilization => {
gpu => $values[3] + 0.0,
memory => $values[4] + 0.0,
unit => "%",
},
memory => {
used => $values[5] + 0.0,
total => $values[6] + 0.0,
unit => "MiB",
},
power => {
draw => $values[7] + 0.0,
limit => $values[8] + 0.0,
unit => "W",
},
fan => {
speed => $values[9] + 0.0,
unit => "%",
},
};
}
# ============================================================================
# NVIDIA GPU — stat collection and write
# ============================================================================
sub _get_and_write_nvidia_stats {
my ($devices) = @_;
my @all_stats;
if ($config{debug}{nvidia_mode} && -f $config{debug}{nvidia_output_file}) {
debug(__LINE__, "Debug mode: reading NVIDIA GPU stats from $config{debug}{nvidia_output_file}");
if (open my $fh, '<', $config{debug}{nvidia_output_file}) {
my $line_num = 0;
while (<$fh>) {
chomp;
$line_num++;
next if $line_num == 1 || /^\s*$/;
my $stats = _parse_nvidia_gpu_line($_);
push @all_stats, $stats if $stats;
}
close $fh;
} else {
debug(__LINE__, "Failed to open debug file $config{debug}{nvidia_output_file}: $!");
}
} else {
unless (check_executable('/usr/bin/nvidia-smi', 'NVIDIA')) {
debug(__LINE__, "nvidia-smi not available, cannot collect stats");
return 0;
}
my $query = 'index,name,temperature.gpu,utilization.gpu,utilization.memory,'
. 'memory.used,memory.total,power.draw,power.limit,fan.speed';
my $cmd = "nvidia-smi --query-gpu=$query --format=csv,nounits";
if (open my $fh, '-|', $cmd) {
my $line_num = 0;
while (<$fh>) {
chomp;
$line_num++;
next if $line_num == 1 || /^\s*$/;
my $stats = _parse_nvidia_gpu_line($_);
push @all_stats, $stats if $stats;
}
close $fh;
}
}
foreach my $stats (@all_stats) {
my $device_index = $stats->{index};
unless ($device_index =~ /^(\d+)$/) {
debug(__LINE__, "Invalid device index: $device_index, skipping");
next;
}
$device_index = $1; # untainted
my $node_name = "gpu$device_index";
my $device_state_file = "$pve_mod_working_dir/stats-nvidia$device_index.json";
my $device_name = $stats->{name};
foreach my $dev (@$devices) {
if ($dev->{index} == $device_index) {
$device_name = $dev->{name};
last;
}
}
my $device_data = {
$node_name => {
name => $device_name,
index => $device_index,
stats => $stats,
}
};
safe_write_json($device_state_file, $device_data);
update_nvidia_gpu_rrd($device_index, $stats);
}
unless (@all_stats) {
debug(__LINE__, "No valid NVIDIA GPU stats collected");
}
return scalar(@all_stats);
}
# ============================================================================
# NVIDIA GPU — long-running collector (all devices in one process)
# ============================================================================
sub collector_for_nvidia_devices {
my ($devices) = @_;
$process_type = 'collector';
$0 = "collector-gpu-nvidia-all";
debug(__LINE__, "NVIDIA collector started for " . scalar(@$devices) . " GPU(s)");
my $shutdown = 0;
setup_collector_signals('nvidia-all', \$shutdown);
while (!$shutdown) {
_get_and_write_nvidia_stats($devices);
sleep $config{intervals}{data_pull} unless $shutdown;
}
debug(__LINE__, "NVIDIA collector shutting down");
exit 0;
}
1;
package PVE::PVEMod::Collector::Nvidia;
use strict;
use warnings;
use Exporter 'import';
use PVE::PVEMod::Config qw(%config $process_type $pve_mod_working_dir);
use PVE::PVEMod::Utils qw(debug check_executable setup_collector_signals safe_write_json parse_csv_line);
use PVE::PVEMod::Store qw(update_nvidia_gpu_rrd);
our @EXPORT_OK = qw(
get_nvidia_gpu_devices
collector_for_nvidia_devices
);
# ============================================================================
# NVIDIA GPU — device discovery
# ============================================================================
sub get_nvidia_gpu_devices {
my @devices = ();
if ($config{debug}{nvidia_mode} && -f $config{debug}{nvidia_devices_file}) {
debug(__LINE__, "Debug mode: reading NVIDIA GPU devices from $config{debug}{nvidia_devices_file}");
if (open my $fh, '<', $config{debug}{nvidia_devices_file}) {
my $line_num = 0;
while (<$fh>) {
chomp;
$line_num++;
next if $line_num == 1 || /^\s*$/;
my @values = parse_csv_line($_, 2);
if (@values) {
push @devices, { index => $values[0], name => $values[1] };
debug(__LINE__, "Found NVIDIA GPU device (debug): $values[1] (index: $values[0])");
}
}
close $fh;
} else {
debug(__LINE__, "Failed to open debug file $config{debug}{nvidia_devices_file}: $!");
}
} else {
if (open my $fh, '-|', 'nvidia-smi --query-gpu=index,name --format=csv') {
my $line_num = 0;
while (<$fh>) {
chomp;
$line_num++;
next if $line_num == 1 || /^\s*$/;
my @values = parse_csv_line($_, 2);
if (@values) {
push @devices, { index => $values[0], name => $values[1] };
debug(__LINE__, "Found NVIDIA GPU device: $values[1] (index: $values[0])");
}
}
close $fh;
}
}
return @devices;
}
# ============================================================================
# NVIDIA GPU — data parsing
# ============================================================================
sub _parse_nvidia_gpu_line {
my ($line) = @_;
# Expected CSV format:
# index, name, temperature.gpu, utilization.gpu, utilization.memory,
# memory.used, memory.total, power.draw, power.limit, fan.speed
my @values = parse_csv_line($line, 10);
return unless @values;
return {
index => $values[0] + 0,
name => $values[1],
temperature => {
gpu => $values[2] + 0.0,
unit => "°C",
},
utilization => {
gpu => $values[3] + 0.0,
memory => $values[4] + 0.0,
unit => "%",
},
memory => {
used => $values[5] + 0.0,
total => $values[6] + 0.0,
unit => "MiB",
},
power => {
draw => $values[7] + 0.0,
limit => $values[8] + 0.0,
unit => "W",
},
fan => {
speed => $values[9] + 0.0,
unit => "%",
},
};
}
# ============================================================================
# NVIDIA GPU — stat collection and write
# ============================================================================
sub _get_and_write_nvidia_stats {
my ($devices) = @_;
my @all_stats;
if ($config{debug}{nvidia_mode} && -f $config{debug}{nvidia_output_file}) {
debug(__LINE__, "Debug mode: reading NVIDIA GPU stats from $config{debug}{nvidia_output_file}");
if (open my $fh, '<', $config{debug}{nvidia_output_file}) {
my $line_num = 0;
while (<$fh>) {
chomp;
$line_num++;
next if $line_num == 1 || /^\s*$/;
my $stats = _parse_nvidia_gpu_line($_);
push @all_stats, $stats if $stats;
}
close $fh;
} else {
debug(__LINE__, "Failed to open debug file $config{debug}{nvidia_output_file}: $!");
}
} else {
unless (check_executable('/usr/bin/nvidia-smi', 'NVIDIA')) {
debug(__LINE__, "nvidia-smi not available, cannot collect stats");
return 0;
}
my $query = 'index,name,temperature.gpu,utilization.gpu,utilization.memory,'
. 'memory.used,memory.total,power.draw,power.limit,fan.speed';
my $cmd = "nvidia-smi --query-gpu=$query --format=csv,nounits";
if (open my $fh, '-|', $cmd) {
my $line_num = 0;
while (<$fh>) {
chomp;
$line_num++;
next if $line_num == 1 || /^\s*$/;
my $stats = _parse_nvidia_gpu_line($_);
push @all_stats, $stats if $stats;
}
close $fh;
}
}
foreach my $stats (@all_stats) {
my $device_index = $stats->{index};
unless ($device_index =~ /^(\d+)$/) {
debug(__LINE__, "Invalid device index: $device_index, skipping");
next;
}
$device_index = $1; # untainted
my $node_name = "gpu$device_index";
my $device_state_file = "$pve_mod_working_dir/stats-nvidia$device_index.json";
my $device_name = $stats->{name};
foreach my $dev (@$devices) {
if ($dev->{index} == $device_index) {
$device_name = $dev->{name};
last;
}
}
my $device_data = {
$node_name => {
name => $device_name,
index => $device_index,
stats => $stats,
}
};
safe_write_json($device_state_file, $device_data);
update_nvidia_gpu_rrd($device_index, $stats);
}
unless (@all_stats) {
debug(__LINE__, "No valid NVIDIA GPU stats collected");
}
return scalar(@all_stats);
}
# ============================================================================
# NVIDIA GPU — long-running collector (all devices in one process)
# ============================================================================
sub collector_for_nvidia_devices {
my ($devices) = @_;
$process_type = 'collector';
$0 = "collector-gpu-nvidia-all";
debug(__LINE__, "NVIDIA collector started for " . scalar(@$devices) . " GPU(s)");
my $shutdown = 0;
setup_collector_signals('nvidia-all', \$shutdown);
while (!$shutdown) {
_get_and_write_nvidia_stats($devices);
sleep $config{intervals}{data_pull} unless $shutdown;
}
debug(__LINE__, "NVIDIA collector shutting down");
exit 0;
}
1;

View File

@ -1,115 +1,115 @@
package PVE::PVEMod::Collector::Ups;
use strict;
use warnings;
use Exporter 'import';
use JSON;
use PVE::PVEMod::Config qw($process_type $ups_state_file);
use PVE::PVEMod::Utils qw(debug setup_collector_signals);
our @EXPORT_OK = qw(
collector_for_ups
);
# ============================================================================
# UPS — long-running collector
# ============================================================================
sub collector_for_ups {
my ($device) = @_;
$process_type = 'collector';
$0 = "collector-ups-$device->{ups_name}";
debug(__LINE__, "UPS collector started");
my $shutdown = 0;
setup_collector_signals("ups-$device->{ups_name}", \$shutdown);
while (!$shutdown) {
my $ups_data = _get_ups_status($device->{ups_name});
eval {
open my $ofh, '>', $ups_state_file
or die "Failed to open $ups_state_file: $!";
print $ofh $ups_data;
close $ofh;
debug(__LINE__, "Wrote UPS data to $ups_state_file");
};
if ($@) {
debug(__LINE__, "Error writing UPS data: $@");
}
sleep 1 unless $shutdown; # $config{intervals}{data_pull}
}
debug(__LINE__, "UPS collector shutting down");
exit 0;
}
# ============================================================================
# UPS — status query
# ============================================================================
sub _get_ups_status {
my ($ups_name) = @_;
debug(__LINE__, "Collecting UPS status for $ups_name");
my $output = `/usr/bin/upsc $ups_name 2>/dev/null`;
unless (defined $output && length($output) > 0) {
debug(__LINE__, "No output from upsc for $ups_name");
return encode_json({ error => "No data from UPS $ups_name" });
}
my $ups_data = _parse_upsc_output($output);
unless (keys %$ups_data) {
debug(__LINE__, "No data received from upsc for $ups_name");
return encode_json({ error => "No data from UPS $ups_name" });
}
return JSON->new->pretty->canonical->encode({ $ups_name => $ups_data });
}
# ============================================================================
# UPS — output parser
# ============================================================================
sub _parse_upsc_output {
my ($output) = @_;
my $ups_data = {};
debug(__LINE__, "Parsing upsc output");
eval {
foreach my $line (split /\n/, $output) {
next if $line =~ /^\s*$/;
next if $line =~ /^Init SSL/;
if ($line =~ /^([^:]+):\s*(.*)$/) {
my ($key, $value) = ($1, $2);
$key =~ s/^\s+|\s+$//g;
$value =~ s/^\s+|\s+$//g;
# Coerce numeric values
if ($value =~ /^-?\d+\.?\d*$/) {
$ups_data->{$key} = $value + 0;
} else {
$ups_data->{$key} = $value;
}
}
}
};
if ($@) {
debug(__LINE__, "Error parsing upsc output: $@");
}
debug(__LINE__, "Completed parsing upsc output");
return $ups_data;
}
1;
package PVE::PVEMod::Collector::Ups;
use strict;
use warnings;
use Exporter 'import';
use JSON;
use PVE::PVEMod::Config qw($process_type $ups_state_file);
use PVE::PVEMod::Utils qw(debug setup_collector_signals);
our @EXPORT_OK = qw(
collector_for_ups
);
# ============================================================================
# UPS — long-running collector
# ============================================================================
sub collector_for_ups {
my ($device) = @_;
$process_type = 'collector';
$0 = "collector-ups-$device->{ups_name}";
debug(__LINE__, "UPS collector started");
my $shutdown = 0;
setup_collector_signals("ups-$device->{ups_name}", \$shutdown);
while (!$shutdown) {
my $ups_data = _get_ups_status($device->{ups_name});
eval {
open my $ofh, '>', $ups_state_file
or die "Failed to open $ups_state_file: $!";
print $ofh $ups_data;
close $ofh;
debug(__LINE__, "Wrote UPS data to $ups_state_file");
};
if ($@) {
debug(__LINE__, "Error writing UPS data: $@");
}
sleep 1 unless $shutdown; # $config{intervals}{data_pull}
}
debug(__LINE__, "UPS collector shutting down");
exit 0;
}
# ============================================================================
# UPS — status query
# ============================================================================
sub _get_ups_status {
my ($ups_name) = @_;
debug(__LINE__, "Collecting UPS status for $ups_name");
my $output = `/usr/bin/upsc $ups_name 2>/dev/null`;
unless (defined $output && length($output) > 0) {
debug(__LINE__, "No output from upsc for $ups_name");
return encode_json({ error => "No data from UPS $ups_name" });
}
my $ups_data = _parse_upsc_output($output);
unless (keys %$ups_data) {
debug(__LINE__, "No data received from upsc for $ups_name");
return encode_json({ error => "No data from UPS $ups_name" });
}
return JSON->new->pretty->canonical->encode({ $ups_name => $ups_data });
}
# ============================================================================
# UPS — output parser
# ============================================================================
sub _parse_upsc_output {
my ($output) = @_;
my $ups_data = {};
debug(__LINE__, "Parsing upsc output");
eval {
foreach my $line (split /\n/, $output) {
next if $line =~ /^\s*$/;
next if $line =~ /^Init SSL/;
if ($line =~ /^([^:]+):\s*(.*)$/) {
my ($key, $value) = ($1, $2);
$key =~ s/^\s+|\s+$//g;
$value =~ s/^\s+|\s+$//g;
# Coerce numeric values
if ($value =~ /^-?\d+\.?\d*$/) {
$ups_data->{$key} = $value + 0;
} else {
$ups_data->{$key} = $value;
}
}
}
};
if ($@) {
debug(__LINE__, "Error parsing upsc output: $@");
}
debug(__LINE__, "Completed parsing upsc output");
return $ups_data;
}
1;

View File

@ -1,96 +1,96 @@
package PVE::PVEMod::Collector::SystemInformation;
use strict;
use warnings;
use Exporter 'import';
use PVE::PVEMod::Config qw(%config);
use PVE::PVEMod::Utils qw(debug);
our @EXPORT_OK = qw(
get_system_information_data
);
# ============================================================================
# System Information — one-time dmidecode call
# ============================================================================
sub get_system_information_data {
unless ($config{system_info}{enabled}) {
debug(__LINE__, "System information collection is disabled");
return {};
}
my $raw_type = $config{system_info}{type};
# Taint-safe: only allow type 1 (System) or 2 (Baseboard/Motherboard)
my $type;
if (defined $raw_type && $raw_type =~ /^([12])$/) {
$type = $1;
} else {
debug(__LINE__, "Invalid system_info type '${\($raw_type // 'undef')}', defaulting to 1");
$type = 1;
}
debug(__LINE__, "Collecting system information via dmidecode -t $type");
return _get_system_info($type);
}
# ============================================================================
# Internal — run dmidecode and parse output
# ============================================================================
sub _get_system_info {
my ($type) = @_;
my $output = `/usr/sbin/dmidecode -t $type 2>/dev/null`;
unless (defined $output && length($output) > 0) {
debug(__LINE__, "No output from dmidecode -t $type");
return {};
}
my %fields;
my @field_order;
for my $line (split /\n/, $output) {
if ($line =~ /^\s+(Manufacturer|Product Name|Serial Number):\s*(.+)$/) {
my ($key, $value) = ($1, $2);
$value =~ s/^\s+|\s+$//g;
my $field_key = lc($key);
$field_key =~ s/ /_/g;
unless (exists $fields{$field_key}) {
push @field_order, $field_key;
$fields{$field_key} = $value;
}
}
}
unless (%fields) {
debug(__LINE__, "No recognised fields found in dmidecode output");
return {};
}
# Build display string: "Manufacturer: X | Product Name: Y | Serial Number: Z"
my %pretty_key = (
manufacturer => 'Manufacturer',
product_name => 'Product Name',
serial_number => 'Serial Number',
);
my @parts;
for my $key (@field_order) {
my $label = $pretty_key{$key} // $key;
push @parts, "$label: $fields{$key}";
}
$fields{display_string} = join(' | ', @parts);
debug(__LINE__, "System information: $fields{display_string}");
return \%fields;
}
1;
package PVE::PVEMod::Collector::SystemInformation;
use strict;
use warnings;
use Exporter 'import';
use PVE::PVEMod::Config qw(%config);
use PVE::PVEMod::Utils qw(debug);
our @EXPORT_OK = qw(
get_system_information_data
);
# ============================================================================
# System Information — one-time dmidecode call
# ============================================================================
sub get_system_information_data {
unless ($config{system_info}{enabled}) {
debug(__LINE__, "System information collection is disabled");
return {};
}
my $raw_type = $config{system_info}{type};
# Taint-safe: only allow type 1 (System) or 2 (Baseboard/Motherboard)
my $type;
if (defined $raw_type && $raw_type =~ /^([12])$/) {
$type = $1;
} else {
debug(__LINE__, "Invalid system_info type '${\($raw_type // 'undef')}', defaulting to 1");
$type = 1;
}
debug(__LINE__, "Collecting system information via dmidecode -t $type");
return _get_system_info($type);
}
# ============================================================================
# Internal — run dmidecode and parse output
# ============================================================================
sub _get_system_info {
my ($type) = @_;
my $output = `/usr/sbin/dmidecode -t $type 2>/dev/null`;
unless (defined $output && length($output) > 0) {
debug(__LINE__, "No output from dmidecode -t $type");
return {};
}
my %fields;
my @field_order;
for my $line (split /\n/, $output) {
if ($line =~ /^\s+(Manufacturer|Product Name|Serial Number):\s*(.+)$/) {
my ($key, $value) = ($1, $2);
$value =~ s/^\s+|\s+$//g;
my $field_key = lc($key);
$field_key =~ s/ /_/g;
unless (exists $fields{$field_key}) {
push @field_order, $field_key;
$fields{$field_key} = $value;
}
}
}
unless (%fields) {
debug(__LINE__, "No recognised fields found in dmidecode output");
return {};
}
# Build display string: "Manufacturer: X | Product Name: Y | Serial Number: Z"
my %pretty_key = (
manufacturer => 'Manufacturer',
product_name => 'Product Name',
serial_number => 'Serial Number',
);
my @parts;
for my $key (@field_order) {
my $label = $pretty_key{$key} // $key;
push @parts, "$label: $fields{$key}";
}
$fields{display_string} = join(' | ', @parts);
debug(__LINE__, "System information: $fields{display_string}");
return \%fields;
}
1;

View File

@ -1,151 +1,151 @@
package PVE::PVEMod::Config;
use strict;
use warnings;
use Exporter 'import';
our @EXPORT_OK = qw(
%config
$DEBUG_ENABLED $VERSION $process_type
$pve_mod_working_dir $stats_dir $state_file
$sensors_state_file $ups_state_file
$pve_mod_worker_lock $startup_lock
$RRD_SOCKET $RRD_BASE
);
# ============================================================================
# Debug / Version
# ============================================================================
our $DEBUG_ENABLED = 1;
our $VERSION = 'version-placeholder';
# Runtime process-type tag — set to 'worker' or 'collector' after fork.
# Each forked child gets its own copy of this variable.
our $process_type = 'main'; # 'main', 'worker', or 'collector'
# ============================================================================
# Configuration
# ============================================================================
our %config = (
gpu => {
intel_enabled => 0,
amd_enabled => 0,
nvidia_enabled => 0,
gpu_history => 0,
},
debug => {
log_enabled => 0,
log_file => '/tmp/pve-mod-debug.log',
lm_sensors_mode => 0,
lm_sensors_output_file => '/tmp/sensors-output.json',
intel_mode => 0,
intel_devices_file => '/tmp/intel-gpu-devices.json',
nvidia_mode => 0,
nvidia_devices_file => '/tmp/nvidia-smi-devices.csv',
nvidia_output_file => '/tmp/nvidia-smi-output.csv',
amd_mode => 0,
amd_devices_file => '/tmp/amd-gpu-devices.json',
ups_mode => 0,
ups_output_file => '/tmp/ups-output.json',
},
intervals => {
data_pull => 1, # seconds between data pulls
collector_timeout => 10, # stop collectors after N seconds of inactivity
},
lm_sensors => {
enabled => 0,
enable_cpu => 0,
cpu_temp_target => 'Core',
enable_ram_temp => 0,
enable_hdd_temp => 0,
enable_nvme_temp => 0,
enable_fan_speed => 0,
display_zero_speed_fans => 0,
temp_unit => 'C',
},
ups => {
enabled => 0,
device_name => 'ups@localhost',
},
system_info => {
enabled => 0,
type => 1, # 1 = System (dmidecode -t 1), 2 = Baseboard/Motherboard (dmidecode -t 2)
},
paths => {
working_dir => '/run/pveproxy/pve-mod',
},
);
# ============================================================================
# Derived paths
# ============================================================================
our $pve_mod_working_dir = $config{paths}{working_dir};
our $stats_dir = $pve_mod_working_dir;
our $state_file = "$pve_mod_working_dir/stats.json";
our $sensors_state_file = "$pve_mod_working_dir/sensors.json";
our $ups_state_file = "$pve_mod_working_dir/ups.json";
our $pve_mod_worker_lock = "$pve_mod_working_dir/pve_mod_worker.lock";
our $startup_lock = "$pve_mod_working_dir/startup.lock";
# ============================================================================
# RRD paths
# ============================================================================
our $RRD_SOCKET = '/var/run/rrdcached.sock';
our $RRD_BASE = '/var/lib/rrdcached/db/pve-mod-gpu';
# ============================================================================
# Load configuration from /etc/pve-mod/pve-mod.conf (INI format).
# Merges file values into %config, overriding compiled-in defaults.
# Safe to call multiple times; silently skips missing file or unknown keys.
# ============================================================================
sub _load_ini_file {
my $path = '/etc/pve-mod/pve-mod.conf';
return unless -f $path;
open my $fh, '<', $path or return;
my $section = '';
while (my $line = <$fh>) {
chomp $line;
$line =~ s/#.*//; # strip inline comments
$line =~ s/^\s+|\s+$//g; # trim whitespace
next unless length $line;
if ($line =~ /^\[([^\]]+)\]$/) {
$section = $1;
next;
}
if ($line =~ /^([^=]+)=(.*)$/) {
my ($key, $val) = ($1, $2);
$key =~ s/^\s+|\s+$//g;
$val =~ s/^\s+|\s+$//g;
if ($section eq 'gpu' && exists $config{gpu}{$key}) {
$config{gpu}{$key} = $val;
}
elsif ($section eq 'lm_sensors' && exists $config{lm_sensors}{$key}) {
$config{lm_sensors}{$key} = $val;
}
elsif ($section eq 'ups' && exists $config{ups}{$key}) {
$config{ups}{$key} = $val;
}
elsif ($section eq 'system_info' && exists $config{system_info}{$key}) {
$config{system_info}{$key} = $val;
}
elsif ($section eq 'debug' && exists $config{debug}{$key}) {
$config{debug}{$key} = $val;
}
}
}
close $fh;
}
_load_ini_file();
1;
package PVE::PVEMod::Config;
use strict;
use warnings;
use Exporter 'import';
our @EXPORT_OK = qw(
%config
$DEBUG_ENABLED $VERSION $process_type
$pve_mod_working_dir $stats_dir $state_file
$sensors_state_file $ups_state_file
$pve_mod_worker_lock $startup_lock
$RRD_SOCKET $RRD_BASE
);
# ============================================================================
# Debug / Version
# ============================================================================
our $DEBUG_ENABLED = 1;
our $VERSION = 'version-placeholder';
# Runtime process-type tag — set to 'worker' or 'collector' after fork.
# Each forked child gets its own copy of this variable.
our $process_type = 'main'; # 'main', 'worker', or 'collector'
# ============================================================================
# Configuration
# ============================================================================
our %config = (
gpu => {
intel_enabled => 0,
amd_enabled => 0,
nvidia_enabled => 0,
gpu_history => 0,
},
debug => {
log_enabled => 0,
log_file => '/tmp/pve-mod-debug.log',
lm_sensors_mode => 0,
lm_sensors_output_file => '/tmp/sensors-output.json',
intel_mode => 0,
intel_devices_file => '/tmp/intel-gpu-devices.json',
nvidia_mode => 0,
nvidia_devices_file => '/tmp/nvidia-smi-devices.csv',
nvidia_output_file => '/tmp/nvidia-smi-output.csv',
amd_mode => 0,
amd_devices_file => '/tmp/amd-gpu-devices.json',
ups_mode => 0,
ups_output_file => '/tmp/ups-output.json',
},
intervals => {
data_pull => 1, # seconds between data pulls
collector_timeout => 10, # stop collectors after N seconds of inactivity
},
lm_sensors => {
enabled => 0,
enable_cpu => 0,
cpu_temp_target => 'Core',
enable_ram_temp => 0,
enable_hdd_temp => 0,
enable_nvme_temp => 0,
enable_fan_speed => 0,
display_zero_speed_fans => 0,
temp_unit => 'C',
},
ups => {
enabled => 0,
device_name => 'ups@localhost',
},
system_info => {
enabled => 0,
type => 1, # 1 = System (dmidecode -t 1), 2 = Baseboard/Motherboard (dmidecode -t 2)
},
paths => {
working_dir => '/run/pveproxy/pve-mod',
},
);
# ============================================================================
# Derived paths
# ============================================================================
our $pve_mod_working_dir = $config{paths}{working_dir};
our $stats_dir = $pve_mod_working_dir;
our $state_file = "$pve_mod_working_dir/stats.json";
our $sensors_state_file = "$pve_mod_working_dir/sensors.json";
our $ups_state_file = "$pve_mod_working_dir/ups.json";
our $pve_mod_worker_lock = "$pve_mod_working_dir/pve_mod_worker.lock";
our $startup_lock = "$pve_mod_working_dir/startup.lock";
# ============================================================================
# RRD paths
# ============================================================================
our $RRD_SOCKET = '/var/run/rrdcached.sock';
our $RRD_BASE = '/var/lib/rrdcached/db/pve-mod-gpu';
# ============================================================================
# Load configuration from /etc/pve-mod/pve-mod.conf (INI format).
# Merges file values into %config, overriding compiled-in defaults.
# Safe to call multiple times; silently skips missing file or unknown keys.
# ============================================================================
sub _load_ini_file {
my $path = '/etc/pve-mod/pve-mod.conf';
return unless -f $path;
open my $fh, '<', $path or return;
my $section = '';
while (my $line = <$fh>) {
chomp $line;
$line =~ s/#.*//; # strip inline comments
$line =~ s/^\s+|\s+$//g; # trim whitespace
next unless length $line;
if ($line =~ /^\[([^\]]+)\]$/) {
$section = $1;
next;
}
if ($line =~ /^([^=]+)=(.*)$/) {
my ($key, $val) = ($1, $2);
$key =~ s/^\s+|\s+$//g;
$val =~ s/^\s+|\s+$//g;
if ($section eq 'gpu' && exists $config{gpu}{$key}) {
$config{gpu}{$key} = $val;
}
elsif ($section eq 'lm_sensors' && exists $config{lm_sensors}{$key}) {
$config{lm_sensors}{$key} = $val;
}
elsif ($section eq 'ups' && exists $config{ups}{$key}) {
$config{ups}{$key} = $val;
}
elsif ($section eq 'system_info' && exists $config{system_info}{$key}) {
$config{system_info}{$key} = $val;
}
elsif ($section eq 'debug' && exists $config{debug}{$key}) {
$config{debug}{$key} = $val;
}
}
}
close $fh;
}
_load_ini_file();
1;

View File

@ -1,469 +1,469 @@
package PVE::PVEMod::ProcessManager;
use strict;
use warnings;
use Exporter 'import';
use POSIX qw(WNOHANG);
use File::Path qw(remove_tree);
use PVE::PVEMod::Config qw(
%config $process_type
$pve_mod_working_dir $state_file
$pve_mod_worker_lock $startup_lock
);
use PVE::PVEMod::Utils qw(
debug is_process_alive read_lock_pid
acquire_exclusive_lock ensure_pve_mod_directory_exists
check_executable startup_message
);
use PVE::PVEMod::Collector::Intel qw(get_intel_gpu_devices collector_for_intel_device);
use PVE::PVEMod::Collector::Nvidia qw(get_nvidia_gpu_devices collector_for_nvidia_devices);
use PVE::PVEMod::Collector::Amd qw(get_amd_gpu_devices collector_for_amd_device);
use PVE::PVEMod::Collector::LmSensors qw(collector_for_temperature_sensors);
use PVE::PVEMod::Collector::Ups qw(collector_for_ups);
our @EXPORT_OK = qw(
pve_mod_starter
notify_pve_mod_worker
);
# Collector registry — only populated inside the worker process.
# Each forked child has its own copy; the parent never accesses this after forking.
my %collectors = ();
# ============================================================================
# Public API (called from SensorInfo)
# ============================================================================
# Ensures the worker is running. Starts it if necessary (double-checked locking).
sub pve_mod_starter {
debug(__LINE__, "Checking if pve_mod_worker is already running");
if (_worker_lock_file_exists()) {
debug(__LINE__, "pve_mod_worker process already running, system is already started");
return "pve_mod_worker process already running, system is already started";
}
debug(__LINE__, "PVE mod worker is not running. PVE Mod will be started.");
startup_message();
ensure_pve_mod_directory_exists();
debug(__LINE__, "Trying to acquire startup lock: $startup_lock");
my $startup_fh = acquire_exclusive_lock($startup_lock, 'startup lock');
return unless $startup_fh;
# Second check after acquiring lock
if (_worker_lock_file_exists()) {
debug(__LINE__, "Worker started by another process while we waited for lock");
close($startup_fh);
unlink($startup_lock);
return "already running";
}
print $startup_fh "$$\n";
$startup_fh->flush();
debug(__LINE__, "Wrote PID $$ to startup lock");
_pve_mod_worker();
unlink($startup_lock);
debug(__LINE__, "Released startup lock");
debug(__LINE__, "pve_mod_worker started successfully, returning");
}
# Sends SIGUSR1 to the worker to reset the inactivity timer.
sub notify_pve_mod_worker {
debug(__LINE__, "notify_pve_mod_worker called");
unless (-f $pve_mod_worker_lock) {
debug(__LINE__, "pve_mod_worker lock file does not exist");
return;
}
debug(__LINE__, "pve_mod_worker lock file exists, reading PID");
if (open my $fh, '<', $pve_mod_worker_lock) {
my $pid = <$fh>;
close $fh;
chomp $pid if defined $pid;
if (defined $pid && $pid =~ /^(\d+)$/) {
my $clean_pid = $1;
if (is_process_alive($clean_pid)) {
debug(__LINE__, "Sending USR1 signal to pve_mod_worker PID $clean_pid");
my $result = kill('USR1', $clean_pid);
debug(__LINE__, "Signal result: $result");
} else {
debug(__LINE__,
"pve_mod_worker process $clean_pid is not alive, removing stale lock");
unlink($pve_mod_worker_lock);
}
} else {
debug(__LINE__,
"pve_mod_worker lock is stale (PID: " . ($pid // 'undefined') . "), removing");
unlink($pve_mod_worker_lock);
}
} else {
debug(__LINE__, "Failed to open pve_mod_worker lock file: $!");
}
}
# ============================================================================
# Worker process management
# ============================================================================
sub _worker_lock_file_exists {
return -f $pve_mod_worker_lock;
}
# Forks the worker process and records its PID in the lock file.
sub _pve_mod_worker {
debug(__LINE__, "_pve_mod_worker called");
my $pve_mod_worker_fh =
acquire_exclusive_lock($pve_mod_worker_lock, 'pve_mod_worker lock');
return unless $pve_mod_worker_fh;
print $pve_mod_worker_fh "$$\n";
close($pve_mod_worker_fh);
debug(__LINE__, "Forking new pve_mod_worker process");
my $pve_mod_worker_pid = fork();
unless (defined $pve_mod_worker_pid) {
debug(__LINE__, "Failed to fork pve_mod_worker process: $!");
return;
}
if ($pve_mod_worker_pid == 0) {
# Child
$0 = "pve_mod_worker_controller";
debug(__LINE__, "Child process forked, calling _pve_mod_keep_alive");
_pve_mod_keep_alive();
exit(0);
}
# Parent — update lock file with real child PID
debug(__LINE__, "Forked pve_mod_worker process with PID $pve_mod_worker_pid");
if (open my $fh, '>', $pve_mod_worker_lock) {
print $fh "$pve_mod_worker_pid\n";
close $fh;
debug(__LINE__, "Wrote pve_mod_worker PID to lock file: $pve_mod_worker_lock");
} else {
debug(__LINE__, "Failed to write pve_mod_worker lock file: $!");
kill('TERM', $pve_mod_worker_pid);
}
debug(__LINE__, "pve_mod_worker process started successfully");
}
# ============================================================================
# Worker keep-alive loop
# ============================================================================
sub _pve_mod_keep_alive {
$process_type = 'worker';
debug(__LINE__, "pve_mod_worker process started with PID $$");
my $last_activity = time();
$SIG{USR1} = sub {
$last_activity = time();
debug(__LINE__, "Activity ping received");
};
$SIG{CHLD} = sub {
while ((my $pid = waitpid(-1, WNOHANG)) > 0) {
my $exit_status = $? >> 8;
debug(__LINE__, "Child process $pid exited with status $exit_status");
foreach my $name (keys %collectors) {
if ($collectors{$name} == $pid) {
debug(__LINE__,
"Collector '$name' (PID $pid) exited, removing from registry");
delete $collectors{$name};
last;
}
}
}
};
$SIG{TERM} = sub {
debug(__LINE__, "pve_mod_worker received SIGTERM, shutting down");
_stop_child_collectors();
unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock;
exit(0);
};
$SIG{INT} = sub {
debug(__LINE__, "pve_mod_worker received SIGINT, shutting down");
_stop_child_collectors();
unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock;
exit(0);
};
debug(__LINE__, "Worker starting all collectors");
_initialise_sensors_collector();
_initialise_graphics_collectors();
_initialise_ups_collector();
debug(__LINE__, "All collectors started by worker");
debug(__LINE__,
"Entering pve_mod_worker loop, timeout=$config{intervals}{collector_timeout}s");
while (1) {
debug(__LINE__, "pve_mod_worker loop start: checking activity");
my $idle_time = time() - $last_activity;
debug(__LINE__,
"pve_mod_worker loop: idle_time=${idle_time}s, "
. "timeout=$config{intervals}{collector_timeout}s");
if ($idle_time > $config{intervals}{collector_timeout}) {
debug(__LINE__, "Timeout reached, stopping collectors");
_stop_child_collectors();
debug(__LINE__, "Collectors stopped, exiting pve_mod_worker");
unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock;
exit(0);
}
sleep(1);
}
debug(__LINE__, "pve_mod_worker loop exited unexpectedly!");
}
# ============================================================================
# Collector startup helpers (called from worker loop)
# ============================================================================
sub _initialise_sensors_collector {
return unless $config{lm_sensors}{enabled};
return unless check_executable('/usr/bin/sensors', 'lm-sensors',
$config{debug}{lm_sensors_mode},
$config{debug}{lm_sensors_output_file});
debug(__LINE__, "Starting lm-sensors collector");
_start_collector('sensors', 'sensors',
\&collector_for_temperature_sensors,
{ name => 'sensors' });
}
sub _initialise_ups_collector {
unless ($config{ups}{enabled} && $config{ups}{device_name}) {
debug(__LINE__, "UPS collection disabled/invalid in config, skipping");
return;
}
return unless check_executable('/usr/bin/upsc', 'UPS',
$config{debug}{ups_mode},
$config{debug}{ups_output_file});
debug(__LINE__, "Starting UPS collector: $config{ups}{device_name}");
_start_collector('ups', 'ups', \&collector_for_ups,
{ ups_name => $config{ups}{device_name} });
}
sub _initialise_graphics_collectors {
unless ($config{gpu}{intel_enabled}
|| $config{gpu}{amd_enabled}
|| $config{gpu}{nvidia_enabled}) {
debug(__LINE__, "No GPU types enabled, skipping collector startup");
return;
}
debug(__LINE__, "Starting graphics collectors");
my (@all_devices, @all_types, @all_collector_subs);
my @nvidia_devices;
# Intel (each GPU has its own collector)
if ($config{gpu}{intel_enabled} && check_executable('/usr/bin/intel_gpu_top', 'Intel',
$config{debug}{intel_mode},
$config{debug}{intel_devices_file})) {
my @intel_devices = get_intel_gpu_devices();
for my $device (@intel_devices) {
push @all_devices, $device;
push @all_types, 'intel';
push @all_collector_subs, \&collector_for_intel_device;
}
}
# AMD (each GPU has its own collector)
if ($config{gpu}{amd_enabled} && check_executable('/usr/bin/rocm-smi', 'AMD',
$config{debug}{amd_mode},
$config{debug}{amd_devices_file})) {
my @amd_devices = get_amd_gpu_devices();
for my $device (@amd_devices) {
push @all_devices, $device;
push @all_types, 'amd';
push @all_collector_subs, \&collector_for_amd_device;
}
}
# NVIDIA (all GPUs collected together in one collector due to nvidia-smi design)
if ($config{gpu}{nvidia_enabled} && check_executable('/usr/bin/nvidia-smi', 'NVIDIA',
$config{debug}{nvidia_mode},
$config{debug}{nvidia_devices_file})) {
@nvidia_devices = get_nvidia_gpu_devices();
}
debug(__LINE__,
"Detected: "
. scalar(grep { $_ eq 'intel' } @all_types) . " Intel, "
. scalar(grep { $_ eq 'amd' } @all_types) . " AMD, "
. scalar(@nvidia_devices) . " NVIDIA");
my $started_count = 0;
# Start individual collectors for Intel and AMD devices
for (my $i = 0; $i < @all_devices; $i++) {
my $device = $all_devices[$i];
my $type = $all_types[$i];
my $collector_sub = $all_collector_subs[$i];
my $device_name = $device->{card} // $device->{name} // "device$i";
my $pid = _start_collector($device_name, $type, $collector_sub, $device);
$started_count++ if $pid;
}
# NVIDIA — single collector for all GPUs
if (@nvidia_devices) {
my $pid = _start_collector('nvidia-all', 'nvidia',
\&collector_for_nvidia_devices,
\@nvidia_devices);
$started_count++ if $pid;
}
debug(__LINE__,
"Started/verified $started_count graphics collector(s)");
}
# ============================================================================
# Generic collector start/stop
# ============================================================================
sub _start_collector {
my ($collector_name, $collector_type, $collector_sub, $device) = @_;
debug(__LINE__, "Starting $collector_type collector: $collector_name");
if (exists $collectors{$collector_name}) {
my $pid = $collectors{$collector_name};
if (kill(0, $pid)) {
debug(__LINE__,
"$collector_type collector '$collector_name' already running with PID $pid");
return $pid;
} else {
debug(__LINE__,
"Collector '$collector_name' PID $pid is stale, removing from registry");
delete $collectors{$collector_name};
}
}
my $pid = _start_child_collector($collector_name, $collector_sub, $device);
unless ($pid) {
debug(__LINE__, "Failed to start $collector_type collector '$collector_name'");
return undef;
}
$collectors{$collector_name} = $pid;
debug(__LINE__,
"Registered $collector_type collector '$collector_name' with PID $pid");
sleep 0.1;
if (kill(0, $pid)) {
debug(__LINE__,
"Verified $collector_type collector '$collector_name' (PID $pid) is alive");
return $pid;
} else {
debug(__LINE__,
"WARNING - $collector_type collector '$collector_name' (PID $pid) died immediately!");
delete $collectors{$collector_name};
return undef;
}
}
sub _start_child_collector {
my ($collector_name, $collector_sub, $device) = @_;
debug(__LINE__, "Starting child collector: $collector_name");
my $pid = fork();
unless (defined $pid) {
debug(__LINE__, "fork failed for $collector_name: $!");
return undef;
}
if ($pid == 0) {
$process_type = 'collector';
debug(__LINE__, "In child process for $collector_name");
$0 = "collector-$collector_name";
$collector_sub->($device);
exit(0);
}
debug(__LINE__, "Forked child PID $pid for $collector_name");
return $pid;
}
sub _stop_child_collectors {
debug(__LINE__, "Stopping all collectors");
my @pids = values %collectors;
if (@pids) {
debug(__LINE__, "Sending SIGTERM to " . scalar(@pids) . " collector process(es)");
foreach my $pid (@pids) {
if (kill(0, $pid)) {
kill('TERM', $pid);
debug(__LINE__, "Sent SIGTERM to collector PID $pid");
}
}
my $timeout = 2;
my $start = time();
while (time() - $start < $timeout) {
my $any_alive = 0;
foreach my $pid (@pids) {
if (kill(0, $pid)) { $any_alive = 1; last; }
}
last unless $any_alive;
select(undef, undef, undef, 0.1);
}
foreach my $pid (@pids) {
if (kill(0, $pid)) {
debug(__LINE__, "Force killing collector process $pid");
kill('KILL', $pid);
}
}
}
%collectors = ();
debug(__LINE__, "Cleared collector registry");
if (-f $state_file) {
unlink $state_file or debug(__LINE__, "Failed to remove $state_file: $!");
}
if (-d $pve_mod_working_dir) {
remove_tree($pve_mod_working_dir, { error => \my $err });
debug(__LINE__, "Cleanup errors: @$err") if @$err;
}
debug(__LINE__, "Cleanup complete");
}
# ============================================================================
# END block — only the worker process performs cleanup
# ============================================================================
END {
if ($process_type eq 'worker') {
debug(__LINE__, "PVE Mod Worker END block: cleaning up");
_stop_child_collectors();
} elsif ($process_type eq 'collector') {
debug(__LINE__, "Collector ($0) END block: no cleanup needed");
} else {
debug(__LINE__, "Main process END block: no cleanup needed");
}
}
1;
package PVE::PVEMod::ProcessManager;
use strict;
use warnings;
use Exporter 'import';
use POSIX qw(WNOHANG);
use File::Path qw(remove_tree);
use PVE::PVEMod::Config qw(
%config $process_type
$pve_mod_working_dir $state_file
$pve_mod_worker_lock $startup_lock
);
use PVE::PVEMod::Utils qw(
debug is_process_alive read_lock_pid
acquire_exclusive_lock ensure_pve_mod_directory_exists
check_executable startup_message
);
use PVE::PVEMod::Collector::Intel qw(get_intel_gpu_devices collector_for_intel_device);
use PVE::PVEMod::Collector::Nvidia qw(get_nvidia_gpu_devices collector_for_nvidia_devices);
use PVE::PVEMod::Collector::Amd qw(get_amd_gpu_devices collector_for_amd_device);
use PVE::PVEMod::Collector::LmSensors qw(collector_for_temperature_sensors);
use PVE::PVEMod::Collector::Ups qw(collector_for_ups);
our @EXPORT_OK = qw(
pve_mod_starter
notify_pve_mod_worker
);
# Collector registry — only populated inside the worker process.
# Each forked child has its own copy; the parent never accesses this after forking.
my %collectors = ();
# ============================================================================
# Public API (called from SensorInfo)
# ============================================================================
# Ensures the worker is running. Starts it if necessary (double-checked locking).
sub pve_mod_starter {
debug(__LINE__, "Checking if pve_mod_worker is already running");
if (_worker_lock_file_exists()) {
debug(__LINE__, "pve_mod_worker process already running, system is already started");
return "pve_mod_worker process already running, system is already started";
}
debug(__LINE__, "PVE mod worker is not running. PVE Mod will be started.");
startup_message();
ensure_pve_mod_directory_exists();
debug(__LINE__, "Trying to acquire startup lock: $startup_lock");
my $startup_fh = acquire_exclusive_lock($startup_lock, 'startup lock');
return unless $startup_fh;
# Second check after acquiring lock
if (_worker_lock_file_exists()) {
debug(__LINE__, "Worker started by another process while we waited for lock");
close($startup_fh);
unlink($startup_lock);
return "already running";
}
print $startup_fh "$$\n";
$startup_fh->flush();
debug(__LINE__, "Wrote PID $$ to startup lock");
_pve_mod_worker();
unlink($startup_lock);
debug(__LINE__, "Released startup lock");
debug(__LINE__, "pve_mod_worker started successfully, returning");
}
# Sends SIGUSR1 to the worker to reset the inactivity timer.
sub notify_pve_mod_worker {
debug(__LINE__, "notify_pve_mod_worker called");
unless (-f $pve_mod_worker_lock) {
debug(__LINE__, "pve_mod_worker lock file does not exist");
return;
}
debug(__LINE__, "pve_mod_worker lock file exists, reading PID");
if (open my $fh, '<', $pve_mod_worker_lock) {
my $pid = <$fh>;
close $fh;
chomp $pid if defined $pid;
if (defined $pid && $pid =~ /^(\d+)$/) {
my $clean_pid = $1;
if (is_process_alive($clean_pid)) {
debug(__LINE__, "Sending USR1 signal to pve_mod_worker PID $clean_pid");
my $result = kill('USR1', $clean_pid);
debug(__LINE__, "Signal result: $result");
} else {
debug(__LINE__,
"pve_mod_worker process $clean_pid is not alive, removing stale lock");
unlink($pve_mod_worker_lock);
}
} else {
debug(__LINE__,
"pve_mod_worker lock is stale (PID: " . ($pid // 'undefined') . "), removing");
unlink($pve_mod_worker_lock);
}
} else {
debug(__LINE__, "Failed to open pve_mod_worker lock file: $!");
}
}
# ============================================================================
# Worker process management
# ============================================================================
sub _worker_lock_file_exists {
return -f $pve_mod_worker_lock;
}
# Forks the worker process and records its PID in the lock file.
sub _pve_mod_worker {
debug(__LINE__, "_pve_mod_worker called");
my $pve_mod_worker_fh =
acquire_exclusive_lock($pve_mod_worker_lock, 'pve_mod_worker lock');
return unless $pve_mod_worker_fh;
print $pve_mod_worker_fh "$$\n";
close($pve_mod_worker_fh);
debug(__LINE__, "Forking new pve_mod_worker process");
my $pve_mod_worker_pid = fork();
unless (defined $pve_mod_worker_pid) {
debug(__LINE__, "Failed to fork pve_mod_worker process: $!");
return;
}
if ($pve_mod_worker_pid == 0) {
# Child
$0 = "pve_mod_worker_controller";
debug(__LINE__, "Child process forked, calling _pve_mod_keep_alive");
_pve_mod_keep_alive();
exit(0);
}
# Parent — update lock file with real child PID
debug(__LINE__, "Forked pve_mod_worker process with PID $pve_mod_worker_pid");
if (open my $fh, '>', $pve_mod_worker_lock) {
print $fh "$pve_mod_worker_pid\n";
close $fh;
debug(__LINE__, "Wrote pve_mod_worker PID to lock file: $pve_mod_worker_lock");
} else {
debug(__LINE__, "Failed to write pve_mod_worker lock file: $!");
kill('TERM', $pve_mod_worker_pid);
}
debug(__LINE__, "pve_mod_worker process started successfully");
}
# ============================================================================
# Worker keep-alive loop
# ============================================================================
sub _pve_mod_keep_alive {
$process_type = 'worker';
debug(__LINE__, "pve_mod_worker process started with PID $$");
my $last_activity = time();
$SIG{USR1} = sub {
$last_activity = time();
debug(__LINE__, "Activity ping received");
};
$SIG{CHLD} = sub {
while ((my $pid = waitpid(-1, WNOHANG)) > 0) {
my $exit_status = $? >> 8;
debug(__LINE__, "Child process $pid exited with status $exit_status");
foreach my $name (keys %collectors) {
if ($collectors{$name} == $pid) {
debug(__LINE__,
"Collector '$name' (PID $pid) exited, removing from registry");
delete $collectors{$name};
last;
}
}
}
};
$SIG{TERM} = sub {
debug(__LINE__, "pve_mod_worker received SIGTERM, shutting down");
_stop_child_collectors();
unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock;
exit(0);
};
$SIG{INT} = sub {
debug(__LINE__, "pve_mod_worker received SIGINT, shutting down");
_stop_child_collectors();
unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock;
exit(0);
};
debug(__LINE__, "Worker starting all collectors");
_initialise_sensors_collector();
_initialise_graphics_collectors();
_initialise_ups_collector();
debug(__LINE__, "All collectors started by worker");
debug(__LINE__,
"Entering pve_mod_worker loop, timeout=$config{intervals}{collector_timeout}s");
while (1) {
debug(__LINE__, "pve_mod_worker loop start: checking activity");
my $idle_time = time() - $last_activity;
debug(__LINE__,
"pve_mod_worker loop: idle_time=${idle_time}s, "
. "timeout=$config{intervals}{collector_timeout}s");
if ($idle_time > $config{intervals}{collector_timeout}) {
debug(__LINE__, "Timeout reached, stopping collectors");
_stop_child_collectors();
debug(__LINE__, "Collectors stopped, exiting pve_mod_worker");
unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock;
exit(0);
}
sleep(1);
}
debug(__LINE__, "pve_mod_worker loop exited unexpectedly!");
}
# ============================================================================
# Collector startup helpers (called from worker loop)
# ============================================================================
sub _initialise_sensors_collector {
return unless $config{lm_sensors}{enabled};
return unless check_executable('/usr/bin/sensors', 'lm-sensors',
$config{debug}{lm_sensors_mode},
$config{debug}{lm_sensors_output_file});
debug(__LINE__, "Starting lm-sensors collector");
_start_collector('sensors', 'sensors',
\&collector_for_temperature_sensors,
{ name => 'sensors' });
}
sub _initialise_ups_collector {
unless ($config{ups}{enabled} && $config{ups}{device_name}) {
debug(__LINE__, "UPS collection disabled/invalid in config, skipping");
return;
}
return unless check_executable('/usr/bin/upsc', 'UPS',
$config{debug}{ups_mode},
$config{debug}{ups_output_file});
debug(__LINE__, "Starting UPS collector: $config{ups}{device_name}");
_start_collector('ups', 'ups', \&collector_for_ups,
{ ups_name => $config{ups}{device_name} });
}
sub _initialise_graphics_collectors {
unless ($config{gpu}{intel_enabled}
|| $config{gpu}{amd_enabled}
|| $config{gpu}{nvidia_enabled}) {
debug(__LINE__, "No GPU types enabled, skipping collector startup");
return;
}
debug(__LINE__, "Starting graphics collectors");
my (@all_devices, @all_types, @all_collector_subs);
my @nvidia_devices;
# Intel (each GPU has its own collector)
if ($config{gpu}{intel_enabled} && check_executable('/usr/bin/intel_gpu_top', 'Intel',
$config{debug}{intel_mode},
$config{debug}{intel_devices_file})) {
my @intel_devices = get_intel_gpu_devices();
for my $device (@intel_devices) {
push @all_devices, $device;
push @all_types, 'intel';
push @all_collector_subs, \&collector_for_intel_device;
}
}
# AMD (each GPU has its own collector)
if ($config{gpu}{amd_enabled} && check_executable('/usr/bin/rocm-smi', 'AMD',
$config{debug}{amd_mode},
$config{debug}{amd_devices_file})) {
my @amd_devices = get_amd_gpu_devices();
for my $device (@amd_devices) {
push @all_devices, $device;
push @all_types, 'amd';
push @all_collector_subs, \&collector_for_amd_device;
}
}
# NVIDIA (all GPUs collected together in one collector due to nvidia-smi design)
if ($config{gpu}{nvidia_enabled} && check_executable('/usr/bin/nvidia-smi', 'NVIDIA',
$config{debug}{nvidia_mode},
$config{debug}{nvidia_devices_file})) {
@nvidia_devices = get_nvidia_gpu_devices();
}
debug(__LINE__,
"Detected: "
. scalar(grep { $_ eq 'intel' } @all_types) . " Intel, "
. scalar(grep { $_ eq 'amd' } @all_types) . " AMD, "
. scalar(@nvidia_devices) . " NVIDIA");
my $started_count = 0;
# Start individual collectors for Intel and AMD devices
for (my $i = 0; $i < @all_devices; $i++) {
my $device = $all_devices[$i];
my $type = $all_types[$i];
my $collector_sub = $all_collector_subs[$i];
my $device_name = $device->{card} // $device->{name} // "device$i";
my $pid = _start_collector($device_name, $type, $collector_sub, $device);
$started_count++ if $pid;
}
# NVIDIA — single collector for all GPUs
if (@nvidia_devices) {
my $pid = _start_collector('nvidia-all', 'nvidia',
\&collector_for_nvidia_devices,
\@nvidia_devices);
$started_count++ if $pid;
}
debug(__LINE__,
"Started/verified $started_count graphics collector(s)");
}
# ============================================================================
# Generic collector start/stop
# ============================================================================
sub _start_collector {
my ($collector_name, $collector_type, $collector_sub, $device) = @_;
debug(__LINE__, "Starting $collector_type collector: $collector_name");
if (exists $collectors{$collector_name}) {
my $pid = $collectors{$collector_name};
if (kill(0, $pid)) {
debug(__LINE__,
"$collector_type collector '$collector_name' already running with PID $pid");
return $pid;
} else {
debug(__LINE__,
"Collector '$collector_name' PID $pid is stale, removing from registry");
delete $collectors{$collector_name};
}
}
my $pid = _start_child_collector($collector_name, $collector_sub, $device);
unless ($pid) {
debug(__LINE__, "Failed to start $collector_type collector '$collector_name'");
return undef;
}
$collectors{$collector_name} = $pid;
debug(__LINE__,
"Registered $collector_type collector '$collector_name' with PID $pid");
sleep 0.1;
if (kill(0, $pid)) {
debug(__LINE__,
"Verified $collector_type collector '$collector_name' (PID $pid) is alive");
return $pid;
} else {
debug(__LINE__,
"WARNING - $collector_type collector '$collector_name' (PID $pid) died immediately!");
delete $collectors{$collector_name};
return undef;
}
}
sub _start_child_collector {
my ($collector_name, $collector_sub, $device) = @_;
debug(__LINE__, "Starting child collector: $collector_name");
my $pid = fork();
unless (defined $pid) {
debug(__LINE__, "fork failed for $collector_name: $!");
return undef;
}
if ($pid == 0) {
$process_type = 'collector';
debug(__LINE__, "In child process for $collector_name");
$0 = "collector-$collector_name";
$collector_sub->($device);
exit(0);
}
debug(__LINE__, "Forked child PID $pid for $collector_name");
return $pid;
}
sub _stop_child_collectors {
debug(__LINE__, "Stopping all collectors");
my @pids = values %collectors;
if (@pids) {
debug(__LINE__, "Sending SIGTERM to " . scalar(@pids) . " collector process(es)");
foreach my $pid (@pids) {
if (kill(0, $pid)) {
kill('TERM', $pid);
debug(__LINE__, "Sent SIGTERM to collector PID $pid");
}
}
my $timeout = 2;
my $start = time();
while (time() - $start < $timeout) {
my $any_alive = 0;
foreach my $pid (@pids) {
if (kill(0, $pid)) { $any_alive = 1; last; }
}
last unless $any_alive;
select(undef, undef, undef, 0.1);
}
foreach my $pid (@pids) {
if (kill(0, $pid)) {
debug(__LINE__, "Force killing collector process $pid");
kill('KILL', $pid);
}
}
}
%collectors = ();
debug(__LINE__, "Cleared collector registry");
if (-f $state_file) {
unlink $state_file or debug(__LINE__, "Failed to remove $state_file: $!");
}
if (-d $pve_mod_working_dir) {
remove_tree($pve_mod_working_dir, { error => \my $err });
debug(__LINE__, "Cleanup errors: @$err") if @$err;
}
debug(__LINE__, "Cleanup complete");
}
# ============================================================================
# END block — only the worker process performs cleanup
# ============================================================================
END {
if ($process_type eq 'worker') {
debug(__LINE__, "PVE Mod Worker END block: cleaning up");
_stop_child_collectors();
} elsif ($process_type eq 'collector') {
debug(__LINE__, "Collector ($0) END block: no cleanup needed");
} else {
debug(__LINE__, "Main process END block: no cleanup needed");
}
}
1;

View File

@ -1,218 +1,218 @@
package PVE::API2::PVEMod_SensorInfo;
use strict;
use warnings;
use PVE::PVEMod::Config qw(%config $VERSION $stats_dir $sensors_state_file $ups_state_file);
use PVE::PVEMod::Utils qw(debug safe_read_json);
use PVE::PVEMod::ProcessManager qw(pve_mod_starter notify_pve_mod_worker);
use PVE::PVEMod::Collector::SystemInformation qw(get_system_information_data);
# Per-endpoint state caches (module-level, reset on worker restart)
my $graphics_cache = { data => {}, mtime => 0 };
my $sensors_cache = { data => '{}', mtime => 0 };
my $ups_cache = { data => '{}', mtime => 0 };
my $system_info_cache = undef;
# ============================================================================
# Internal helpers
# ============================================================================
sub _read_state_file_cached {
my ($files, $cache_ref, $reader, $empty_fallback) = @_;
# Normalize scalar path to single-element arrayref
my @filepaths = ref($files) eq 'ARRAY' ? @$files : ($files);
# Find newest mtime across all files
my $newest_mtime = 0;
my $any_exist = 0;
foreach my $fp (@filepaths) {
my @st = stat($fp);
if (@st) {
$any_exist = 1;
$newest_mtime = $st[9] if $st[9] > $newest_mtime;
}
}
unless ($any_exist) {
debug(__LINE__, "No state files exist: " . join(', ', @filepaths));
return $cache_ref->{data} // $empty_fallback;
}
if ($newest_mtime == $cache_ref->{mtime} && defined $cache_ref->{data}) {
debug(__LINE__, "State files unchanged, returning cached data");
return $cache_ref->{data};
}
my $data;
if (ref($reader) eq 'CODE') {
$data = $reader->(\@filepaths);
} else {
$data = safe_read_json($filepaths[0], $reader);
}
if (!defined $data) {
debug(__LINE__, "Failed to read state file(s): " . join(', ', @filepaths));
return $cache_ref->{data} // $empty_fallback;
}
$cache_ref->{data} = $data;
$cache_ref->{mtime} = $newest_mtime;
return $cache_ref->{data};
}
sub _merge_graphics_files {
my ($filepaths) = @_;
my $merged = {
Graphics => {
Intel => {},
NVIDIA => {},
AMD => {},
}
};
foreach my $filepath (@$filepaths) {
my ($file) = $filepath =~ m{([^/]+)$};
debug(__LINE__, "Reading device file: $filepath");
my $device_data = safe_read_json($filepath, 0);
if (!$device_data) {
debug(__LINE__, "Failed to read/parse $filepath");
next;
}
my $device_type = ($file =~ /^stats-card/) ? 'Intel'
: ($file =~ /^stats-nvidia/) ? 'NVIDIA'
: 'AMD';
foreach my $node_name (keys %$device_data) {
$merged->{Graphics}->{$device_type}->{$node_name} = $device_data->{$node_name};
debug(__LINE__, "Merged $device_type node '$node_name' from $file");
}
}
return $merged;
}
sub _load_graphics_data {
# Build filename patterns for enabled GPU types
my @patterns;
push @patterns, 'card\d+' if $config{gpu}{intel_enabled};
push @patterns, 'nvidia\d+' if $config{gpu}{nvidia_enabled};
push @patterns, 'amd\d+' if $config{gpu}{amd_enabled};
unless (@patterns) {
debug(__LINE__, "No GPU types enabled in config");
return $graphics_cache->{data};
}
my $pattern = join('|', @patterns);
# Find device stat files for enabled GPU types
my $dh;
unless (opendir($dh, $stats_dir)) {
debug(__LINE__, "Failed to open stats directory: $stats_dir: $!");
return $graphics_cache->{data};
}
my @stat_files = grep { /^stats-(?:$pattern)\.json$/ } readdir($dh);
closedir($dh);
unless (@stat_files) {
debug(__LINE__, "No device stat files found in $stats_dir");
return $graphics_cache->{data};
}
debug(__LINE__, "Found " . scalar(@stat_files) . " device stat file(s): " . join(', ', @stat_files));
my @filepaths = map { "$stats_dir/$_" } @stat_files;
my $data = _read_state_file_cached(
\@filepaths,
$graphics_cache,
\&_merge_graphics_files,
{ Graphics => { Intel => {}, NVIDIA => {}, AMD => {} } }
);
my $intel_count = scalar(keys %{$data->{Graphics}{Intel} // {}});
my $nvidia_count = scalar(keys %{$data->{Graphics}{NVIDIA} // {}});
my $amd_count = scalar(keys %{$data->{Graphics}{AMD} // {}});
debug(__LINE__, "Returning $intel_count Intel + $nvidia_count NVIDIA + $amd_count AMD device node(s)");
return $data;
}
# ============================================================================
# API calls
# ============================================================================
sub get_graphic_info {
debug(__LINE__, "get_graphic_info called");
# Start PVE Mod
pve_mod_starter();
my $data = _load_graphics_data();
# Notify pve_mod_worker of activity
notify_pve_mod_worker();
return $data;
}
sub get_sensors_info {
debug(__LINE__, "get_sensors_info called");
# Start PVE Mod
pve_mod_starter();
my $data = _read_state_file_cached($sensors_state_file, $sensors_cache, 1, '{}');
# Notify pve_mod_worker of activity
notify_pve_mod_worker();
return $data;
}
sub get_ups_info {
debug(__LINE__, "get_ups_info called");
# Start PVE Mod
pve_mod_starter();
my $data = _read_state_file_cached($ups_state_file, $ups_cache, 1, '{}');
# Notify pve_mod_worker of activity
notify_pve_mod_worker();
return $data;
}
sub get_pve_mod_version {
debug(__LINE__, "get_pve_mod_version called");
# Notify pve_mod_worker of activity
notify_pve_mod_worker();
debug(__LINE__, "Returning version: $VERSION");
return $VERSION;
}
sub get_system_information {
debug(__LINE__, "get_system_information called");
if (defined $system_info_cache) {
debug(__LINE__, "Returning cached system information");
return $system_info_cache;
}
$system_info_cache = get_system_information_data();
return $system_info_cache;
}
1;
package PVE::API2::PVEMod_SensorInfo;
use strict;
use warnings;
use PVE::PVEMod::Config qw(%config $VERSION $stats_dir $sensors_state_file $ups_state_file);
use PVE::PVEMod::Utils qw(debug safe_read_json);
use PVE::PVEMod::ProcessManager qw(pve_mod_starter notify_pve_mod_worker);
use PVE::PVEMod::Collector::SystemInformation qw(get_system_information_data);
# Per-endpoint state caches (module-level, reset on worker restart)
my $graphics_cache = { data => {}, mtime => 0 };
my $sensors_cache = { data => '{}', mtime => 0 };
my $ups_cache = { data => '{}', mtime => 0 };
my $system_info_cache = undef;
# ============================================================================
# Internal helpers
# ============================================================================
sub _read_state_file_cached {
my ($files, $cache_ref, $reader, $empty_fallback) = @_;
# Normalize scalar path to single-element arrayref
my @filepaths = ref($files) eq 'ARRAY' ? @$files : ($files);
# Find newest mtime across all files
my $newest_mtime = 0;
my $any_exist = 0;
foreach my $fp (@filepaths) {
my @st = stat($fp);
if (@st) {
$any_exist = 1;
$newest_mtime = $st[9] if $st[9] > $newest_mtime;
}
}
unless ($any_exist) {
debug(__LINE__, "No state files exist: " . join(', ', @filepaths));
return $cache_ref->{data} // $empty_fallback;
}
if ($newest_mtime == $cache_ref->{mtime} && defined $cache_ref->{data}) {
debug(__LINE__, "State files unchanged, returning cached data");
return $cache_ref->{data};
}
my $data;
if (ref($reader) eq 'CODE') {
$data = $reader->(\@filepaths);
} else {
$data = safe_read_json($filepaths[0], $reader);
}
if (!defined $data) {
debug(__LINE__, "Failed to read state file(s): " . join(', ', @filepaths));
return $cache_ref->{data} // $empty_fallback;
}
$cache_ref->{data} = $data;
$cache_ref->{mtime} = $newest_mtime;
return $cache_ref->{data};
}
sub _merge_graphics_files {
my ($filepaths) = @_;
my $merged = {
Graphics => {
Intel => {},
NVIDIA => {},
AMD => {},
}
};
foreach my $filepath (@$filepaths) {
my ($file) = $filepath =~ m{([^/]+)$};
debug(__LINE__, "Reading device file: $filepath");
my $device_data = safe_read_json($filepath, 0);
if (!$device_data) {
debug(__LINE__, "Failed to read/parse $filepath");
next;
}
my $device_type = ($file =~ /^stats-card/) ? 'Intel'
: ($file =~ /^stats-nvidia/) ? 'NVIDIA'
: 'AMD';
foreach my $node_name (keys %$device_data) {
$merged->{Graphics}->{$device_type}->{$node_name} = $device_data->{$node_name};
debug(__LINE__, "Merged $device_type node '$node_name' from $file");
}
}
return $merged;
}
sub _load_graphics_data {
# Build filename patterns for enabled GPU types
my @patterns;
push @patterns, 'card\d+' if $config{gpu}{intel_enabled};
push @patterns, 'nvidia\d+' if $config{gpu}{nvidia_enabled};
push @patterns, 'amd\d+' if $config{gpu}{amd_enabled};
unless (@patterns) {
debug(__LINE__, "No GPU types enabled in config");
return $graphics_cache->{data};
}
my $pattern = join('|', @patterns);
# Find device stat files for enabled GPU types
my $dh;
unless (opendir($dh, $stats_dir)) {
debug(__LINE__, "Failed to open stats directory: $stats_dir: $!");
return $graphics_cache->{data};
}
my @stat_files = grep { /^stats-(?:$pattern)\.json$/ } readdir($dh);
closedir($dh);
unless (@stat_files) {
debug(__LINE__, "No device stat files found in $stats_dir");
return $graphics_cache->{data};
}
debug(__LINE__, "Found " . scalar(@stat_files) . " device stat file(s): " . join(', ', @stat_files));
my @filepaths = map { "$stats_dir/$_" } @stat_files;
my $data = _read_state_file_cached(
\@filepaths,
$graphics_cache,
\&_merge_graphics_files,
{ Graphics => { Intel => {}, NVIDIA => {}, AMD => {} } }
);
my $intel_count = scalar(keys %{$data->{Graphics}{Intel} // {}});
my $nvidia_count = scalar(keys %{$data->{Graphics}{NVIDIA} // {}});
my $amd_count = scalar(keys %{$data->{Graphics}{AMD} // {}});
debug(__LINE__, "Returning $intel_count Intel + $nvidia_count NVIDIA + $amd_count AMD device node(s)");
return $data;
}
# ============================================================================
# API calls
# ============================================================================
sub get_graphic_info {
debug(__LINE__, "get_graphic_info called");
# Start PVE Mod
pve_mod_starter();
my $data = _load_graphics_data();
# Notify pve_mod_worker of activity
notify_pve_mod_worker();
return $data;
}
sub get_sensors_info {
debug(__LINE__, "get_sensors_info called");
# Start PVE Mod
pve_mod_starter();
my $data = _read_state_file_cached($sensors_state_file, $sensors_cache, 1, '{}');
# Notify pve_mod_worker of activity
notify_pve_mod_worker();
return $data;
}
sub get_ups_info {
debug(__LINE__, "get_ups_info called");
# Start PVE Mod
pve_mod_starter();
my $data = _read_state_file_cached($ups_state_file, $ups_cache, 1, '{}');
# Notify pve_mod_worker of activity
notify_pve_mod_worker();
return $data;
}
sub get_pve_mod_version {
debug(__LINE__, "get_pve_mod_version called");
# Notify pve_mod_worker of activity
notify_pve_mod_worker();
debug(__LINE__, "Returning version: $VERSION");
return $VERSION;
}
sub get_system_information {
debug(__LINE__, "get_system_information called");
if (defined $system_info_cache) {
debug(__LINE__, "Returning cached system information");
return $system_info_cache;
}
$system_info_cache = get_system_information_data();
return $system_info_cache;
}
1;

File diff suppressed because it is too large Load Diff

View File

@ -1,166 +1,166 @@
package PVE::PVEMod::Store;
use strict;
use warnings;
use Exporter 'import';
use File::Path qw(make_path);
use PVE::INotify;
use RRDs;
use PVE::PVEMod::Config qw($RRD_SOCKET $RRD_BASE);
use PVE::PVEMod::Utils qw(debug);
our @EXPORT_OK = qw(
get_nodename
gpu_rrd_path
update_intel_gpu_rrd
update_nvidia_gpu_rrd
);
# ============================================================================
# Node name
# ============================================================================
sub get_nodename {
return PVE::INotify::nodename();
}
# ============================================================================
# RRD path helper
# ============================================================================
sub gpu_rrd_path {
my ($card) = @_;
return "$RRD_BASE/" . get_nodename() . "/$card";
}
# ============================================================================
# Intel GPU RRD
# ============================================================================
sub _ensure_intel_gpu_rrd {
my ($card) = @_;
my $path = gpu_rrd_path($card);
return if -f $path;
my $dir = "$RRD_BASE/" . get_nodename();
make_path($dir, { mode => 0755 }) unless -d $dir;
RRDs::create(
$path,
'--step', '1',
'DS:freq_req:GAUGE:120:0:U',
'DS:freq_act:GAUGE:120:0:U',
'DS:rc6:GAUGE:120:0:100',
'DS:power_gpu:GAUGE:120:0:U',
'DS:power_pkg:GAUGE:120:0:U',
'DS:render_busy:GAUGE:120:0:100',
'DS:blitter_busy:GAUGE:120:0:100',
'DS:video_busy:GAUGE:120:0:100',
'DS:videnh_busy:GAUGE:120:0:100',
'RRA:AVERAGE:0.5:1:1440',
'RRA:AVERAGE:0.5:60:1440',
'RRA:AVERAGE:0.5:1800:1344',
'RRA:AVERAGE:0.5:21600:1464',
'RRA:AVERAGE:0.5:604800:520',
'RRA:MAX:0.5:1:1440',
'RRA:MAX:0.5:60:1440',
'RRA:MAX:0.5:1800:1344',
'RRA:MAX:0.5:21600:1464',
'RRA:MAX:0.5:604800:520',
);
my $err = RRDs::error();
debug(__LINE__, "Created Intel GPU RRD $path: " . ($err // 'OK'));
}
sub update_intel_gpu_rrd {
my ($card, $stats) = @_;
_ensure_intel_gpu_rrd($card);
my $path = gpu_rrd_path($card);
my $freq_req = $stats->{frequency}{requested} // 'U';
my $freq_act = $stats->{frequency}{actual} // 'U';
my $rc6 = $stats->{rc6}{value} // 'U';
my $power_gpu = $stats->{power}{GPU} // 'U';
my $power_pkg = $stats->{power}{Package} // 'U';
my $render_busy = $stats->{engines}{'Render/3D'}{busy} // 'U';
my $blitter = $stats->{engines}{Blitter}{busy} // 'U';
my $video = $stats->{engines}{Video}{busy} // 'U';
my $videnh = $stats->{engines}{VideoEnhance}{busy} // 'U';
my @daemon_args = (-S $RRD_SOCKET) ? ('--daemon', "unix:$RRD_SOCKET") : ();
RRDs::update(
$path,
@daemon_args,
"N:$freq_req:$freq_act:$rc6:$power_gpu:$power_pkg:$render_busy:$blitter:$video:$videnh",
);
my $err = RRDs::error();
debug(__LINE__, "RRD update intel $card: $err") if $err;
}
# ============================================================================
# NVIDIA GPU RRD
# ============================================================================
sub _ensure_nvidia_gpu_rrd {
my ($index) = @_;
my $card = "nvidia$index";
my $path = gpu_rrd_path($card);
return if -f $path;
my $dir = "$RRD_BASE/" . get_nodename();
make_path($dir, { mode => 0755 }) unless -d $dir;
RRDs::create(
$path,
'--step', '1',
'DS:gpu_util:GAUGE:120:0:100',
'DS:mem_util:GAUGE:120:0:100',
'DS:mem_used:GAUGE:120:0:U',
'DS:mem_total:GAUGE:120:0:U',
'DS:power_draw:GAUGE:120:0:U',
'DS:power_limit:GAUGE:120:0:U',
'DS:temp_gpu:GAUGE:120:0:U',
'DS:fan_speed:GAUGE:120:0:100',
'RRA:AVERAGE:0.5:1:1440',
'RRA:AVERAGE:0.5:60:1440',
'RRA:AVERAGE:0.5:1800:1344',
'RRA:AVERAGE:0.5:21600:1464',
'RRA:AVERAGE:0.5:604800:520',
'RRA:MAX:0.5:1:1440',
'RRA:MAX:0.5:60:1440',
'RRA:MAX:0.5:1800:1344',
'RRA:MAX:0.5:21600:1464',
'RRA:MAX:0.5:604800:520',
);
my $err = RRDs::error();
debug(__LINE__, "Created NVIDIA GPU RRD $path: " . ($err // 'OK'));
}
sub update_nvidia_gpu_rrd {
my ($index, $stats) = @_;
_ensure_nvidia_gpu_rrd($index);
my $card = "nvidia$index";
my $path = gpu_rrd_path($card);
my $gpu_util = $stats->{utilization}{gpu} // 'U';
my $mem_util = $stats->{utilization}{memory} // 'U';
my $mem_used = $stats->{memory}{used} // 'U';
my $mem_total = $stats->{memory}{total} // 'U';
my $power_draw = $stats->{power}{draw} // 'U';
my $power_limit = $stats->{power}{limit} // 'U';
my $temp_gpu = $stats->{temperature}{gpu} // 'U';
my $fan_speed = $stats->{fan}{speed} // 'U';
my @daemon_args = (-S $RRD_SOCKET) ? ('--daemon', "unix:$RRD_SOCKET") : ();
RRDs::update(
$path,
@daemon_args,
"N:$gpu_util:$mem_util:$mem_used:$mem_total:$power_draw:$power_limit:$temp_gpu:$fan_speed",
);
my $err = RRDs::error();
debug(__LINE__, "RRD update nvidia$index: $err") if $err;
}
1;
package PVE::PVEMod::Store;
use strict;
use warnings;
use Exporter 'import';
use File::Path qw(make_path);
use PVE::INotify;
use RRDs;
use PVE::PVEMod::Config qw($RRD_SOCKET $RRD_BASE);
use PVE::PVEMod::Utils qw(debug);
our @EXPORT_OK = qw(
get_nodename
gpu_rrd_path
update_intel_gpu_rrd
update_nvidia_gpu_rrd
);
# ============================================================================
# Node name
# ============================================================================
sub get_nodename {
return PVE::INotify::nodename();
}
# ============================================================================
# RRD path helper
# ============================================================================
sub gpu_rrd_path {
my ($card) = @_;
return "$RRD_BASE/" . get_nodename() . "/$card";
}
# ============================================================================
# Intel GPU RRD
# ============================================================================
sub _ensure_intel_gpu_rrd {
my ($card) = @_;
my $path = gpu_rrd_path($card);
return if -f $path;
my $dir = "$RRD_BASE/" . get_nodename();
make_path($dir, { mode => 0755 }) unless -d $dir;
RRDs::create(
$path,
'--step', '1',
'DS:freq_req:GAUGE:120:0:U',
'DS:freq_act:GAUGE:120:0:U',
'DS:rc6:GAUGE:120:0:100',
'DS:power_gpu:GAUGE:120:0:U',
'DS:power_pkg:GAUGE:120:0:U',
'DS:render_busy:GAUGE:120:0:100',
'DS:blitter_busy:GAUGE:120:0:100',
'DS:video_busy:GAUGE:120:0:100',
'DS:videnh_busy:GAUGE:120:0:100',
'RRA:AVERAGE:0.5:1:1440',
'RRA:AVERAGE:0.5:60:1440',
'RRA:AVERAGE:0.5:1800:1344',
'RRA:AVERAGE:0.5:21600:1464',
'RRA:AVERAGE:0.5:604800:520',
'RRA:MAX:0.5:1:1440',
'RRA:MAX:0.5:60:1440',
'RRA:MAX:0.5:1800:1344',
'RRA:MAX:0.5:21600:1464',
'RRA:MAX:0.5:604800:520',
);
my $err = RRDs::error();
debug(__LINE__, "Created Intel GPU RRD $path: " . ($err // 'OK'));
}
sub update_intel_gpu_rrd {
my ($card, $stats) = @_;
_ensure_intel_gpu_rrd($card);
my $path = gpu_rrd_path($card);
my $freq_req = $stats->{frequency}{requested} // 'U';
my $freq_act = $stats->{frequency}{actual} // 'U';
my $rc6 = $stats->{rc6}{value} // 'U';
my $power_gpu = $stats->{power}{GPU} // 'U';
my $power_pkg = $stats->{power}{Package} // 'U';
my $render_busy = $stats->{engines}{'Render/3D'}{busy} // 'U';
my $blitter = $stats->{engines}{Blitter}{busy} // 'U';
my $video = $stats->{engines}{Video}{busy} // 'U';
my $videnh = $stats->{engines}{VideoEnhance}{busy} // 'U';
my @daemon_args = (-S $RRD_SOCKET) ? ('--daemon', "unix:$RRD_SOCKET") : ();
RRDs::update(
$path,
@daemon_args,
"N:$freq_req:$freq_act:$rc6:$power_gpu:$power_pkg:$render_busy:$blitter:$video:$videnh",
);
my $err = RRDs::error();
debug(__LINE__, "RRD update intel $card: $err") if $err;
}
# ============================================================================
# NVIDIA GPU RRD
# ============================================================================
sub _ensure_nvidia_gpu_rrd {
my ($index) = @_;
my $card = "nvidia$index";
my $path = gpu_rrd_path($card);
return if -f $path;
my $dir = "$RRD_BASE/" . get_nodename();
make_path($dir, { mode => 0755 }) unless -d $dir;
RRDs::create(
$path,
'--step', '1',
'DS:gpu_util:GAUGE:120:0:100',
'DS:mem_util:GAUGE:120:0:100',
'DS:mem_used:GAUGE:120:0:U',
'DS:mem_total:GAUGE:120:0:U',
'DS:power_draw:GAUGE:120:0:U',
'DS:power_limit:GAUGE:120:0:U',
'DS:temp_gpu:GAUGE:120:0:U',
'DS:fan_speed:GAUGE:120:0:100',
'RRA:AVERAGE:0.5:1:1440',
'RRA:AVERAGE:0.5:60:1440',
'RRA:AVERAGE:0.5:1800:1344',
'RRA:AVERAGE:0.5:21600:1464',
'RRA:AVERAGE:0.5:604800:520',
'RRA:MAX:0.5:1:1440',
'RRA:MAX:0.5:60:1440',
'RRA:MAX:0.5:1800:1344',
'RRA:MAX:0.5:21600:1464',
'RRA:MAX:0.5:604800:520',
);
my $err = RRDs::error();
debug(__LINE__, "Created NVIDIA GPU RRD $path: " . ($err // 'OK'));
}
sub update_nvidia_gpu_rrd {
my ($index, $stats) = @_;
_ensure_nvidia_gpu_rrd($index);
my $card = "nvidia$index";
my $path = gpu_rrd_path($card);
my $gpu_util = $stats->{utilization}{gpu} // 'U';
my $mem_util = $stats->{utilization}{memory} // 'U';
my $mem_used = $stats->{memory}{used} // 'U';
my $mem_total = $stats->{memory}{total} // 'U';
my $power_draw = $stats->{power}{draw} // 'U';
my $power_limit = $stats->{power}{limit} // 'U';
my $temp_gpu = $stats->{temperature}{gpu} // 'U';
my $fan_speed = $stats->{fan}{speed} // 'U';
my @daemon_args = (-S $RRD_SOCKET) ? ('--daemon', "unix:$RRD_SOCKET") : ();
RRDs::update(
$path,
@daemon_args,
"N:$gpu_util:$mem_util:$mem_used:$mem_total:$power_draw:$power_limit:$temp_gpu:$fan_speed",
);
my $err = RRDs::error();
debug(__LINE__, "RRD update nvidia$index: $err") if $err;
}
1;

View File

@ -1,274 +1,274 @@
package PVE::PVEMod::Utils;
use strict;
use warnings;
use Exporter 'import';
use JSON;
use Fcntl qw(O_CREAT O_EXCL O_WRONLY);
use PVE::PVEMod::Config qw($DEBUG_ENABLED $VERSION $pve_mod_working_dir %config);
my $debug_log_fh;
our @EXPORT_OK = qw(
debug
read_sysfs
is_process_alive
read_lock_pid
acquire_exclusive_lock
ensure_pve_mod_directory_exists
check_executable
startup_message
setup_collector_signals
safe_write_json
safe_read_json
parse_csv_line
);
# ============================================================================
# Debug
# ============================================================================
# debug function showing line number and call chain
# Usage: debug(__LINE__, "message")
sub debug {
return unless $DEBUG_ENABLED;
my ($line, $message) = @_;
my @caller1 = caller(1); # who called debug()
my @caller2 = caller(2); # parent of caller
my $sub1 = $caller1[3] || 'main';
my $sub2 = $caller2[3];
$sub1 =~ s/.*:://;
my $output;
if (defined $sub2) {
$sub2 =~ s/.*:://;
$output = "[$sub2 -> $sub1:$line] $message\n";
} else {
$output = "[$sub1:$line] $message\n";
}
warn $output;
if ($config{debug}{log_enabled} && !defined $debug_log_fh) {
if (open(my $fh, '>>', $config{debug}{log_file})) {
$fh->autoflush(1);
$debug_log_fh = $fh;
} else {
warn "[debug] Failed to open log file $config{debug}{log_file}: $!\n";
}
}
print $debug_log_fh $output if defined $debug_log_fh;
}
# ============================================================================
# File / Process helpers
# ============================================================================
sub read_sysfs {
my ($path) = @_;
return "unknown" unless defined $path && -f $path;
if (open my $fh, '<', $path) {
my $value = <$fh>;
close $fh;
if (defined $value) {
chomp $value;
$value =~ s/^\s+|\s+$//g;
return $value ne '' ? $value : "unknown";
}
}
return "unknown";
}
sub is_process_alive {
my ($pid) = @_;
return -d "/proc/$pid";
}
sub read_lock_pid {
my ($lock_path) = @_;
return undef unless open(my $fh, '<', $lock_path);
my $pid = <$fh>;
close($fh);
chomp $pid if defined $pid;
return $pid;
}
sub acquire_exclusive_lock {
my ($lock_path, $purpose) = @_;
$purpose //= 'lock';
my $fh;
if (sysopen($fh, $lock_path, O_CREAT|O_EXCL|O_WRONLY, 0644)) {
debug(__LINE__, "Acquired $purpose on first try");
return $fh;
}
debug(__LINE__, ucfirst($purpose) . " exists, checking if stale");
my $lock_pid = read_lock_pid($lock_path);
if (!defined $lock_pid) {
debug(__LINE__, "Could not read $purpose file: $!");
return undef;
}
if ($lock_pid eq '' || $lock_pid !~ /^\d+$/) {
debug(__LINE__, "Invalid PID in $purpose: '" . ($lock_pid // 'undefined') . "', removing");
unlink($lock_path);
} elsif (is_process_alive($lock_pid)) {
debug(__LINE__, ucfirst($purpose) . " holder PID $lock_pid is still alive");
return undef;
} else {
debug(__LINE__, ucfirst($purpose) . " holder PID $lock_pid is dead, removing stale lock");
unlink($lock_path);
}
unless (sysopen($fh, $lock_path, O_CREAT|O_EXCL|O_WRONLY, 0644)) {
debug(__LINE__, "Failed to acquire $purpose on retry: $!");
return undef;
}
debug(__LINE__, "Acquired $purpose after removing stale lock");
return $fh;
}
sub ensure_pve_mod_directory_exists {
unless (-d $pve_mod_working_dir) {
debug(__LINE__, "Creating directory $pve_mod_working_dir");
unless (mkdir($pve_mod_working_dir, 0755)) {
debug(__LINE__, "Failed to create $pve_mod_working_dir: $!. PVE Mod cannot start.");
die "Failed to create $pve_mod_working_dir: $!";
}
debug(__LINE__, "Directory $pve_mod_working_dir created");
} else {
debug(__LINE__, "Directory $pve_mod_working_dir already exists");
}
}
# Returns 1 if executable exists, or debug mode is active with a debug file present.
# Returns 0 otherwise.
sub check_executable {
my ($exec_path, $type, $debug_mode_enabled, $debug_file) = @_;
if (defined $debug_mode_enabled && $debug_mode_enabled) {
if (defined $debug_file && -f $debug_file) {
debug(__LINE__, "Debug mode enabled for $type, using debug file: $debug_file");
return 1;
} elsif (defined $debug_file) {
debug(__LINE__, "Debug mode enabled for $type but debug file missing: $debug_file");
return 0;
} else {
debug(__LINE__, "Debug mode enabled for $type, skipping executable check for $exec_path");
return 1;
}
}
unless (-x $exec_path) {
debug(__LINE__, "$type executable not found or not executable: $exec_path");
return 0;
}
debug(__LINE__, "$type executable found: $exec_path");
return 1;
}
sub startup_message {
debug(__LINE__, "PVE Mod is being started. Version $VERSION");
}
# Setup common TERM/INT signal handlers for collector processes.
# $shutdown_ref is a scalar ref that will be set to 1 on signal.
sub setup_collector_signals {
my ($name, $shutdown_ref, $extra_cleanup) = @_;
$SIG{TERM} = sub {
debug(__LINE__, "Collector $name received SIGTERM");
$$shutdown_ref = 1;
$extra_cleanup->() if $extra_cleanup;
};
$SIG{INT} = sub {
debug(__LINE__, "Collector $name received SIGINT");
$$shutdown_ref = 1;
$extra_cleanup->() if $extra_cleanup;
};
}
# ============================================================================
# JSON helpers
# ============================================================================
sub safe_write_json {
my ($filepath, $data, $pretty) = @_;
$pretty //= 1;
eval {
open my $fh, '>', $filepath or die "Failed to open $filepath: $!";
my $json = $pretty ? JSON->new->pretty->encode($data) : encode_json($data);
print $fh $json;
close $fh;
debug(__LINE__, "Wrote JSON to $filepath");
};
if ($@) {
debug(__LINE__, "Error writing to $filepath: $@");
return 0;
}
return 1;
}
sub safe_read_json {
my ($filepath, $as_string) = @_;
return unless -f $filepath;
my $result;
eval {
open my $fh, '<', $filepath or die "Failed to open $filepath: $!";
local $/;
my $json = <$fh>;
close $fh;
if ($as_string) {
$result = $json;
} else {
$result = decode_json($json);
}
debug(__LINE__, "Read JSON from $filepath");
};
if ($@) {
debug(__LINE__, "Error reading $filepath: $@");
return;
}
return $result;
}
# ============================================================================
# CSV helper
# ============================================================================
sub parse_csv_line {
my ($line, $expected_fields) = @_;
return unless $line;
$line =~ s/^\s+|\s+$//g;
my @values = map { s/^\s+|\s+$//gr } split(/,/, $line);
return unless !$expected_fields || @values >= $expected_fields;
return @values;
}
1;
package PVE::PVEMod::Utils;
use strict;
use warnings;
use Exporter 'import';
use JSON;
use Fcntl qw(O_CREAT O_EXCL O_WRONLY);
use PVE::PVEMod::Config qw($DEBUG_ENABLED $VERSION $pve_mod_working_dir %config);
my $debug_log_fh;
our @EXPORT_OK = qw(
debug
read_sysfs
is_process_alive
read_lock_pid
acquire_exclusive_lock
ensure_pve_mod_directory_exists
check_executable
startup_message
setup_collector_signals
safe_write_json
safe_read_json
parse_csv_line
);
# ============================================================================
# Debug
# ============================================================================
# debug function showing line number and call chain
# Usage: debug(__LINE__, "message")
sub debug {
return unless $DEBUG_ENABLED;
my ($line, $message) = @_;
my @caller1 = caller(1); # who called debug()
my @caller2 = caller(2); # parent of caller
my $sub1 = $caller1[3] || 'main';
my $sub2 = $caller2[3];
$sub1 =~ s/.*:://;
my $output;
if (defined $sub2) {
$sub2 =~ s/.*:://;
$output = "[$sub2 -> $sub1:$line] $message\n";
} else {
$output = "[$sub1:$line] $message\n";
}
warn $output;
if ($config{debug}{log_enabled} && !defined $debug_log_fh) {
if (open(my $fh, '>>', $config{debug}{log_file})) {
$fh->autoflush(1);
$debug_log_fh = $fh;
} else {
warn "[debug] Failed to open log file $config{debug}{log_file}: $!\n";
}
}
print $debug_log_fh $output if defined $debug_log_fh;
}
# ============================================================================
# File / Process helpers
# ============================================================================
sub read_sysfs {
my ($path) = @_;
return "unknown" unless defined $path && -f $path;
if (open my $fh, '<', $path) {
my $value = <$fh>;
close $fh;
if (defined $value) {
chomp $value;
$value =~ s/^\s+|\s+$//g;
return $value ne '' ? $value : "unknown";
}
}
return "unknown";
}
sub is_process_alive {
my ($pid) = @_;
return -d "/proc/$pid";
}
sub read_lock_pid {
my ($lock_path) = @_;
return undef unless open(my $fh, '<', $lock_path);
my $pid = <$fh>;
close($fh);
chomp $pid if defined $pid;
return $pid;
}
sub acquire_exclusive_lock {
my ($lock_path, $purpose) = @_;
$purpose //= 'lock';
my $fh;
if (sysopen($fh, $lock_path, O_CREAT|O_EXCL|O_WRONLY, 0644)) {
debug(__LINE__, "Acquired $purpose on first try");
return $fh;
}
debug(__LINE__, ucfirst($purpose) . " exists, checking if stale");
my $lock_pid = read_lock_pid($lock_path);
if (!defined $lock_pid) {
debug(__LINE__, "Could not read $purpose file: $!");
return undef;
}
if ($lock_pid eq '' || $lock_pid !~ /^\d+$/) {
debug(__LINE__, "Invalid PID in $purpose: '" . ($lock_pid // 'undefined') . "', removing");
unlink($lock_path);
} elsif (is_process_alive($lock_pid)) {
debug(__LINE__, ucfirst($purpose) . " holder PID $lock_pid is still alive");
return undef;
} else {
debug(__LINE__, ucfirst($purpose) . " holder PID $lock_pid is dead, removing stale lock");
unlink($lock_path);
}
unless (sysopen($fh, $lock_path, O_CREAT|O_EXCL|O_WRONLY, 0644)) {
debug(__LINE__, "Failed to acquire $purpose on retry: $!");
return undef;
}
debug(__LINE__, "Acquired $purpose after removing stale lock");
return $fh;
}
sub ensure_pve_mod_directory_exists {
unless (-d $pve_mod_working_dir) {
debug(__LINE__, "Creating directory $pve_mod_working_dir");
unless (mkdir($pve_mod_working_dir, 0755)) {
debug(__LINE__, "Failed to create $pve_mod_working_dir: $!. PVE Mod cannot start.");
die "Failed to create $pve_mod_working_dir: $!";
}
debug(__LINE__, "Directory $pve_mod_working_dir created");
} else {
debug(__LINE__, "Directory $pve_mod_working_dir already exists");
}
}
# Returns 1 if executable exists, or debug mode is active with a debug file present.
# Returns 0 otherwise.
sub check_executable {
my ($exec_path, $type, $debug_mode_enabled, $debug_file) = @_;
if (defined $debug_mode_enabled && $debug_mode_enabled) {
if (defined $debug_file && -f $debug_file) {
debug(__LINE__, "Debug mode enabled for $type, using debug file: $debug_file");
return 1;
} elsif (defined $debug_file) {
debug(__LINE__, "Debug mode enabled for $type but debug file missing: $debug_file");
return 0;
} else {
debug(__LINE__, "Debug mode enabled for $type, skipping executable check for $exec_path");
return 1;
}
}
unless (-x $exec_path) {
debug(__LINE__, "$type executable not found or not executable: $exec_path");
return 0;
}
debug(__LINE__, "$type executable found: $exec_path");
return 1;
}
sub startup_message {
debug(__LINE__, "PVE Mod is being started. Version $VERSION");
}
# Setup common TERM/INT signal handlers for collector processes.
# $shutdown_ref is a scalar ref that will be set to 1 on signal.
sub setup_collector_signals {
my ($name, $shutdown_ref, $extra_cleanup) = @_;
$SIG{TERM} = sub {
debug(__LINE__, "Collector $name received SIGTERM");
$$shutdown_ref = 1;
$extra_cleanup->() if $extra_cleanup;
};
$SIG{INT} = sub {
debug(__LINE__, "Collector $name received SIGINT");
$$shutdown_ref = 1;
$extra_cleanup->() if $extra_cleanup;
};
}
# ============================================================================
# JSON helpers
# ============================================================================
sub safe_write_json {
my ($filepath, $data, $pretty) = @_;
$pretty //= 1;
eval {
open my $fh, '>', $filepath or die "Failed to open $filepath: $!";
my $json = $pretty ? JSON->new->pretty->encode($data) : encode_json($data);
print $fh $json;
close $fh;
debug(__LINE__, "Wrote JSON to $filepath");
};
if ($@) {
debug(__LINE__, "Error writing to $filepath: $@");
return 0;
}
return 1;
}
sub safe_read_json {
my ($filepath, $as_string) = @_;
return unless -f $filepath;
my $result;
eval {
open my $fh, '<', $filepath or die "Failed to open $filepath: $!";
local $/;
my $json = <$fh>;
close $fh;
if ($as_string) {
$result = $json;
} else {
$result = decode_json($json);
}
debug(__LINE__, "Read JSON from $filepath");
};
if ($@) {
debug(__LINE__, "Error reading $filepath: $@");
return;
}
return $result;
}
# ============================================================================
# CSV helper
# ============================================================================
sub parse_csv_line {
my ($line, $expected_fields) = @_;
return unless $line;
$line =~ s/^\s+|\s+$//g;
my @values = map { s/^\s+|\s+$//gr } split(/,/, $line);
return unless !$expected_fields || @values >= $expected_fields;
return @values;
}
1;

View File

@ -1,27 +1,27 @@
# pve-mod :: node_info file manifest
# Maps files in this directory to their installation destinations.
# Format: <source> <destination> [permission]
# source - path relative to this files/ directory
# destination - path relative to the package root (no leading slash)
# permission - octal mode, optional (defaults to 644)
# Read by src/gen-rules.sh to generate the per-module debian install rules.
# PVE API2 facade
PveMod_SensorInfo.pm usr/share/perl5/PVE/API2/PVEMod_SensorInfo.pm
# PVEMod core modules
Config.pm usr/share/perl5/PVE/PVEMod/Config.pm
Utils.pm usr/share/perl5/PVE/PVEMod/Utils.pm
Store.pm usr/share/perl5/PVE/PVEMod/Store.pm
ProcessManager.pm usr/share/perl5/PVE/PVEMod/ProcessManager.pm
# Collector plugins
Collector/Intel.pm usr/share/perl5/PVE/PVEMod/Collector/Intel.pm
Collector/Nvidia.pm usr/share/perl5/PVE/PVEMod/Collector/Nvidia.pm
Collector/Amd.pm usr/share/perl5/PVE/PVEMod/Collector/Amd.pm
Collector/LmSensors.pm usr/share/perl5/PVE/PVEMod/Collector/LmSensors.pm
Collector/Ups.pm usr/share/perl5/PVE/PVEMod/Collector/Ups.pm
Collector/systemInformation.pm usr/share/perl5/PVE/PVEMod/Collector/SystemInformation.pm
# JS module (rename to match loader reference)
PveMod_pvemanagerlib.js usr/share/pve-manager/js/PveMod_PveNodeStatusView.js
# pve-mod :: node_info file manifest
# Maps files in this directory to their installation destinations.
# Format: <source> <destination> [permission]
# source - path relative to this files/ directory
# destination - path relative to the package root (no leading slash)
# permission - octal mode, optional (defaults to 644)
# Read by src/gen-rules.sh to generate the per-module debian install rules.
# PVE API2 facade
PveMod_SensorInfo.pm usr/share/perl5/PVE/API2/PVEMod_SensorInfo.pm
# PVEMod core modules
Config.pm usr/share/perl5/PVE/PVEMod/Config.pm
Utils.pm usr/share/perl5/PVE/PVEMod/Utils.pm
Store.pm usr/share/perl5/PVE/PVEMod/Store.pm
ProcessManager.pm usr/share/perl5/PVE/PVEMod/ProcessManager.pm
# Collector plugins
Collector/Intel.pm usr/share/perl5/PVE/PVEMod/Collector/Intel.pm
Collector/Nvidia.pm usr/share/perl5/PVE/PVEMod/Collector/Nvidia.pm
Collector/Amd.pm usr/share/perl5/PVE/PVEMod/Collector/Amd.pm
Collector/LmSensors.pm usr/share/perl5/PVE/PVEMod/Collector/LmSensors.pm
Collector/Ups.pm usr/share/perl5/PVE/PVEMod/Collector/Ups.pm
Collector/systemInformation.pm usr/share/perl5/PVE/PVEMod/Collector/SystemInformation.pm
# JS module (rename to match loader reference)
PveMod_pvemanagerlib.js usr/share/pve-manager/js/PveMod_PveNodeStatusView.js

View File

@ -1,45 +1,45 @@
# pve-mod :: node_info mod configuration
# Settings for the node-info / sensor-monitoring mod.
# Managed by pve-mod-configure. Re-run to update.
[gpu]
intel_enabled=0
nvidia_enabled=0
amd_enabled=0
gpu_history=0
[lm_sensors]
enabled=0
enable_cpu=0
cpu_temp_target=Core
enable_ram_temp=0
enable_hdd_temp=0
enable_nvme_temp=0
enable_fan_speed=0
display_zero_speed_fans=0
temp_unit=C
[ups]
enabled=0
device_name=ups@localhost
[system_info]
enabled=0
type=1
# Debug mode: when a collector's mode is 1, the real tool is not required.
# Data is read from the file path instead. Useful for development/testing.
[debug]
lm_sensors_mode=0
lm_sensors_output_file=/tmp/sensors-output.json
intel_mode=0
intel_devices_file=/tmp/intel-gpu-devices.json
nvidia_mode=0
nvidia_devices_file=/tmp/nvidia-smi-devices.csv
nvidia_output_file=/tmp/nvidia-smi-output.csv
amd_mode=0
amd_devices_file=/tmp/amd-gpu-devices.json
ups_mode=0
ups_output_file=/tmp/ups-output.json
log_enabled=0
log_file=/tmp/pve-mod-debug.log
# pve-mod :: node_info mod configuration
# Settings for the node-info / sensor-monitoring mod.
# Managed by pve-mod-configure. Re-run to update.
[gpu]
intel_enabled=0
nvidia_enabled=0
amd_enabled=0
gpu_history=0
[lm_sensors]
enabled=0
enable_cpu=0
cpu_temp_target=Core
enable_ram_temp=0
enable_hdd_temp=0
enable_nvme_temp=0
enable_fan_speed=0
display_zero_speed_fans=0
temp_unit=C
[ups]
enabled=0
device_name=ups@localhost
[system_info]
enabled=0
type=1
# Debug mode: when a collector's mode is 1, the real tool is not required.
# Data is read from the file path instead. Useful for development/testing.
[debug]
lm_sensors_mode=0
lm_sensors_output_file=/tmp/sensors-output.json
intel_mode=0
intel_devices_file=/tmp/intel-gpu-devices.json
nvidia_mode=0
nvidia_devices_file=/tmp/nvidia-smi-devices.csv
nvidia_output_file=/tmp/nvidia-smi-output.csv
amd_mode=0
amd_devices_file=/tmp/amd-gpu-devices.json
ups_mode=0
ups_output_file=/tmp/ups-output.json
log_enabled=0
log_file=/tmp/pve-mod-debug.log

View File

@ -1,9 +1,9 @@
# pve-mod :: node_info patch manifest
# Format: <patch-file> [section.key=value]
# Patches are applied top-to-bottom. An optional condition (read from this mod's
# conf.d file, /etc/pve-mod/conf.d/node_info.conf) gates a patch; it is applied
# only when the key equals the given value.
01-nodes-pm-sensors.patch
02-nodes-pm-GPU-RRD-history.patch gpu.gpu_history=1
03-pvemanager-js-sensors.patch
# pve-mod :: node_info patch manifest
# Format: <patch-file> [section.key=value]
# Patches are applied top-to-bottom. An optional condition (read from this mod's
# conf.d file, /etc/pve-mod/conf.d/node_info.conf) gates a patch; it is applied
# only when the key equals the given value.
01-nodes-pm-sensors.patch
02-nodes-pm-GPU-RRD-history.patch gpu.gpu_history=1
03-pvemanager-js-sensors.patch

View File

@ -1,3 +1,3 @@
## Draft code
This version of PVEMod is as draft version and may or may not be fully functional.
## Draft code
This version of PVEMod is as draft version and may or may not be fully functional.
The installer is currently not working and is work in progress.

View File

@ -1,19 +1,19 @@
# pve-mod main configuration file
# Run 'pve-mod-configure' to set values interactively.
#
# This file only declares which mods are enabled. Each mod keeps its own
# settings in /etc/pve-mod/conf.d/<mod>.conf
#
# When a mod flag below is 1, its patches are (re)applied on install and,
# if [pve_trigger] enabled=1, after every pve-manager upgrade.
[modules]
node_info=0
nag_screen=0
# Re-apply patches automatically after a pve-manager upgrade (dpkg trigger).
[pve_trigger]
enabled=0
[service]
mode=embedded
# pve-mod main configuration file
# Run 'pve-mod-configure' to set values interactively.
#
# This file only declares which mods are enabled. Each mod keeps its own
# settings in /etc/pve-mod/conf.d/<mod>.conf
#
# When a mod flag below is 1, its patches are (re)applied on install and,
# if [pve_trigger] enabled=1, after every pve-manager upgrade.
[modules]
node_info=0
nag_screen=0
# Re-apply patches automatically after a pve-manager upgrade (dpkg trigger).
[pve_trigger]
enabled=0
[service]
mode=embedded

View File

@ -1 +1 @@
trigger workflow test 1, 2, 3, 4
trigger workflow test 1, 2, 3, 4, 5