Restructure repo (#251)
* refactor: restructure src/ into modules/, scripts/, and build/ - src/nag_screen/ and src/node_info/ -> src/modules/ (mods grouped explicitly) - src/Scripts/ -> src/scripts/ (lowercase, consistent) - src/gen-rules.sh -> build/ (build tool separated from source) Update all references: debian/rules, .github/workflows/*, build/gen-rules.sh, src/scripts/test/test-syntax.sh (SRC_DIR now points to src/modules/), src/scripts/test/test-patches.sh, and files.list comments. * refactor: move CI test scripts from src/scripts/test/ to build/test/ Test scripts are not installed — they are CI/dev tooling and belong alongside build/gen-rules.sh. Update workflow references and fix the SRC_DIR path depth in build/test/test-syntax.sh (../ -> ../..). * d --------- Co-authored-by: Meliox <na>
This commit is contained in:
1 parent
3db6a010df
commit
6585849459
44 files changed
+342
-30
No files matched your search
@@ -0,0 +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 build/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.
|
||||
@@ -0,0 +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.
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# nag_screen.configure.sh - Configure module for pve-mod nag_screen
|
||||
#
|
||||
# Sourced by pve-mod-configure. Requires CONFD_DIR to be defined in the
|
||||
# calling script before this file is sourced.
|
||||
#
|
||||
# Provides the standard four-function module API:
|
||||
# nag_screen_defaults — no-op (nag_screen has no tunable variables)
|
||||
# nag_screen_load_conf — no-op (no tunable settings to load)
|
||||
# nag_screen_configure — no-op (no interactive configuration needed)
|
||||
# nag_screen_write_conf — write placeholder /etc/pve-mod/conf.d/nag_screen.conf
|
||||
|
||||
NAG_SCREEN_CONF="${CONFD_DIR}/nag_screen.conf"
|
||||
|
||||
nag_screen_defaults() { :; }
|
||||
nag_screen_load_conf() { :; }
|
||||
nag_screen_configure() { :; }
|
||||
|
||||
nag_screen_write_conf() {
|
||||
# Only write the placeholder if it does not already exist; it has no
|
||||
# machine-managed values so there is nothing to update on re-runs.
|
||||
if [[ ! -f "$NAG_SCREEN_CONF" ]]; then
|
||||
cat > "$NAG_SCREEN_CONF" <<EOF
|
||||
# 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.
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
--- a/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js
|
||||
+++ b/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js
|
||||
@@ -599,37 +599,8 @@
|
||||
Ext.Msg.alert(gettext('Error'), res.htmlStatus || res.result.message),
|
||||
|
||||
checked_command: function (orig_cmd) {
|
||||
- Proxmox.Utils.API2Request({
|
||||
- url: '/nodes/localhost/subscription',
|
||||
- method: 'GET',
|
||||
- failure: function (response, opts) {
|
||||
- Ext.Msg.alert(gettext('Error'), response.htmlStatus);
|
||||
- },
|
||||
- success: function (response, opts) {
|
||||
- let res = response.result;
|
||||
- if (
|
||||
- res === null ||
|
||||
- res === undefined ||
|
||||
- !res ||
|
||||
- res.data.status.toLowerCase() !== 'active'
|
||||
- ) {
|
||||
- Ext.Msg.show({
|
||||
- title: gettext('No valid subscription'),
|
||||
- icon: Ext.Msg.WARNING,
|
||||
- message: Proxmox.Utils.getNoSubKeyHtml(res.data.url),
|
||||
- buttons: Ext.Msg.OK,
|
||||
- callback: function (btn) {
|
||||
- if (btn !== 'ok') {
|
||||
- return;
|
||||
- }
|
||||
- orig_cmd();
|
||||
- },
|
||||
- });
|
||||
- } else {
|
||||
- orig_cmd();
|
||||
- }
|
||||
- },
|
||||
- });
|
||||
+ // disable subscription nag screen
|
||||
+ orig_cmd();
|
||||
},
|
||||
|
||||
assemble_field_data: function (values, data) {
|
||||
@@ -0,0 +1,26 @@
|
||||
--- a/usr/share/pve-yew-mobile-gui/index.html.tpl
|
||||
+++ b/usr/share/pve-yew-mobile-gui/index.html.tpl
|
||||
@@ -59,3 +59,23 @@
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+<!-- MANAGED BLOCK FOR MOBILE NAG -->
|
||||
+<script>
|
||||
+ function removeSubscriptionElements() {
|
||||
+ document.querySelectorAll('dialog.pwt-outer-dialog').forEach(dialog => {
|
||||
+ if ((dialog.textContent || '').toLowerCase().includes('subscription')) {
|
||||
+ dialog.remove();
|
||||
+ }
|
||||
+ });
|
||||
+ document.querySelectorAll('.pwt-card.pwt-p-2.pwt-d-flex.pwt-interactive.pwt-justify-content-center').forEach(card => {
|
||||
+ if (!card.querySelector('button') && (card.textContent || '').toLowerCase().includes('subscription')) {
|
||||
+ card.remove();
|
||||
+ }
|
||||
+ });
|
||||
+ }
|
||||
+ const observer = new MutationObserver(removeSubscriptionElements);
|
||||
+ observer.observe(document.body, { childList: true, subtree: true });
|
||||
+ removeSubscriptionElements();
|
||||
+ setInterval(removeSubscriptionElements, 300);
|
||||
+ setTimeout(() => { observer.disconnect(); }, 10000);
|
||||
+</script>
|
||||
@@ -0,0 +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
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# post-apply hook for the nag_screen mod.
|
||||
# Runs after nag_screen patches are applied. Provided env: MOD_CONF, STASH_DIR,
|
||||
# CONFD_DIR. Requests a pveproxy restart so the patched UI is served.
|
||||
# Exit codes: 0 = no change, 100 = changed (restart pveproxy), other = error.
|
||||
|
||||
set -u
|
||||
|
||||
exit 100
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# post-revert hook for the nag_screen mod.
|
||||
# Runs before nag_screen patches are reverted. Provided env: MOD_CONF,
|
||||
# STASH_DIR, CONFD_DIR. Requests a pveproxy restart so the original UI is served.
|
||||
# Exit codes: 0 = no change, 100 = changed (restart pveproxy), other = error.
|
||||
|
||||
set -u
|
||||
|
||||
exit 100
|
||||
@@ -0,0 +1,3 @@
|
||||
# nag_screen
|
||||
|
||||
Removes the "No valid subscription" popup from the Proxmox VE web UI. Targets both the desktop interface and the mobile GUI.
|
||||
@@ -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;
|
||||
@@ -0,0 +1,226 @@
|
||||
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 = ();
|
||||
my $fh;
|
||||
|
||||
# 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 ($config{debug}{intel_mode} && -f $config{debug}{intel_devices_file}) {
|
||||
my $file = $config{debug}{intel_devices_file};
|
||||
debug(__LINE__, "Debug mode: reading Intel GPU devices from $file");
|
||||
unless (open $fh, '<', $file) {
|
||||
debug(__LINE__, "Failed to open debug file $file: $!");
|
||||
return @devices;
|
||||
}
|
||||
} else {
|
||||
debug(__LINE__, "Getting Intel GPU devices");
|
||||
unless (open $fh, '-|', 'intel_gpu_top -L') {
|
||||
debug(__LINE__, "Failed to run intel_gpu_top -L: $!");
|
||||
return @devices;
|
||||
}
|
||||
}
|
||||
|
||||
while (<$fh>) {
|
||||
chomp;
|
||||
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;
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
my $node_name = "node0";
|
||||
|
||||
if ($config{debug}{intel_mode} && -f $config{debug}{intel_output_file}) {
|
||||
debug(__LINE__, "Debug mode: reading Intel GPU stats from $config{debug}{intel_output_file}");
|
||||
while (!$shutdown) {
|
||||
if (open my $fh, '<', $config{debug}{intel_output_file}) {
|
||||
while (my $line = <$fh>) {
|
||||
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;
|
||||
} else {
|
||||
debug(__LINE__, "Failed to open debug file $config{debug}{intel_output_file}: $!");
|
||||
}
|
||||
sleep $config{intervals}{data_pull} unless $shutdown;
|
||||
}
|
||||
} else {
|
||||
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");
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,453 @@
|
||||
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({
|
||||
cpu => $config{lm_sensors}{enable_cpu} ? \1 : \0,
|
||||
nvme => $config{lm_sensors}{enable_nvme_temp} ? \1 : \0,
|
||||
fans => $config{lm_sensors}{enable_fan_speed} ? \1 : \0,
|
||||
hdd => $config{lm_sensors}{enable_hdd_temp} ? \1 : \0,
|
||||
other => $config{lm_sensors}{enable_other_temp} ? \1 : \0,
|
||||
temp_unit => $config{lm_sensors}{temp_unit},
|
||||
data => { '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-/ || /^cpu_thermal-virtual-/ } 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ----- Raspberry Pi cpu_thermal -----
|
||||
elsif ($entry =~ /^cpu_thermal-virtual-/) {
|
||||
$pkg = 0;
|
||||
$cpu_model = _rpi_cpu_model();
|
||||
debug(__LINE__, "Found RPi CPU: $entry -> Model: $cpu_model");
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Raspberry Pi model lookup helper
|
||||
# ============================================================================
|
||||
|
||||
sub _rpi_cpu_model {
|
||||
for my $field ('Model', 'Hardware') {
|
||||
if (open my $fh, '<', '/proc/cpuinfo') {
|
||||
while (my $line = <$fh>) {
|
||||
chomp $line;
|
||||
if ($line =~ /^$field\s*:\s*(.+)$/) {
|
||||
close $fh;
|
||||
(my $model = $1) =~ s/^\s+|\s+$//g;
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
close $fh;
|
||||
}
|
||||
}
|
||||
return "Raspberry Pi";
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,125 @@
|
||||
package PVE::PVEMod::Collector::Ups;
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
use Exporter 'import';
|
||||
|
||||
use JSON;
|
||||
|
||||
use PVE::PVEMod::Config qw(%config $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 $config{intervals}{data_pull} unless $shutdown;
|
||||
}
|
||||
|
||||
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;
|
||||
if ($config{debug}{ups_mode} && -f $config{debug}{ups_output_file}) {
|
||||
debug(__LINE__, "Debug mode: reading UPS data from $config{debug}{ups_output_file}");
|
||||
open my $fh, '<', $config{debug}{ups_output_file}
|
||||
or do { debug(__LINE__, "Failed to open debug file $config{debug}{ups_output_file}: $!"); return encode_json({ error => "Failed to open debug file" }); };
|
||||
local $/;
|
||||
$output = <$fh>;
|
||||
close $fh;
|
||||
} else {
|
||||
$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;
|
||||
@@ -0,0 +1,102 @@
|
||||
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 $cache_file = "/var/lib/pve-mod/dmidecode-type${type}.txt";
|
||||
my $output;
|
||||
if (open(my $fh, '<', $cache_file)) {
|
||||
local $/;
|
||||
$output = <$fh>;
|
||||
close($fh);
|
||||
}
|
||||
|
||||
unless (defined $output && length($output) > 0) {
|
||||
debug(__LINE__, "No cached DMI data at $cache_file - re-run pve-mod-configure as root to refresh");
|
||||
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;
|
||||
@@ -0,0 +1,161 @@
|
||||
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';
|
||||
|
||||
# ============================================================================
|
||||
# Config paths
|
||||
# ============================================================================
|
||||
|
||||
my $CONF_FILE = '/etc/pve-mod/pve-mod.conf';
|
||||
my $CONFD_DIR = '/etc/pve-mod/conf.d';
|
||||
|
||||
# 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.txt',
|
||||
intel_output_file => '/tmp/intel-gpu-top-output.txt',
|
||||
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_other_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) = @_;
|
||||
return unless defined $path && -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($CONF_FILE);
|
||||
_load_ini_file($_) for glob("$CONFD_DIR/*.conf");
|
||||
|
||||
1;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,235 @@
|
||||
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_graphics_info {
|
||||
debug(__LINE__, "get_graphics_info called");
|
||||
if (!($config{gpu}{intel_enabled} || !$config{gpu}{nvidia_enabled} || !$config{gpu}{amd_enabled})) {
|
||||
debug(__LINE__, "GPU information collection is disabled");
|
||||
return { disabled => \1 };
|
||||
}
|
||||
|
||||
# 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");
|
||||
if (!$config{lm_sensors}{enabled}) {
|
||||
debug(__LINE__, "LM Sensors collection is disabled");
|
||||
return { disabled => \1 };
|
||||
}
|
||||
|
||||
# Start PVE Mod
|
||||
pve_mod_starter();
|
||||
|
||||
my $data = _read_state_file_cached($sensors_state_file, $sensors_cache, 0, {});
|
||||
|
||||
# Notify pve_mod_worker of activity
|
||||
notify_pve_mod_worker();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
sub get_ups_info {
|
||||
debug(__LINE__, "get_ups_info called");
|
||||
if (!$config{ups}{enabled}) {
|
||||
debug(__LINE__, "UPS collection is disabled");
|
||||
return { disabled => \1 };
|
||||
}
|
||||
|
||||
# Start PVE Mod
|
||||
pve_mod_starter();
|
||||
|
||||
my $data = _read_state_file_cached($ups_state_file, $ups_cache, 0, {});
|
||||
|
||||
# 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 (!$config{system_info}{enabled}) {
|
||||
debug(__LINE__, "System information collection is disabled");
|
||||
return { disabled => \1 };
|
||||
}
|
||||
|
||||
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
@@ -0,0 +1,170 @@
|
||||
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(%config $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 {
|
||||
return unless $config{gpu}{gpu_history};
|
||||
|
||||
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 {
|
||||
return unless $config{gpu}{gpu_history};
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,282 @@
|
||||
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;
|
||||
|
||||
# Untaint filepath for taint-mode environments (pveproxy runs with -T)
|
||||
($filepath) = ($filepath =~ /^([a-zA-Z0-9_\/\-\.]+)$/)
|
||||
or do { debug(__LINE__, "Unsafe filepath rejected: $filepath"); return 0; };
|
||||
|
||||
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) = @_;
|
||||
|
||||
# Untaint filepath
|
||||
($filepath) = ($filepath =~ /^([a-zA-Z0-9_\/\-\.]+)$/)
|
||||
or return;
|
||||
|
||||
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;
|
||||
@@ -0,0 +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 build/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
|
||||
@@ -0,0 +1,46 @@
|
||||
# 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
|
||||
intel_output_file=/tmp/intel-gpu-output.txt
|
||||
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
|
||||
@@ -0,0 +1,510 @@
|
||||
#!/usr/bin/env bash
|
||||
# node_info.configure.sh - Configure module for pve-mod node_info
|
||||
#
|
||||
# Sourced by pve-mod-configure. Requires CONFD_DIR and the helper functions
|
||||
# (info, warn, err, ask, msgb) to be defined in the calling script before
|
||||
# this file is sourced.
|
||||
#
|
||||
# Provides the standard four-function module API:
|
||||
# node_info_defaults — set all variables to safe defaults
|
||||
# node_info_load_conf — parse /etc/pve-mod/conf.d/node_info.conf
|
||||
# node_info_configure — interactive hardware-detection wizard
|
||||
# node_info_write_conf — write /etc/pve-mod/conf.d/node_info.conf
|
||||
|
||||
NODE_INFO_CONF="${CONFD_DIR}/node_info.conf"
|
||||
KNOWN_CPU_SENSORS=("coretemp-isa-" "k10temp-pci-" "cpu_thermal-virtual-")
|
||||
|
||||
# --- utilities ---------------------------------------------------------------
|
||||
|
||||
sanitize_sensors_output() {
|
||||
local input="$1"
|
||||
echo "$input" | perl -0777 -pe '
|
||||
s/ERROR:.+\s(\w+):\s(.+)/"$1": 0.000,/g;
|
||||
s/ERROR:.+\s(\w+)!/"$1": 0.000,/g;
|
||||
s/,\s*(\})/$1/g;
|
||||
s/\bNaN\b/null/g;
|
||||
s/"SODIMM"\s*:\s*\{\s*"temp(\d+)_input"/"SODIMM $1": {\n "temp$1_input"/g;
|
||||
s/"([^"]*Fan[^"]*)"\s*:\s*\{\s*"fan(\d+)_input"/"$1 $2": {\n "fan$2_input"/g;
|
||||
' | python3 -m json.tool 2>/dev/null || echo "$input"
|
||||
}
|
||||
|
||||
_check_or_install_tool() {
|
||||
local cmd="$1" pkg="$2" description="$3"
|
||||
if command -v "$cmd" &>/dev/null; then
|
||||
info "$description is installed."
|
||||
return 0
|
||||
fi
|
||||
local choice
|
||||
choice=$(ask "$description is not installed. Install it now? (y/N)")
|
||||
case "$choice" in
|
||||
[yY])
|
||||
apt-get update -qq
|
||||
apt-get install -y "$pkg"
|
||||
command -v "$cmd" &>/dev/null && { info "$description installed."; return 0; } || \
|
||||
{ warn "$description installation failed. Section will be skipped."; return 1; }
|
||||
;;
|
||||
*)
|
||||
info "Skipping $description."
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
_check_nvidia_tool() {
|
||||
if command -v nvidia-smi &>/dev/null; then
|
||||
info "nvidia-smi is installed."
|
||||
return 0
|
||||
fi
|
||||
warn "nvidia-smi not found. NVIDIA monitoring requires NVIDIA drivers (not installable via apt)."
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- standard API ------------------------------------------------------------
|
||||
|
||||
node_info_defaults() {
|
||||
LM_SENSORS_ENABLED=0
|
||||
ENABLE_CPU=0; CPU_TEMP_TARGET="Core"
|
||||
ENABLE_RAM_TEMP=0; ENABLE_HDD_TEMP=0; ENABLE_NVME_TEMP=0; ENABLE_OTHER_TEMP=0
|
||||
ENABLE_FAN_SPEED=0; DISPLAY_ZERO_SPEED_FANS=0; TEMP_UNIT="C"
|
||||
ENABLE_INTEL_GPU_INFO=0; ENABLE_NVIDIA_GPU_INFO=0; ENABLE_AMD_GPU_INFO=0
|
||||
ENABLE_GPU_HISTORY=0
|
||||
ENABLE_UPS=0; UPS_DEVICE_NAME="ups@localhost"
|
||||
ENABLE_SYSTEM_INFO=0; SYSTEM_INFO_TYPE=1
|
||||
DEBUG_LM_SENSORS=0; DEBUG_LM_SENSORS_FILE="/tmp/sensors-output.json"
|
||||
DEBUG_INTEL=0; DEBUG_INTEL_FILE="/tmp/intel-gpu-devices.txt"
|
||||
DEBUG_INTEL_OUTPUT_FILE="/tmp/intel-gpu-top-output.txt"
|
||||
DEBUG_NVIDIA=0; DEBUG_NVIDIA_OUTPUT_FILE="/tmp/nvidia-smi-output.csv"
|
||||
DEBUG_NVIDIA_DEVICES_FILE="/tmp/nvidia-smi-devices.csv"
|
||||
DEBUG_AMD=0; DEBUG_AMD_FILE="/tmp/amd-gpu-devices.json"
|
||||
DEBUG_UPS=0; DEBUG_UPS_FILE="/tmp/ups-output.json"
|
||||
DEBUG_LOG=0; DEBUG_LOG_FILE="/tmp/pve-mod-debug.log"
|
||||
}
|
||||
|
||||
node_info_load_conf() {
|
||||
[[ -f "$NODE_INFO_CONF" ]] || return 0
|
||||
local line key val section=""
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
'#'*|'') continue ;;
|
||||
'['*']') section="${line#[}"; section="${section%]}"; continue ;;
|
||||
esac
|
||||
[[ "$line" == *=* ]] || continue
|
||||
key="${line%%=*}"; val="${line#*=}"
|
||||
case "${section}.${key}" in
|
||||
gpu.intel_enabled) ENABLE_INTEL_GPU_INFO="$val" ;;
|
||||
gpu.nvidia_enabled) ENABLE_NVIDIA_GPU_INFO="$val" ;;
|
||||
gpu.amd_enabled) ENABLE_AMD_GPU_INFO="$val" ;;
|
||||
gpu.gpu_history) ENABLE_GPU_HISTORY="$val" ;;
|
||||
lm_sensors.enabled) LM_SENSORS_ENABLED="$val" ;;
|
||||
lm_sensors.enable_cpu) ENABLE_CPU="$val" ;;
|
||||
lm_sensors.cpu_temp_target) CPU_TEMP_TARGET="$val" ;;
|
||||
lm_sensors.enable_ram_temp) ENABLE_RAM_TEMP="$val" ;;
|
||||
lm_sensors.enable_hdd_temp) ENABLE_HDD_TEMP="$val" ;;
|
||||
lm_sensors.enable_nvme_temp) ENABLE_NVME_TEMP="$val" ;;
|
||||
lm_sensors.enable_other_temp) ENABLE_OTHER_TEMP="$val" ;;
|
||||
lm_sensors.enable_fan_speed) ENABLE_FAN_SPEED="$val" ;;
|
||||
lm_sensors.display_zero_speed_fans) DISPLAY_ZERO_SPEED_FANS="$val" ;;
|
||||
lm_sensors.temp_unit) TEMP_UNIT="$val" ;;
|
||||
ups.enabled) ENABLE_UPS="$val" ;;
|
||||
ups.device_name) UPS_DEVICE_NAME="$val" ;;
|
||||
system_info.enabled) ENABLE_SYSTEM_INFO="$val" ;;
|
||||
system_info.type) SYSTEM_INFO_TYPE="$val" ;;
|
||||
debug.lm_sensors_mode) DEBUG_LM_SENSORS="$val" ;;
|
||||
debug.lm_sensors_output_file) DEBUG_LM_SENSORS_FILE="$val" ;;
|
||||
debug.intel_mode) DEBUG_INTEL="$val" ;;
|
||||
debug.intel_devices_file) DEBUG_INTEL_FILE="$val" ;;
|
||||
debug.intel_output_file) DEBUG_INTEL_OUTPUT_FILE="$val" ;;
|
||||
debug.nvidia_mode) DEBUG_NVIDIA="$val" ;;
|
||||
debug.nvidia_devices_file) DEBUG_NVIDIA_DEVICES_FILE="$val" ;;
|
||||
debug.nvidia_output_file) DEBUG_NVIDIA_OUTPUT_FILE="$val" ;;
|
||||
debug.amd_mode) DEBUG_AMD="$val" ;;
|
||||
debug.amd_devices_file) DEBUG_AMD_FILE="$val" ;;
|
||||
debug.ups_mode) DEBUG_UPS="$val" ;;
|
||||
debug.ups_output_file) DEBUG_UPS_FILE="$val" ;;
|
||||
debug.log_enabled) DEBUG_LOG="$val" ;;
|
||||
debug.log_file) DEBUG_LOG_FILE="$val" ;;
|
||||
esac
|
||||
done < "$NODE_INFO_CONF"
|
||||
}
|
||||
|
||||
#region node-info wizard
|
||||
node_info_configure() {
|
||||
# Initialize all variables to off
|
||||
LM_SENSORS_ENABLED=0
|
||||
ENABLE_CPU=0; CPU_TEMP_TARGET="Core"
|
||||
ENABLE_RAM_TEMP=0; ENABLE_HDD_TEMP=0; ENABLE_NVME_TEMP=0; ENABLE_OTHER_TEMP=0
|
||||
ENABLE_FAN_SPEED=0; DISPLAY_ZERO_SPEED_FANS=0; TEMP_UNIT="C"
|
||||
ENABLE_INTEL_GPU_INFO=0; ENABLE_NVIDIA_GPU_INFO=0; ENABLE_AMD_GPU_INFO=0
|
||||
ENABLE_GPU_HISTORY=0
|
||||
ENABLE_UPS=0; UPS_DEVICE_NAME="ups@localhost"
|
||||
ENABLE_SYSTEM_INFO=0; SYSTEM_INFO_TYPE=1
|
||||
|
||||
local lm_sensors_ok=false
|
||||
local sensors_detected=false
|
||||
|
||||
if [[ "$DEBUG_LM_SENSORS" -eq 1 && -f "$DEBUG_LM_SENSORS_FILE" ]]; then
|
||||
info "[debug] Using sensor data from $DEBUG_LM_SENSORS_FILE"
|
||||
lm_sensors_ok=true; LM_SENSORS_ENABLED=1
|
||||
else
|
||||
_check_or_install_tool sensors lm-sensors "lm-sensors" && lm_sensors_ok=true && LM_SENSORS_ENABLED=1
|
||||
fi
|
||||
|
||||
if [[ "$lm_sensors_ok" == true ]]; then
|
||||
local sensorsOutput
|
||||
if [[ "$DEBUG_LM_SENSORS" -eq 1 ]]; then
|
||||
sensorsOutput=$(cat "$DEBUG_LM_SENSORS_FILE")
|
||||
else
|
||||
sensorsOutput=$(sensors -j 2>/dev/null) || true
|
||||
fi
|
||||
|
||||
local trimmedSensorsOutput
|
||||
trimmedSensorsOutput=$(echo "$sensorsOutput" | tr -d '[:space:]')
|
||||
if [[ -z "$trimmedSensorsOutput" || "$trimmedSensorsOutput" == "{}" ]]; then
|
||||
warn "lm-sensors is installed but reported no sensors."
|
||||
warn "No kernel sensor drivers appear to be loaded."
|
||||
warn "Run 'sensors-detect' and load the suggested modules, then re-run this configurator."
|
||||
warn "lm-sensors output is the foundation for this mod. Mod cannot be enabled without it."
|
||||
exit 0
|
||||
lm_sensors_ok=false
|
||||
LM_SENSORS_ENABLED=0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$lm_sensors_ok" == true ]]; then
|
||||
local sanitisedSensorsOutput
|
||||
sanitisedSensorsOutput=$(sanitize_sensors_output "$sensorsOutput")
|
||||
|
||||
#region CPU
|
||||
msgb "\n=== Detecting CPU temperature sensors ==="
|
||||
local cpuList="" cpuCount=0
|
||||
for pattern in "${KNOWN_CPU_SENSORS[@]}"; do
|
||||
local found_cpus
|
||||
found_cpus=$(echo "$sanitisedSensorsOutput" | grep -o "\"${pattern}[^\"]*\"" || true | sed 's/"//g')
|
||||
if [[ -n "$found_cpus" ]]; then
|
||||
while read -r sensor; do
|
||||
[[ -z "$sensor" ]] && continue
|
||||
cpuCount=$((cpuCount + 1))
|
||||
cpuList="${cpuList:+$cpuList,}$sensor"
|
||||
ENABLE_CPU=1
|
||||
done <<< "$found_cpus"
|
||||
fi
|
||||
done
|
||||
if [[ "$ENABLE_CPU" -eq 1 ]]; then
|
||||
info "Detected CPU sensors ($cpuCount): $cpuList"
|
||||
sensors_detected=true
|
||||
local bRpiOnly=false
|
||||
if [[ "$cpuList" == *"cpu_thermal-virtual-"* ]] && \
|
||||
[[ "$cpuList" != *"coretemp-isa-"* ]] && \
|
||||
[[ "$cpuList" != *"k10temp-pci-"* ]]; then
|
||||
bRpiOnly=true
|
||||
fi
|
||||
if [[ "$bRpiOnly" == true ]]; then
|
||||
CPU_TEMP_TARGET="Core"
|
||||
else
|
||||
while true; do
|
||||
local choice
|
||||
choice=$(ask "Display temperatures for all cores [C] or average per CPU [a]? (C/a)")
|
||||
case "$choice" in
|
||||
[cC]|"") CPU_TEMP_TARGET="Core"; info "Showing per-core temperatures."; break ;;
|
||||
[aA]) CPU_TEMP_TARGET="Package"; info "Showing average per-CPU temperature."; break ;;
|
||||
*) warn "Invalid input, choose C or a." ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
else
|
||||
warn "No CPU temperature sensors found."
|
||||
fi
|
||||
#endregion CPU
|
||||
|
||||
#region RAM
|
||||
msgb "\n=== Detecting RAM temperature sensors ==="
|
||||
local ramCount
|
||||
ramCount=$(grep -c '"SODIMM[^"]*"' <<<"$sanitisedSensorsOutput" || true)
|
||||
if [[ "$ramCount" -gt 0 ]]; then
|
||||
info "Detected $ramCount RAM sensor(s)."
|
||||
ENABLE_RAM_TEMP=1; sensors_detected=true
|
||||
else
|
||||
warn "No RAM temperature sensors found."
|
||||
fi
|
||||
#endregion RAM
|
||||
|
||||
#region HDD/SSD
|
||||
msgb "\n=== Detecting HDD/SSD temperature sensors ==="
|
||||
local hddList
|
||||
hddList=$(echo "$sanitisedSensorsOutput" | grep -o '"drivetemp-scsi[^"]*"' | sed 's/"//g' | wc -l || true)
|
||||
if [[ "$hddList" -gt 0 ]]; then
|
||||
info "Detected $hddList HDD/SSD sensor(s)."
|
||||
ENABLE_HDD_TEMP=1; sensors_detected=true
|
||||
else
|
||||
warn "No HDD/SSD temperature sensors found. (Requires kernel module 'drivetemp'.)"
|
||||
fi
|
||||
#endregion HDD/SSD
|
||||
|
||||
#region NVMe
|
||||
msgb "\n=== Detecting NVMe temperature sensors ==="
|
||||
local nvmeCount
|
||||
nvmeCount=$(echo "$sanitisedSensorsOutput" | grep -c '"nvme[^"]*"' || true)
|
||||
if [[ "$nvmeCount" -gt 0 ]]; then
|
||||
info "Detected $nvmeCount NVMe sensor(s)."
|
||||
ENABLE_NVME_TEMP=1; sensors_detected=true
|
||||
else
|
||||
warn "No NVMe temperature sensors found."
|
||||
fi
|
||||
#endregion NVMe
|
||||
|
||||
#region Other thermals
|
||||
msgb "\n=== Detecting other thermal sensors ==="
|
||||
local otherTempCount
|
||||
otherTempCount=$(echo "$sanitisedSensorsOutput" \
|
||||
| grep -Ev '"(coretemp|k10temp-pci|cpu_thermal-virtual|nvme|drivetemp-scsi|SODIMM|spd5118)[^"]*"' \
|
||||
| grep -c '"temp[0-9]*_input"' || true)
|
||||
|
||||
if [[ "$otherTempCount" -gt 0 ]]; then
|
||||
info "Detected $otherTempCount other temperature reading(s)."
|
||||
ENABLE_OTHER_TEMP=1; sensors_detected=true
|
||||
else
|
||||
warn "No other temperature sensors found."
|
||||
fi
|
||||
#region Other thermals
|
||||
|
||||
#region Fans
|
||||
msgb "\n=== Detecting fan speed sensors ==="
|
||||
local fanCount
|
||||
fanCount=$(grep -c 'fan[0-9]\+_input' <<<"$sanitisedSensorsOutput" || true)
|
||||
if [[ "$fanCount" -gt 0 ]]; then
|
||||
info "Detected $fanCount fan speed reading(s)."
|
||||
ENABLE_FAN_SPEED=1; sensors_detected=true
|
||||
local choice
|
||||
choice=$(ask "Display fans reporting zero speed? (Y/n)")
|
||||
case "$choice" in
|
||||
[nN]) DISPLAY_ZERO_SPEED_FANS=0; info "Zero-speed fans will be hidden." ;;
|
||||
*) DISPLAY_ZERO_SPEED_FANS=1; info "Zero-speed fans will be shown." ;;
|
||||
esac
|
||||
else
|
||||
warn "No fan speed sensors found."
|
||||
fi
|
||||
#endregion Fans
|
||||
|
||||
#region Temperature unit
|
||||
if [[ "$sensors_detected" == true ]]; then
|
||||
msgb "\n=== Temperature unit ==="
|
||||
local unit
|
||||
unit=$(ask "Display temperatures in Celsius [C] or Fahrenheit [f]? (C/f)")
|
||||
case "$unit" in
|
||||
[fF]) TEMP_UNIT="F"; info "Using Fahrenheit." ;;
|
||||
*) TEMP_UNIT="C"; info "Using Celsius." ;;
|
||||
esac
|
||||
fi
|
||||
#endregion Temperature unit
|
||||
fi
|
||||
|
||||
#region Intel GPU
|
||||
msgb "\n=== Detecting Intel GPU ==="
|
||||
local intelCards=""
|
||||
if [[ "$DEBUG_INTEL" -eq 1 && -f "$DEBUG_INTEL_FILE" ]]; then
|
||||
info "[debug] Using Intel GPU data from $DEBUG_INTEL_FILE"
|
||||
intelCards=$(cat "$DEBUG_INTEL_FILE")
|
||||
if [[ -n "$intelCards" ]]; then
|
||||
info "Intel GPU(s) detected (debug):"
|
||||
echo "$intelCards" | while IFS= read -r line; do echo " $line"; done
|
||||
if [[ -f "$DEBUG_INTEL_OUTPUT_FILE" ]]; then
|
||||
info "[debug] Intel GPU stats output file found at $DEBUG_INTEL_OUTPUT_FILE"
|
||||
ENABLE_INTEL_GPU_INFO=1
|
||||
else
|
||||
warn "[debug] Intel GPU stats output file not found at $DEBUG_INTEL_OUTPUT_FILE. GPU stats graphs will be empty."
|
||||
fi
|
||||
else
|
||||
warn "No Intel GPUs in debug file."
|
||||
fi
|
||||
elif _check_or_install_tool intel_gpu_top intel-gpu-tools "Intel GPU tools (intel-gpu-tools)"; then
|
||||
intelCards=$(intel_gpu_top -L 2>/dev/null | grep -E '^card[0-9]+' || true)
|
||||
if [[ -n "$intelCards" ]]; then
|
||||
info "Intel GPU(s) detected:"
|
||||
echo "$intelCards" | while IFS= read -r line; do echo " $line"; done
|
||||
ENABLE_INTEL_GPU_INFO=1
|
||||
|
||||
# Write JSON devices file for debug/cache use by the Perl collector
|
||||
local json="["
|
||||
local first=1
|
||||
while IFS= read -r line; do
|
||||
# Parse: "card0 Intel Alderlake_n (Gen12) pci:vendor=8086,device=46D0,card=0"
|
||||
if [[ "$line" =~ ^(card[0-9]+)[[:space:]]+(.+[^[:space:]])[[:space:]]+(pci:[^[:space:]]+) ]]; then
|
||||
local card="${BASH_REMATCH[1]}"
|
||||
local name="${BASH_REMATCH[2]}"
|
||||
local path="${BASH_REMATCH[3]}"
|
||||
name="${name%"${name##*[![:space:]]}"}" # rtrim
|
||||
[[ "$first" -eq 0 ]] && json+=","
|
||||
json+="{\"card\":\"$card\",\"name\":\"$name\",\"path\":\"$path\",\"drm_path\":\"/dev/dri/$card\"}"
|
||||
first=0
|
||||
fi
|
||||
done <<< "$intelCards"
|
||||
json+="]"
|
||||
echo "$json" > "$DEBUG_INTEL_FILE"
|
||||
info "Intel GPU device list saved to $DEBUG_INTEL_FILE"
|
||||
else
|
||||
warn "No Intel GPUs detected by intel_gpu_top."
|
||||
fi
|
||||
fi
|
||||
#endregion Intel GPU
|
||||
|
||||
#region NVIDIA GPU
|
||||
msgb "\n=== Detecting NVIDIA GPU ==="
|
||||
if [[ "$DEBUG_NVIDIA" -eq 1 && -f "$DEBUG_NVIDIA_DEVICES_FILE" ]]; then
|
||||
info "[debug] Using NVIDIA GPU data from $DEBUG_NVIDIA_DEVICES_FILE"
|
||||
local nvidiaCards
|
||||
nvidiaCards=$(cat "$DEBUG_NVIDIA_DEVICES_FILE")
|
||||
if [[ -n "$nvidiaCards" ]]; then
|
||||
info "NVIDIA GPU(s) detected (debug):"
|
||||
echo "$nvidiaCards" | while IFS= read -r line; do echo " $line"; done
|
||||
if [[ -f "$DEBUG_NVIDIA_OUTPUT_FILE" ]]; then
|
||||
info "[debug] NVIDIA GPU stats output file found at $DEBUG_NVIDIA_OUTPUT_FILE"
|
||||
ENABLE_NVIDIA_GPU_INFO=1
|
||||
else
|
||||
warn "[debug] NVIDIA GPU stats output file not found at $DEBUG_NVIDIA_OUTPUT_FILE. GPU stats graphs will be empty."
|
||||
fi
|
||||
else
|
||||
warn "No NVIDIA GPUs in debug file."
|
||||
fi
|
||||
elif _check_nvidia_tool; then
|
||||
local nvidiaCards
|
||||
nvidiaCards=$(nvidia-smi -L 2>/dev/null || true)
|
||||
if [[ -n "$nvidiaCards" ]]; then
|
||||
info "NVIDIA GPU(s) detected:"
|
||||
echo "$nvidiaCards" | while IFS= read -r line; do echo " $line"; done
|
||||
ENABLE_NVIDIA_GPU_INFO=1
|
||||
else
|
||||
warn "No NVIDIA GPUs detected by nvidia-smi."
|
||||
fi
|
||||
fi
|
||||
#endregion NVIDIA GPU
|
||||
|
||||
#region AMD GPU (placeholder)
|
||||
ENABLE_AMD_GPU_INFO=0
|
||||
#endregion AMD GPU
|
||||
|
||||
#region GPU history
|
||||
if [[ "$ENABLE_INTEL_GPU_INFO" -eq 1 || "$ENABLE_NVIDIA_GPU_INFO" -eq 1 ]]; then
|
||||
msgb "\n=== GPU Historical Data ==="
|
||||
local choice
|
||||
choice=$(ask "Store historical GPU data for graphs? (y/N)")
|
||||
case "$choice" in
|
||||
[yY]) ENABLE_GPU_HISTORY=1; info "Historical GPU data will be stored." ;;
|
||||
*) info "Historical GPU data disabled." ;;
|
||||
esac
|
||||
fi
|
||||
#endregion GPU history
|
||||
|
||||
#region UPS
|
||||
msgb "\n=== UPS Information ==="
|
||||
local choiceUPS
|
||||
choiceUPS=$(ask "Enable UPS information? (y/N)")
|
||||
case "$choiceUPS" in
|
||||
[yY])
|
||||
local upsConn modelName upsOutput
|
||||
upsConn=$(ask "Enter UPS connection string (e.g. upsname@hostname[:port])")
|
||||
if [[ "$DEBUG_UPS" -eq 1 ]]; then
|
||||
if [[ -f "$DEBUG_UPS_FILE" ]]; then
|
||||
info "[debug] Using UPS data from $DEBUG_UPS_FILE"
|
||||
else
|
||||
warn "[debug] Debug mode for UPS is enabled but file not found at $DEBUG_UPS_FILE."
|
||||
fi
|
||||
upsOutput=$(cat "$DEBUG_UPS_FILE" || true)
|
||||
ENABLE_UPS=1
|
||||
elif _check_or_install_tool upsc nut-client "Network UPS Tools (upsc)" && [[ -n "$upsConn" ]]; then
|
||||
upsOutput=$(upsc "$upsConn" 2>/dev/null || true)
|
||||
else
|
||||
warn "Could not connect to UPS at '$upsConn'. UPS info will be disabled."
|
||||
fi
|
||||
if [[ -n "$upsOutput" ]]; then
|
||||
modelName=$(echo "$upsOutput" | grep "device.model:" | cut -d: -f2- | xargs)
|
||||
ENABLE_UPS=1
|
||||
UPS_DEVICE_NAME="$upsConn"
|
||||
info "Connected to UPS: $modelName at $upsConn"
|
||||
else
|
||||
warn "Could not connect to UPS at '$upsConn'. UPS info will be disabled."
|
||||
ENABLE_UPS=0
|
||||
fi
|
||||
;;
|
||||
*) info "UPS information disabled." ;;
|
||||
esac
|
||||
#endregion UPS
|
||||
|
||||
#region System info
|
||||
msgb "\n=== System Information ==="
|
||||
echo " type 1) System information (manufacturer, product, serial)"
|
||||
dmidecode -t 1 2>/dev/null | awk -F': ' '/Manufacturer|Product Name|Serial Number/ {print " "$0}' || true
|
||||
echo " type 2) Baseboard/Motherboard information"
|
||||
dmidecode -t 2 2>/dev/null | awk -F': ' '/Manufacturer|Product Name|Serial Number/ {print " "$0}' || true
|
||||
|
||||
local choiceSys
|
||||
choiceSys=$(ask "Enable system information? (1/2/n)")
|
||||
case "$choiceSys" in
|
||||
1|"") ENABLE_SYSTEM_INFO=1; SYSTEM_INFO_TYPE=1; info "System information (type 1) will be shown." ;;
|
||||
2) ENABLE_SYSTEM_INFO=1; SYSTEM_INFO_TYPE=2; info "Baseboard information (type 2) will be shown." ;;
|
||||
[nN]) info "System information disabled." ;;
|
||||
*) warn "Invalid selection. Defaulting to type 1."; ENABLE_SYSTEM_INFO=1; SYSTEM_INFO_TYPE=1 ;;
|
||||
esac
|
||||
|
||||
if [[ "$ENABLE_SYSTEM_INFO" -eq 1 ]]; then
|
||||
local cache_dir="/var/lib/pve-mod"
|
||||
mkdir -p "$cache_dir"
|
||||
local cache_file="${cache_dir}/dmidecode-type${SYSTEM_INFO_TYPE}.txt"
|
||||
dmidecode -t "$SYSTEM_INFO_TYPE" > "$cache_file" 2>/dev/null || true
|
||||
chmod 644 "$cache_file"
|
||||
info "DMI data cached to $cache_file"
|
||||
fi
|
||||
#endregion System info
|
||||
}
|
||||
#endregion node-info wizard
|
||||
|
||||
node_info_write_conf() {
|
||||
cat > "$NODE_INFO_CONF" <<EOF
|
||||
# pve-mod :: node_info mod configuration
|
||||
# Managed by pve-mod-configure. Re-run to update.
|
||||
|
||||
[gpu]
|
||||
intel_enabled=${ENABLE_INTEL_GPU_INFO}
|
||||
nvidia_enabled=${ENABLE_NVIDIA_GPU_INFO}
|
||||
amd_enabled=${ENABLE_AMD_GPU_INFO}
|
||||
gpu_history=${ENABLE_GPU_HISTORY}
|
||||
|
||||
[lm_sensors]
|
||||
enabled=${LM_SENSORS_ENABLED}
|
||||
enable_cpu=${ENABLE_CPU}
|
||||
cpu_temp_target=${CPU_TEMP_TARGET}
|
||||
enable_ram_temp=${ENABLE_RAM_TEMP}
|
||||
enable_hdd_temp=${ENABLE_HDD_TEMP}
|
||||
enable_nvme_temp=${ENABLE_NVME_TEMP}
|
||||
enable_other_temp=${ENABLE_OTHER_TEMP}
|
||||
enable_fan_speed=${ENABLE_FAN_SPEED}
|
||||
display_zero_speed_fans=${DISPLAY_ZERO_SPEED_FANS}
|
||||
temp_unit=${TEMP_UNIT}
|
||||
|
||||
[ups]
|
||||
enabled=${ENABLE_UPS}
|
||||
device_name=${UPS_DEVICE_NAME}
|
||||
|
||||
[system_info]
|
||||
enabled=${ENABLE_SYSTEM_INFO}
|
||||
type=${SYSTEM_INFO_TYPE}
|
||||
|
||||
# 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=${DEBUG_LM_SENSORS}
|
||||
lm_sensors_output_file=${DEBUG_LM_SENSORS_FILE}
|
||||
intel_mode=${DEBUG_INTEL}
|
||||
intel_devices_file=${DEBUG_INTEL_FILE}
|
||||
intel_output_file=${DEBUG_INTEL_OUTPUT_FILE}
|
||||
nvidia_mode=${DEBUG_NVIDIA}
|
||||
nvidia_devices_file=${DEBUG_NVIDIA_DEVICES_FILE}
|
||||
nvidia_output_file=${DEBUG_NVIDIA_OUTPUT_FILE}
|
||||
amd_mode=${DEBUG_AMD}
|
||||
amd_devices_file=${DEBUG_AMD_FILE}
|
||||
ups_mode=${DEBUG_UPS}
|
||||
ups_output_file=${DEBUG_UPS_FILE}
|
||||
log_enabled=${DEBUG_LOG}
|
||||
log_file=${DEBUG_LOG_FILE}
|
||||
EOF
|
||||
info "node_info configuration saved to $NODE_INFO_CONF"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
--- a/usr/share/perl5/PVE/API2/Nodes.pm
|
||||
+++ b/usr/share/perl5/PVE/API2/Nodes.pm
|
||||
@@ -536,6 +536,13 @@
|
||||
|
||||
$res->{pveversion} = PVE::pvecfg::package() . "/" . PVE::pvecfg::version_text();
|
||||
|
||||
+ # Collect sensor data from PveMod_SensorInfo
|
||||
+ use PVE::API2::PVEMod_SensorInfo;
|
||||
+ $res->{PveMod_JsonSensorInfo} = PVE::API2::PVEMod_SensorInfo::get_sensors_info();
|
||||
+ $res->{PveMod_Version} = PVE::API2::PVEMod_SensorInfo::get_pve_mod_version();
|
||||
+ $res->{PveMod_upsInfo} = PVE::API2::PVEMod_SensorInfo::get_ups_info();
|
||||
+ $res->{PveMod_graphicsInfo} = PVE::API2::PVEMod_SensorInfo::get_graphics_info();
|
||||
+ $res->{PveMod_systemInfo} = PVE::API2::PVEMod_SensorInfo::get_system_information();
|
||||
my $dinfo = df('/', 1); # output is bytes
|
||||
|
||||
$res->{rootfs} = {
|
||||
@@ -0,0 +1,67 @@
|
||||
--- usr/share/perl5/PVE/API2/Nodes.pm 2026-05-24 02:05:58.000000000 +0200
|
||||
+++ /usr/share/perl5/PVE/API2/Nodes.pm 2026-06-13 16:29:47.810720485 +0200
|
||||
@@ -251,6 +251,7 @@
|
||||
{ name => 'report' },
|
||||
{ name => 'rrd' }, # fixme: remove?
|
||||
{ name => 'rrddata' },
|
||||
+ { name => 'gpurrddata' },
|
||||
{ name => 'scan' },
|
||||
{ name => 'sdn' },
|
||||
{ name => 'services' },
|
||||
@@ -879,6 +880,56 @@
|
||||
);
|
||||
},
|
||||
});
|
||||
+
|
||||
+__PACKAGE__->register_method({
|
||||
+ name => 'gpurrddata',
|
||||
+ path => 'gpurrddata',
|
||||
+ method => 'GET',
|
||||
+ protected => 1,
|
||||
+ proxyto => 'node',
|
||||
+ permissions => {
|
||||
+ check => ['perm', '/nodes/{node}', ['Sys.Audit']],
|
||||
+ },
|
||||
+ description => "Read GPU RRD statistics",
|
||||
+ parameters => {
|
||||
+ additionalProperties => 0,
|
||||
+ properties => {
|
||||
+ node => get_standard_option('pve-node'),
|
||||
+ card => {
|
||||
+ description => "The GPU card identifier (e.g. card0, nvidia0).",
|
||||
+ type => 'string',
|
||||
+ pattern => '[a-zA-Z0-9]+',
|
||||
+ },
|
||||
+ timeframe => {
|
||||
+ description => "Specify the time frame you are interested in.",
|
||||
+ type => 'string',
|
||||
+ enum => ['hour', 'day', 'week', 'month', 'year', 'decade'],
|
||||
+ },
|
||||
+ cf => {
|
||||
+ description => "The RRD consolidation function",
|
||||
+ type => 'string',
|
||||
+ enum => ['AVERAGE', 'MAX'],
|
||||
+ optional => 1,
|
||||
+ },
|
||||
+ },
|
||||
+ },
|
||||
+ returns => {
|
||||
+ type => "array",
|
||||
+ items => {
|
||||
+ type => "object",
|
||||
+ properties => {},
|
||||
+ },
|
||||
+ },
|
||||
+ code => sub {
|
||||
+ my ($param) = @_;
|
||||
+ my $nodename = PVE::INotify::nodename();
|
||||
+ my $card = $param->{card};
|
||||
+ die "invalid card name\n" unless $card =~ /^[a-zA-Z0-9]+$/;
|
||||
+ return PVE::RRD::create_rrd_data(
|
||||
+ "pve-mod-gpu/$nodename/$card", $param->{timeframe}, $param->{cf},
|
||||
+ );
|
||||
+ },
|
||||
+});
|
||||
|
||||
__PACKAGE__->register_method({
|
||||
name => 'syslog',
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +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
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# post-apply hook for the node_info mod.
|
||||
# Runs after node_info patches are applied. Provided env: MOD_CONF, STASH_DIR,
|
||||
# CONFD_DIR. Creates the GPU RRD storage directory when GPU history is enabled.
|
||||
# Exit codes: 0 = no change, 100 = changed (restart pveproxy), other = error.
|
||||
|
||||
set -u
|
||||
|
||||
GPU_RRD_DIR="/var/lib/rrdcached/db/pve-mod-gpu"
|
||||
|
||||
read_conf() {
|
||||
local file="$1" section="$2" key="$3" default="${4:-0}"
|
||||
[[ -f "$file" ]] || { echo "$default"; return; }
|
||||
local val
|
||||
val=$(awk -F= -v sec="[$section]" -v k="$key" '
|
||||
/^\[/ { in_sec = ($0 == sec) }
|
||||
in_sec && /^[^#=]+=/ {
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1)
|
||||
if ($1 == k) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2); print $2; exit }
|
||||
}
|
||||
' "$file")
|
||||
echo "${val:-$default}"
|
||||
}
|
||||
|
||||
gpu_history="$(read_conf "${MOD_CONF:-/etc/pve-mod/conf.d/node_info.conf}" gpu gpu_history 0)"
|
||||
|
||||
if [[ "$gpu_history" == "1" && ! -d "$GPU_RRD_DIR" ]]; then
|
||||
mkdir -p "$GPU_RRD_DIR"
|
||||
chown www-data:www-data "$GPU_RRD_DIR" 2>/dev/null || true
|
||||
echo "[pve-mod] Created GPU RRD directory: $GPU_RRD_DIR"
|
||||
exit 100
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,69 @@
|
||||
# node_info
|
||||
|
||||
Extends the Proxmox VE node status view with live hardware sensor data. A background daemon collects data from the configured sources and exposes it through the PVE API.
|
||||
|
||||
## Features
|
||||
|
||||
### Temperature Sensors (lm-sensors)
|
||||
|
||||
Reads hardware sensor data via `lm-sensors` and enriches each chip/adapter entry with context. CPU, RAM, HDD/SSD, NVME are directly supported and other temperature sensors can be bundled and displayed together.
|
||||
|
||||
### NVIDIA GPU
|
||||
|
||||
Polls `nvidia-smi` on a configurable interval. Supports multiple GPUs. Metrics can be stored in RRD for historical graphing.
|
||||
|
||||
| Metric | Unit |
|
||||
|--------|------|
|
||||
| GPU temperature | °C |
|
||||
| GPU utilisation | % |
|
||||
| Memory utilisation | % |
|
||||
| Memory used / total | MiB |
|
||||
| Power draw / limit | W |
|
||||
| Fan speed | % |
|
||||
|
||||
### Intel GPU
|
||||
|
||||
Polls `intel_gpu_top` for each detected Intel GPU card. Metrics can be stored in RRD for historical graphing.
|
||||
|
||||
| Metric | Unit |
|
||||
|--------|------|
|
||||
| Requested / actual frequency | MHz |
|
||||
| Interrupt rate | irq/s |
|
||||
| RC6 residency | % |
|
||||
| GPU power / package power | W |
|
||||
| Engine busy/semaphore/wait (Render, Blitter, Video, VideoEnhance) | % |
|
||||
|
||||
### AMD GPU
|
||||
|
||||
Placeholder — device discovery and collection are not yet implemented.
|
||||
|
||||
### UPS (Network UPS Tools)
|
||||
|
||||
Polls `upsc` for a configured NUT device and exposes all key-value pairs returned by the daemon. Supports any UPS accessible via NUT (local or remote).
|
||||
|
||||
Configuration: `device_name=ups@localhost`
|
||||
|
||||
### System Information
|
||||
|
||||
Reads hardware identity from `dmidecode` (cached at configure time, no runtime root required).
|
||||
|
||||
| `type` | Data source | Fields exposed |
|
||||
|--------|-------------|----------------|
|
||||
| `1` | DMI System | Manufacturer, Product Name, Serial Number |
|
||||
| `2` | DMI Baseboard | Manufacturer, Product Name, Serial Number |
|
||||
|
||||
## Requirements
|
||||
|
||||
Each feature requires the corresponding tool to be installed on the Proxmox host:
|
||||
|
||||
| Feature | Required tool |
|
||||
|---------|---------------|
|
||||
| Temperature sensors | `lm-sensors` (`sensors` binary) |
|
||||
| NVIDIA GPU | `nvidia-smi` |
|
||||
| Intel GPU | `intel-gpu-tools` (`intel_gpu_top` binary) |
|
||||
| UPS | `nut-client` (`upsc` binary) |
|
||||
| System information | `dmidecode` (run once via `pve-mod-configure`) |
|
||||
|
||||
## Debug Mode
|
||||
|
||||
Each collector supports a debug mode that reads from a local file instead of executing the real tool. Useful for development and testing without physical hardware.
|
||||
Reference in new issue
Block a user