PVE Temp mod version 2.0 (#152)

* New implement of PVENode info
This commit is contained in:
Meliox 2026-06-07 11:23:02 +02:00 committed by GitHub
parent 6246669adc
commit bfb078a0ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 4836 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,91 @@
package PVE::PVEMod::Config;
use strict;
use warnings;
use Exporter 'import';
our @EXPORT_OK = qw(
%config
$DEBUG_ENABLED $VERSION $process_type
$pve_mod_working_dir $stats_dir $state_file
$sensors_state_file $ups_state_file
$pve_mod_worker_lock $startup_lock
$RRD_SOCKET $RRD_BASE
);
# ============================================================================
# Debug / Version
# ============================================================================
our $DEBUG_ENABLED = 1;
our $VERSION = '1.0';
# Runtime process-type tag — set to 'worker' or 'collector' after fork.
# Each forked child gets its own copy of this variable.
our $process_type = 'main'; # 'main', 'worker', or 'collector'
# ============================================================================
# Configuration
# ============================================================================
our %config = (
gpu => {
intel_enabled => 1,
amd_enabled => 0,
nvidia_enabled => 0,
},
debug => {
log_enabled => 0,
log_file => '/tmp/pve-mod-debug.log',
nvidia_mode => 1,
nvidia_devices_file => '/tmp/nvidia-smi-devices.csv',
nvidia_output_file => '/tmp/nvidia-smi-output.csv',
intel_mode => 0,
intel_devices_file => '/tmp/intel-gpu-devices.json',
amd_mode => 0,
amd_devices_file => '/tmp/amd-gpu-devices.json',
ups_mode => 0,
ups_output_file => '/tmp/ups-output.json',
lm_sensors_mode => 0,
lm_sensors_output_file => '/tmp/sensors-output.json',
},
intervals => {
data_pull => 1, # seconds between data pulls
collector_timeout => 10, # stop collectors after N seconds of inactivity
},
lm_sensors => {
enabled => 1,
},
ups => {
enabled => 1,
device_name => 'ups@192.168.3.2',
},
system_info => {
enabled => 1,
type => 1, # 1 = System (dmidecode -t 1), 2 = Baseboard/Motherboard (dmidecode -t 2)
},
paths => {
working_dir => '/run/pveproxy/pve-mod',
},
);
# ============================================================================
# Derived paths
# ============================================================================
our $pve_mod_working_dir = $config{paths}{working_dir};
our $stats_dir = $pve_mod_working_dir;
our $state_file = "$pve_mod_working_dir/stats.json";
our $sensors_state_file = "$pve_mod_working_dir/sensors.json";
our $ups_state_file = "$pve_mod_working_dir/ups.json";
our $pve_mod_worker_lock = "$pve_mod_working_dir/pve_mod_worker.lock";
our $startup_lock = "$pve_mod_working_dir/startup.lock";
# ============================================================================
# RRD paths
# ============================================================================
our $RRD_SOCKET = '/var/run/rrdcached.sock';
our $RRD_BASE = '/var/lib/rrdcached/db/pve-mod-gpu';
1;

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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