Compare commits

..

11 Commits

Author SHA1 Message Date
github-actions[bot]
de13e2329b
chore: update changelog to v1.0.7 (#298)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-23 01:31:02 +02:00
Meliox
6f78347a0b
fix:(nag_screen): register dpkg trigger interest for proxmox-widget-toolkit for automatic patching (#317)
Co-authored-by: Meliox <na>
2026-08-23 01:23:53 +02:00
Meliox
8d70194b8a
fix(nag_screen): Patch typo (#318)
Co-authored-by: Meliox <na>
2026-08-23 01:23:42 +02:00
Meliox
2a122ef3eb
feat(node_info) Add configurable minimum-temperature ignore threshold (#316)
Co-authored-by: Meliox <na>
2026-08-23 00:30:51 +02:00
Meliox
0476410de5
fix(node_info): re-show sensor widgets when data becomes valid again after being hidden (#312)
Co-authored-by: Meliox <na>
2026-08-23 00:25:27 +02:00
Meliox
972c9ae956
fix(node_info): reap zombie pve_mod_worker and orphaned collector processes (#307)
* fix(node_info): reap zombie pve_mod_worker and orphaned collector processes

Double-forks pve_mod_worker so it is reparented to init instead of the calling process, which reaps it immediately on exit and prevents it lingering as a <defunct> zombie. Also adds startup-time reaping of orphaned collector-* processes (ppid==1) left behind if a worker was previously killed with SIGKILL, and an opportunistic waitpid(WNOHANG) in _worker_lock_file_exists as a cheap supplemental reap.

* fix compile error and reap orphans

---------

Co-authored-by: Meliox <na>
2026-08-22 23:48:59 +02:00
Evan
69e2d78b0a
feat(node_info): UPS widget: redesign status panel with battery-charge progress bar (#310) 2026-08-22 23:33:04 +02:00
Meliox
bdb3f9f3df
fix(node_info): normalize UPS data — derive ups.realpower fallback and clean invalid battery.mfr.date (#309)
Co-authored-by: Meliox <na>
2026-08-22 23:17:59 +02:00
Meliox
c768eac6e3
fix(nag_screen): Fix nag screen patch webui line offsets (#315)
Co-authored-by: Meliox <na>
2026-08-22 21:20:25 +02:00
Meliox
7cb9aefced
feat(ci): Capture and report Proxmox package versions in weekly patch test issues (#314)
Co-authored-by: Meliox <na>
2026-08-22 20:50:22 +02:00
Meliox
d99d08cf5f
fix(ci): pass mod argument to single patch in test-patches.sh (#313)
Co-authored-by: Meliox <na>
2026-08-22 20:49:58 +02:00
13 changed files with 538 additions and 216 deletions

17
debian/changelog vendored
View File

@ -1,3 +1,20 @@
pve-mod (1.0.7) stable; urgency=low
* 8d70194 fix(nag_screen): Patch typo (#318)
* 2a122ef feat(node_info) Add configurable minimum-temperature ignore threshold (#316)
* 0476410 fix(node_info): re-show sensor widgets when data becomes valid again after being hidden (#312)
* 972c9ae fix(node_info): reap zombie pve_mod_worker and orphaned collector processes (#307)
* 69e2d78 feat(node_info): UPS widget: redesign status panel with battery-charge progress bar (#310)
* bdb3f9f fix(node_info): normalize UPS data — derive ups.realpower fallback and clean invalid battery.mfr.date (#309)
* c768eac fix(nag_screen): Fix nag screen patch webui line offsets (#315)
* 8a9b756 fix(node_info): hide GPU Details and GPU Fans when no GPU is present (#311)
* f754331 fix(node_info): fix stale worker lock detection for zombie processes (#306)
* e46734c fix(node_info): return average and core temps for intel (#302)
* cee8d04 feat/fix(node_info): expose temp_unit in graphics API response & fix temperature conversion (#295)
* 8ebc2b4 feat(node_info): add GPU utilization display with color-coded thresholds for NVIDIA GPUs (#296)
-- github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Sat, 22 Aug 2026 23:24:00 +0000
pve-mod (1.0.6) stable; urgency=low
* c21b973 remove buildtest file (#288)

View File

@ -1,3 +1,4 @@
interest-noawait /usr/share/pve-manager/js
interest-noawait /usr/share/perl5/PVE
interest-noawait /usr/share/pve-yew-mobile-gui
interest-noawait /usr/share/javascript/proxmox-widget-toolkit

View File

@ -1,7 +1,7 @@
--- 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),
--- /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.orig 2026-08-23 01:17:11.563403975 +0200
+++ /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js 2026-08-23 01:19:00.972084488 +0200
@@ -612,37 +612,8 @@
),
checked_command: function (orig_cmd) {
- Proxmox.Utils.API2Request({

View File

@ -97,8 +97,9 @@ sub _get_temperature_sensors {
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},
temp_unit => $config{system_info}{temp_unit},
cpu_temp_target => $config{lm_sensors}{cpu_temp_target},
ignore_temp_below => $config{system_info}{ignore_temp_below} + 0,
data => { 'PVE MOD lm-sensors Enhanced' => $sensors_json },
});

View File

@ -112,6 +112,8 @@ sub _parse_upsc_output {
}
}
}
_normalize_ups_data($ups_data);
};
if ($@) {
debug(__LINE__, "Error parsing upsc output: $@");
@ -122,4 +124,28 @@ sub _parse_upsc_output {
return $ups_data;
}
# ============================================================================
# UPS — data normalization
# ============================================================================
sub _normalize_ups_data {
my ($ups_data) = @_;
# Some devices report a placeholder (e.g. "OPEN") instead of a real date
if (defined $ups_data->{'battery.mfr.date'} && $ups_data->{'battery.mfr.date'} !~ m{^\d{4}[/-]\d{1,2}[/-]\d{1,2}$}) {
delete $ups_data->{'battery.mfr.date'};
}
# Derive real power draw for devices that don't report ups.realpower directly
if (!defined $ups_data->{'ups.realpower'}) {
if (defined $ups_data->{'ups.load'} && defined $ups_data->{'ups.realpower.nominal'}) {
$ups_data->{'ups.realpower'} = int((($ups_data->{'ups.load'} / 100) * $ups_data->{'ups.realpower.nominal'}) + 0.5);
} elsif (defined $ups_data->{'output.current'} && defined $ups_data->{'output.voltage'}) {
$ups_data->{'ups.realpower'} = int(($ups_data->{'output.current'} * $ups_data->{'output.voltage'}) + 0.5);
}
}
return;
}
1;

View File

@ -72,7 +72,6 @@ our %config = (
enable_other_temp => 0,
enable_fan_speed => 0,
display_zero_speed_fans => 0,
temp_unit => 'C',
},
ups => {
enabled => 0,
@ -81,6 +80,8 @@ our %config = (
system_info => {
enabled => 0,
type => 1, # 1 = System (dmidecode -t 1), 2 = Baseboard/Motherboard (dmidecode -t 2)
temp_unit => 'C',
ignore_temp_below => 5,
},
paths => {
working_dir => '/run/pveproxy/pve-mod',

View File

@ -4,7 +4,7 @@ use strict;
use warnings;
use Exporter 'import';
use POSIX qw(WNOHANG);
use POSIX qw(WNOHANG setsid);
use File::Path qw(remove_tree);
use PVE::PVEMod::Config qw(
@ -13,7 +13,7 @@ use PVE::PVEMod::Config qw(
$pve_mod_worker_lock $startup_lock
);
use PVE::PVEMod::Utils qw(
debug is_process_alive read_lock_pid
debug is_process_alive get_process_ppid read_lock_pid
acquire_exclusive_lock ensure_pve_mod_directory_exists
check_executable startup_message
);
@ -113,16 +113,36 @@ sub notify_pve_mod_worker {
sub _worker_lock_file_exists {
return 0 unless -f $pve_mod_worker_lock;
my $pid = read_lock_pid($pve_mod_worker_lock);
if (defined $pid && $pid =~ /^(\d+)$/ && is_process_alive($1)) {
if (!defined $pid || $pid !~ /^(\d+)$/) {
debug(__LINE__, "Worker lock is invalid (PID: " . ($pid // 'undefined') . "), removing");
unlink($pve_mod_worker_lock);
return 0;
}
my $worker_pid = $1;
if (is_process_alive($worker_pid)) {
return 1;
}
debug(__LINE__, "Stale or zombie worker lock found, removing it");
debug(__LINE__, "Stale or zombie worker lock found for PID $worker_pid, reaping if needed");
my $reaped_pid = waitpid($worker_pid, WNOHANG);
if ($reaped_pid == $worker_pid) {
debug(__LINE__, "Reaped stale worker PID $worker_pid");
} elsif ($reaped_pid == -1 && $!{ECHILD}) {
# Expected once the worker is double-forked: we're not its parent, init is.
debug(__LINE__, "PID $worker_pid is not a child of this process; treating lock as stale");
} else {
debug(__LINE__, "waitpid on PID $worker_pid returned $reaped_pid: $!");
}
unlink($pve_mod_worker_lock);
return 0;
}
# Forks the worker process and records its PID in the lock file.
# Double-forks so the worker is reparented to init (PID 1) instead of the
# caller; init reaps it on exit, so it never lingers as a <defunct> zombie.
sub _pve_mod_worker {
debug(__LINE__, "_pve_mod_worker called");
@ -132,34 +152,57 @@ sub _pve_mod_worker {
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();
debug(__LINE__, "Forking intermediate process for pve_mod_worker");
my $intermediate_pid = fork();
unless (defined $pve_mod_worker_pid) {
debug(__LINE__, "Failed to fork pve_mod_worker process: $!");
unless (defined $intermediate_pid) {
debug(__LINE__, "Failed to fork intermediate pve_mod_worker process: $!");
unlink($pve_mod_worker_lock);
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);
if ($intermediate_pid == 0) {
# Intermediate child: detach into its own session, fork the real
# worker, then exit immediately so the worker is reparented to init.
setsid();
my $worker_pid = fork();
unless (defined $worker_pid) {
debug(__LINE__, "Failed to fork pve_mod_worker process: $!");
unlink($pve_mod_worker_lock);
POSIX::_exit(1);
}
if ($worker_pid == 0) {
# Grandchild — the actual worker
$0 = "pve_mod_worker_controller";
if (open my $fh, '>', $pve_mod_worker_lock) {
print $fh "$$\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: $!");
}
debug(__LINE__, "Worker process forked, calling _pve_mod_keep_alive");
_pve_mod_keep_alive();
exit(0);
}
debug(__LINE__, "Intermediate process exiting, worker PID $worker_pid reparented to init");
# _exit (not exit) so we skip END blocks/global destruction — this
# process only ever existed to perform the double fork.
POSIX::_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");
# Parent — reap the short-lived intermediate process immediately, so it
# never has a chance to become a zombie under us either.
waitpid($intermediate_pid, 0);
my $status = $? >> 8;
if ($status == 0) {
debug(__LINE__, "pve_mod_worker process started successfully");
} else {
debug(__LINE__, "Failed to write pve_mod_worker lock file: $!");
kill('TERM', $pve_mod_worker_pid);
debug(__LINE__, "Intermediate pve_mod_worker process exited with status $status");
}
debug(__LINE__, "pve_mod_worker process started successfully");
}
# ============================================================================
@ -206,6 +249,8 @@ sub _pve_mod_keep_alive {
exit(0);
};
_reap_orphaned_collectors();
debug(__LINE__, "Worker starting all collectors");
_initialise_sensors_collector();
_initialise_graphics_collectors();
@ -458,6 +503,74 @@ sub _stop_child_collectors {
debug(__LINE__, "Cleanup complete");
}
# Finds and terminates collector processes left behind by a previous worker
# that never got to run _stop_child_collectors() (e.g. killed with SIGKILL).
# Only targets processes reparented to init, never a running worker's own.
sub _reap_orphaned_collectors {
debug(__LINE__, "Scanning for orphaned collector processes from a previous worker");
my $dh;
unless (opendir($dh, '/proc')) {
debug(__LINE__, "Failed to open /proc: $!");
return;
}
my @orphans;
while (my $entry = readdir($dh)) {
next unless $entry =~ /^(\d+)$/;
my $pid = $1;
next if $pid == $$;
next unless open(my $fh, '<', "/proc/$pid/cmdline");
my $cmdline = <$fh>;
close($fh);
next unless defined $cmdline;
my ($name) = split /\0/, $cmdline;
next unless defined $name && $name =~ /^collector-/;
my $ppid = get_process_ppid($pid);
next unless defined $ppid && $ppid == 1;
push @orphans, $pid;
}
closedir($dh);
unless (@orphans) {
debug(__LINE__, "No orphaned collectors found");
return;
}
debug(__LINE__, "Found " . scalar(@orphans) . " orphaned collector(s), terminating");
foreach my $pid (@orphans) {
kill('TERM', $pid);
debug(__LINE__, "Sent SIGTERM to orphaned collector PID $pid");
}
# Orphans are already zombies as soon as init reaps them, and kill(0,...)
# keeps reporting a zombie's PID as present - use is_process_alive() so we
# don't spin the full timeout (or send a pointless KILL) on a dead PID.
my $timeout = 2;
my $start = time();
while (time() - $start < $timeout) {
my $any_alive = 0;
foreach my $pid (@orphans) {
if (is_process_alive($pid)) { $any_alive = 1; last; }
}
last unless $any_alive;
select(undef, undef, undef, 0.1);
}
foreach my $pid (@orphans) {
if (is_process_alive($pid)) {
debug(__LINE__, "Force killing orphaned collector process $pid");
kill('KILL', $pid);
}
}
debug(__LINE__, "Orphan cleanup complete");
}
# ============================================================================
# END block — only the worker process performs cleanup
# ============================================================================

View File

@ -71,7 +71,8 @@ sub _merge_graphics_files {
Intel => {},
NVIDIA => {},
AMD => {},
temp_unit => $config{lm_sensors}{temp_unit},
temp_unit => $config{system_info}{temp_unit},
ignore_temp_below => $config{system_info}{ignore_temp_below} + 0,
}
};
@ -135,7 +136,7 @@ sub _load_graphics_data {
\@filepaths,
$graphics_cache,
\&_merge_graphics_files,
{ Graphics => { Intel => {}, NVIDIA => {}, AMD => {}, temp_unit => $config{lm_sensors}{temp_unit} } }
{ Graphics => { Intel => {}, NVIDIA => {}, AMD => {}, temp_unit => $config{system_info}{temp_unit}, ignore_temp_below => $config{system_info}{ignore_temp_below} + 0 } }
);
my $intel_count = scalar(keys %{$data->{Graphics}{Intel} // {}});

View File

@ -223,8 +223,10 @@ Ext.define('PVE.node.StatusView', {
} catch(e) {
objValue = {};
}
this.show();
// sensors configuration
const cpuTempHelper = Ext.create('PVE.mod.TempHelper', {srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: value.temp_unit === 'F' ? PVE.mod.TempHelper.FAHRENHEIT : PVE.mod.TempHelper.CELSIUS});
const cpuIgnoreThreshold = cpuTempHelper.getTemp(parseFloat(value.ignore_temp_below));
const cpuKeysI = Object.keys(objValue).filter(item => String(item).startsWith('coretemp-isa-')).sort();
const cpuKeysA = Object.keys(objValue).filter(item => String(item).startsWith('k10temp-pci-')).sort();
const cpuKeysRpi = Object.keys(objValue).filter(item => String(item).startsWith('cpu_thermal-virtual-')).sort();
@ -304,7 +306,7 @@ Ext.define('PVE.node.StatusView', {
}
});
if (!isNaN(tempVal)) {
if (!isNaN(tempVal) && tempVal >= cpuIgnoreThreshold) {
let tempStyle = '';
if (!isNaN(tempMax) && tempVal >= tempMax) {
tempStyle = 'color: #FFC300; font-weight: bold;';
@ -483,17 +485,20 @@ Ext.define('PVE.node.StatusView', {
// Temperature
if (stats.temperature) {
const gpuTemp = gpuTempHelper.getTemp(parseFloat(stats.temperature.gpu));
const tempUnit = gpuTempHelper.getUnit();
// Convert thresholds to target unit for comparison
const tempHigh = gpuTempHelper.getTemp(80);
const tempWarn = gpuTempHelper.getTemp(70);
let tempStyle = '';
if (gpuTemp >= tempHigh) {
tempStyle = 'color: red; font-weight: bold;';
} else if (gpuTemp >= tempWarn) {
tempStyle = 'color: #FFC300; font-weight: bold;';
const gpuIgnoreThreshold = gpuTempHelper.getTemp(parseFloat(gpuStats.ignore_temp_below));
if (gpuTemp >= gpuIgnoreThreshold) {
const tempUnit = gpuTempHelper.getUnit();
// Convert thresholds to target unit for comparison
const tempHigh = gpuTempHelper.getTemp(80);
const tempWarn = gpuTempHelper.getTemp(70);
let tempStyle = '';
if (gpuTemp >= tempHigh) {
tempStyle = 'color: red; font-weight: bold;';
} else if (gpuTemp >= tempWarn) {
tempStyle = 'color: #FFC300; font-weight: bold;';
}
details.push(`Temp: <span style="${tempStyle}">${Ext.util.Format.number(gpuTemp, '0')}${tempUnit}</span>`);
}
details.push(`Temp: <span style="${tempStyle}">${Ext.util.Format.number(gpuTemp, '0')}${tempUnit}</span>`);
}
// Power
@ -543,7 +548,9 @@ Ext.define('PVE.node.StatusView', {
} catch(e) {
objValue = {};
}
this.show();
const tempHelper = Ext.create('PVE.mod.TempHelper', {srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: value.temp_unit === 'F' ? PVE.mod.TempHelper.FAHRENHEIT : PVE.mod.TempHelper.CELSIUS});
const ignoreThreshold = tempHelper.getTemp(parseFloat(value.ignore_temp_below));
const drvKeys = Object.keys(objValue).filter(item => String(item).startsWith(addressPrefix)).sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
let drvData = [];
drvKeys.forEach((drvKey) => {
@ -559,7 +566,7 @@ Ext.define('PVE.node.StatusView', {
tempCrit = tempHelper.getTemp(parseFloat(drv[sensorName][secondLevelKey]));
}
});
if (!isNaN(tempVal)) {
if (!isNaN(tempVal) && tempVal >= ignoreThreshold) {
let tempStyle = '';
if (!isNaN(tempMax) && tempVal >= tempMax) {
tempStyle = 'color: #FFC300; font-weight: bold;';
@ -623,7 +630,9 @@ Ext.define('PVE.node.StatusView', {
} catch(e) {
objValue = {};
}
this.show();
const tempHelper = Ext.create('PVE.mod.TempHelper', {srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: value.temp_unit === 'F' ? PVE.mod.TempHelper.FAHRENHEIT : PVE.mod.TempHelper.CELSIUS});
const ignoreThreshold = tempHelper.getTemp(parseFloat(value.ignore_temp_below));
const nvmeKeys = Object.keys(objValue).filter(item => String(item).startsWith(addressPrefix)).sort();
let nvmeData = [];
nvmeKeys.forEach((nvmeKey, index) => {
@ -641,7 +650,7 @@ Ext.define('PVE.node.StatusView', {
model = objValue[nvmeKey]['model'] || 'Unknown';
serial = objValue[nvmeKey]['serial'] || '';
if (!isNaN(tempVal)) {
if (!isNaN(tempVal) && tempVal >= ignoreThreshold) {
let tempStyle = '';
if (!isNaN(tempMax) && tempVal >= tempMax) {
tempStyle = 'color: #FFC300; font-weight: bold;';
@ -711,7 +720,9 @@ Ext.define('PVE.node.StatusView', {
} catch(e) {
objValue = {};
}
this.show();
const tempHelper = Ext.create('PVE.mod.TempHelper', {srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: value.temp_unit === 'F' ? PVE.mod.TempHelper.FAHRENHEIT : PVE.mod.TempHelper.CELSIUS});
const ignoreThreshold = tempHelper.getTemp(parseFloat(value.ignore_temp_below));
// Keep only keys that do not belong to known categories
const otherKeys = Object.keys(objValue).filter(key =>
@ -746,7 +757,7 @@ Ext.define('PVE.node.StatusView', {
}
});
if (!isNaN(tempVal)) {
if (!isNaN(tempVal) && tempVal >= ignoreThreshold) {
let tempStyle = '';
if (!isNaN(tempMax) && tempVal >= tempMax) {
tempStyle = 'color: #FFC300; font-weight: bold;';
@ -811,6 +822,7 @@ Ext.define('PVE.node.StatusView', {
} catch(e) {
objValue = {};
}
this.show();
// Recursive function to find fan keys and values
function findFanKeys(obj, fanKeys, parentKey = null) {
@ -906,191 +918,298 @@ Ext.define('PVE.node.StatusView', {
},
},
{
itemId: 'upsc',
colspan: 2,
printBar: false,
title: gettext('UPS Status'),
iconCls: 'fa fa-fw fa-battery-three-quarters',
textField: 'PveMod_upsInfo',
renderer: function(value) {
itemId: 'upsc',
colspan: 2,
title: gettext('UPS Status'),
iconCls: 'fa fa-fw fa-battery-three-quarters',
valueField: 'PveMod_upsInfo',
printBar: true,
warningThreshold: 1.1,
criticalThreshold: 1.2,
layout: {
type: 'vbox',
align: 'stretch',
},
items: [
{
xtype: 'component',
itemId: 'label',
data: {
title: '',
usage: '',
iconCls: undefined,
},
tpl: [
'<div class="left-aligned">',
'<tpl if="iconCls">',
'<i class="{iconCls}"></i> ',
'</tpl>',
'{title}</div>',
],
},
{
xtype: 'container',
layout: {
type: 'hbox',
align: 'middle',
},
items: [
{
xtype: 'component',
itemId: 'usageText',
flex: 1,
minWidth: 0,
margin: '0 16 0 0',
},
{
xtype: 'container',
flex: 1,
minWidth: 0,
layout: {
type: 'vbox',
align: 'stretch',
},
items: [
{
xtype: 'component',
itemId: 'loadText',
margin: '0 0 2 0',
},
{
xtype: 'progressbar',
itemId: 'progress',
height: 5,
value: 0,
animate: true,
},
],
},
],
},
],
initComponent: function() {
var me = this;
if (!me.title) {
throw 'no title defined';
}
Ext.container.Container.prototype.initComponent.call(me);
var progress = me.down('#progress');
if (progress) {
progress.setVisible(!!me.printBar);
}
me.updateValue(me.text, me.value);
me.setIconCls(me.iconCls);
},
setPrintBar: function(enable) {
var me = this;
me.printBar = enable;
var progress = me.down('#progress');
if (progress) {
progress.setVisible(enable);
}
},
// Single source of truth for all DOM writes — called once per refresh
// cycle, right after renderer() returns. renderer() only computes data;
// it never touches the DOM, so there's no race/flash between the two.
updateValue: function(text, usage) {
var me = this;
var loadText = me._pendingLoadText || '';
if (me.lastText === text && me.lastUsage === usage && me.lastLoadText === loadText) {
return;
}
me.lastText = text;
me.lastUsage = usage;
me.lastLoadText = loadText;
var label = me.getComponent('label');
if (label) {
label.update(Ext.apply(label.data, { title: me.title, usage: '' }));
}
var usageText = me.down('#usageText');
if (usageText) {
if (usageText.setHtml) {
usageText.setHtml(text || '');
} else {
usageText.update(text || '');
}
}
var loadTextCmp = me.down('#loadText');
if (loadTextCmp) {
if (loadTextCmp.setHtml) {
loadTextCmp.setHtml(loadText);
} else {
loadTextCmp.update(loadText);
}
}
var progressBar = me.down('#progress');
if (usage !== undefined && me.printBar && Ext.isNumeric(usage) && usage >= 0 && progressBar) {
progressBar.updateProgress(usage, '');
if (usage > me.criticalThreshold) {
progressBar.removeCls('warning');
progressBar.addCls('critical');
} else if (usage > me.warningThreshold) {
progressBar.removeCls('critical');
progressBar.addCls('warning');
} else {
progressBar.removeCls('warning');
progressBar.removeCls('critical');
}
}
},
// Drives the progress bar's fill — based on battery.charge.
calculate: function(used) {
if (!used || used.disabled === true || typeof used !== 'object') {
return 0;
}
let charge = NaN;
Object.keys(used).forEach(function(k) {
const row = used[k];
if (row && typeof row === 'object' && row['battery.charge'] != null) {
charge = parseFloat(row['battery.charge']);
}
});
if (isNaN(charge)) {
return 0;
}
return Math.max(0, Math.min(1, charge / 100));
},
// Pure computation — no DOM writes. Stashes the "Battery capacity ...
// X% (Runtime: ...)" line for updateValue to place above the bar, and
// returns a 30/70 table (model | other info incl. Load) wrapped in the
// standard indent div, matching the other widgets in this panel.
renderer: function(value) {
let objValue;
try {
objValue = value || {};
} catch(e) {
} catch (e) {
objValue = {};
}
if (objValue.disabled === true) {
this.hide();
this.setPrintBar(false);
return '';
} else if (!objValue || Object.keys(objValue).length === 0) {
return 'N/A';
}
this.show();
// Helper function to get status color
function getStatusColor(status) {
if (!status) return '#999';
const statusUpper = status.toUpperCase();
if (statusUpper.includes('OL')) return null;
if (statusUpper.includes('OB')) return '#d9534f';
if (statusUpper.includes('LB')) return '#d9534f';
return '#f0ad4e';
const upsKeys = Object.keys(objValue).filter(function(k) {
return objValue[k] && typeof objValue[k] === 'object' && !Array.isArray(objValue[k]);
});
if (!upsKeys.length) {
this.hide();
this.setPrintBar(false);
return '';
}
this.show();
this.setPrintBar(true);
// Helper function to get load/charge color
function getPercentageColor(value, isLoad = false) {
if (!value || isNaN(value)) return '#999';
const num = parseFloat(value);
if (isLoad) {
if (num >= 80) return '#d9534f';
if (num >= 60) return '#f0ad4e';
return null;
} else {
if (num <= 20) return '#d9534f';
if (num <= 50) return '#f0ad4e';
return null;
}
}
// Helper function to format runtime
function formatRuntime(seconds) {
if (!seconds || isNaN(seconds)) return 'N/A';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}m ${secs}s`;
const s = parseInt(seconds, 10);
if (!s || isNaN(s)) {
return null;
}
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
if (h > 0) {
return h + 'h ' + m + 'm';
}
return m + 'm';
}
// Process each UPS in the data
let allDisplayItems = [];
function statusText(upsStatus) {
const u = String(upsStatus || '').toUpperCase();
if (u.indexOf('LB') >= 0) {
return { text: 'Low Battery', color: '#d9534f' };
}
if (u.indexOf('OB') >= 0) {
return { text: 'On Battery', color: '#d9534f' };
}
if (u.indexOf('FSD') >= 0) {
return { text: 'Shutdown', color: '#d9534f' };
}
if (u.indexOf('OL') >= 0) {
return {
text: u.indexOf('CHRG') >= 0 ? 'Online, charging' : 'Online',
color: null,
};
}
return { text: upsStatus || 'Unknown', color: '#f0ad4e' };
}
Object.keys(objValue).forEach(upsKey => {
const upsData = objValue[upsKey];
function colorize(label, color) {
if (!color) {
return label;
}
return '<span style="color:' + color + ';">' + label + '</span>';
}
// Extract key UPS information
const batteryCharge = upsData['battery.charge'];
const batteryRuntime = upsData['battery.runtime'];
const inputVoltage = upsData['input.voltage'];
const upsLoad = upsData['ups.load'];
const upsStatus = upsData['ups.status'];
const upsModel = upsData['ups.model'] || upsData['device.model'];
let aboveBarText = '';
const rows = [];
upsKeys.forEach(function(upsKey) {
const upsData = objValue[upsKey] || {};
const charge = parseFloat(upsData['battery.charge']);
const runtime = formatRuntime(upsData['battery.runtime']);
const inputVoltage = parseFloat(upsData['input.voltage']);
const load = parseFloat(upsData['ups.load']);
const watts = parseFloat(upsData['ups.realpower']);
const model = upsData['ups.model'] || upsData['device.model'] || upsKey;
const st = statusText(upsData['ups.status']);
const testResult = upsData['ups.test.result'];
const batteryChargeLow = upsData['battery.charge.low'];
const batteryRuntimeLow = upsData['battery.runtime.low'];
const upsRealPowerNominal = upsData['ups.realpower.nominal'];
const batteryMfrDate = upsData['battery.mfr.date'];
const manufacturingDate = upsData['battery.mfr.date'];
// Main status line with all metrics
let statusLine = '';
// Above the bar: "Battery capacity" on the left, charge% (Runtime: ...) on the right.
let rightSide = !isNaN(charge) ? (Math.round(charge) + '%') : '';
if (runtime) {
rightSide += (rightSide ? ' ' : '') + '(Runtime: ' + runtime + ' left)';
}
aboveBarText =
'<div style="display: flex; justify-content: space-between; gap: 8px;">' +
'<span>Battery capacity</span>' +
'<span style="text-align: right;">' + rightSide + '</span>' +
'</div>';
// Status
if (upsStatus) {
const statusUpper = upsStatus.toUpperCase();
let statusText = 'Unknown';
let statusColor = '#f0ad4e';
if (statusUpper.includes('OL')) {
statusText = 'Online';
statusColor = null;
} else if (statusUpper.includes('OB')) {
statusText = 'On Battery';
statusColor = '#d9534f';
} else if (statusUpper.includes('LB')) {
statusText = 'Low Battery';
statusColor = '#d9534f';
} else {
statusText = upsStatus;
statusColor = '#f0ad4e';
}
let statusStyle = statusColor ? ('color: ' + statusColor + ';') : '';
statusLine += 'Status: <span style="' + statusStyle + '">' + statusText + '</span>';
} else {
statusLine += 'Status: <span>N/A</span>';
// General information table: Status, Output, Input, Load, Test.
const infoBits = [];
if (st.text) {
infoBits.push('Status: ' + colorize(st.text, st.color));
}
if (!isNaN(watts)) {
infoBits.push('Output: ' + Math.round(watts) + 'W');
}
if (!isNaN(inputVoltage)) {
const places = inputVoltage >= 50 ? 0 : 1;
infoBits.push('Input: ' + inputVoltage.toFixed(places) + ' V');
}
if (!isNaN(load)) {
infoBits.push('Load: ' + Math.round(load) + '%');
}
if (manufacturingDate) {
infoBits.push('Manufacturing Date: ' + manufacturingDate);
}
if (testResult) {
infoBits.push('Test: ' + testResult);
}
// Battery charge
if (statusLine) statusLine += ' | ';
if (batteryCharge) {
const chargeColor = getPercentageColor(batteryCharge, false);
let chargeStyle = chargeColor ? ('color: ' + chargeColor + ';') : '';
statusLine += 'Battery: <span style="' + chargeStyle + '">' + batteryCharge + '%</span>';
} else {
statusLine += 'Battery: <span>N/A</span>';
}
// Load percentage
if (statusLine) statusLine += ' | ';
if (upsLoad) {
const loadColor = getPercentageColor(upsLoad, true);
let loadStyle = loadColor ? ('color: ' + loadColor + ';') : '';
statusLine += 'Load: <span style="' + loadStyle + '">' + upsLoad + '%</span>';
} else {
statusLine += 'Load: <span>N/A</span>';
}
// Runtime
if (statusLine) statusLine += ' | ';
if (batteryRuntime) {
const runtime = parseInt(batteryRuntime);
const runtimeLowThreshold = batteryRuntimeLow ? parseInt(batteryRuntimeLow) : 600;
let runtimeColor = null;
if (runtime <= runtimeLowThreshold / 2) runtimeColor = '#d9534f';
else if (runtime <= runtimeLowThreshold) runtimeColor = '#f0ad4e';
let runtimeStyle = runtimeColor ? ('color: ' + runtimeColor + ';') : '';
statusLine += 'Runtime: <span style="' + runtimeStyle + '">' + formatRuntime(runtime) + '</span>';
} else {
statusLine += 'Runtime: <span>N/A</span>';
}
// Input voltage
if (statusLine) statusLine += ' | ';
if (inputVoltage) {
statusLine += 'Input: <span>' + parseFloat(inputVoltage).toFixed(0) + 'V</span>';
} else {
statusLine += 'Input: <span>N/A</span>';
}
// Calculate actual watt usage
if (statusLine) statusLine += ' | ';
let actualWattage = null;
if (upsLoad && upsRealPowerNominal) {
const load = parseFloat(upsLoad);
const nominal = parseFloat(upsRealPowerNominal);
if (!isNaN(load) && !isNaN(nominal)) {
actualWattage = Math.round((load / 100) * nominal);
}
}
// Real power (calculated watt usage)
if (actualWattage !== null) {
statusLine += 'Output: <span>' + actualWattage + 'W</span>';
} else {
statusLine += 'Output: <span>N/A</span>';
}
// Append battery MFD + last test to the same line (single-line UPS summary)
statusLine += ' | Battery MFD: ' + (batteryMfrDate || 'N/A');
if (testResult && !testResult.toLowerCase().includes('no test')) {
const testColor = testResult.toLowerCase().includes('passed') ? null : '#d9534f';
let testStyle = testColor ? ('color: ' + testColor + ';') : '';
statusLine += ' | <span style="' + testStyle + '">Test: ' + testResult + '</span>';
} else {
statusLine += ' | Test: N/A';
}
// Build UPS display with model on left, details on right
let upsHtml = '<tr>';
upsHtml += '<td style="padding: 2px 10px 2px 0; text-align: left; width: 30%; vertical-align: top; overflow-wrap: anywhere; word-break: break-word;">' + (upsModel || upsKey) + '</td>';
upsHtml += '<td style="padding: 2px 0 2px 10px; text-align: right; width: 70%; vertical-align: top; overflow-wrap: anywhere; word-break: break-word; white-space: normal;">' + statusLine + '</td>';
upsHtml += '</tr>';
allDisplayItems.push(upsHtml);
rows.push(
'<tr>' +
'<td style="padding: 2px 10px 2px 0; text-align: left; width: 30%; vertical-align: top; overflow-wrap: anywhere; word-break: break-word;">' + model + '</td>' +
'<td style="padding: 2px 0 2px 10px; text-align: right; width: 70%; vertical-align: top; overflow-wrap: anywhere; word-break: break-word; white-space: normal;">' + infoBits.join(' | ') + '</td>' +
'</tr>'
);
});
// Format the final output for all UPS devices
return '<div style="padding-left: 20px; box-sizing: border-box;"><table style="width: 100%; border-collapse: collapse; table-layout: fixed;">' + allDisplayItems.join('') + '</table></div>';
// Stash for updateValue to consume — no DOM writes here.
this._pendingLoadText = aboveBarText;
// Becomes the `text` argument passed to updateValue (left column table).
return '<div style="padding-left: 20px; box-sizing: border-box;"><table style="width: 100%; border-collapse: collapse; table-layout: fixed;">' + rows.join('') + '</table></div>';
}
},
},
{
xtype: 'box',
colspan: 2,
@ -1158,6 +1277,7 @@ Ext.define('PVE.node.StatusView', {
this.hide();
return '';
}
this.show();
const titleMap = {
manufacturer: 'Manufacturer',

View File

@ -15,6 +15,7 @@ our @EXPORT_OK = qw(
debug
read_sysfs
is_process_alive
get_process_ppid
read_lock_pid
acquire_exclusive_lock
ensure_pve_mod_directory_exists
@ -102,6 +103,24 @@ sub is_process_alive {
return 1;
}
# Returns the parent PID of $pid, or undef if it can't be determined.
sub get_process_ppid {
my ($pid) = @_;
return undef unless open my $fh, '<', "/proc/$pid/stat";
my $line = <$fh>;
close $fh;
return undef unless defined $line;
# comm field (2nd, in parens) can itself contain ')', so split on the
# last one before parsing the remaining space-separated fields.
my $last_paren = rindex($line, ')');
return undef if $last_paren < 0;
my @fields = split ' ', substr($line, $last_paren + 1);
return undef unless defined $fields[1] && $fields[1] =~ /^(\d+)$/;
return $1;
}
sub read_lock_pid {
my ($lock_path) = @_;

View File

@ -17,7 +17,6 @@ enable_hdd_temp=0
enable_nvme_temp=0
enable_fan_speed=0
display_zero_speed_fans=0
temp_unit=C
[ups]
enabled=0
@ -26,6 +25,8 @@ device_name=ups@localhost
[system_info]
enabled=0
type=1
temp_unit=C
ignore_temp_below=5
# 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.

View File

@ -65,11 +65,11 @@ 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_FAN_SPEED=0; DISPLAY_ZERO_SPEED_FANS=0
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
ENABLE_SYSTEM_INFO=0; SYSTEM_INFO_TYPE=1; TEMP_UNIT="C"; IGNORE_TEMP_BELOW=5
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"
@ -104,11 +104,12 @@ node_info_load_conf() {
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" ;;
system_info.temp_unit) TEMP_UNIT="$val" ;;
system_info.ignore_temp_below) IGNORE_TEMP_BELOW="$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" ;;
@ -133,11 +134,11 @@ node_info_configure() {
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_FAN_SPEED=0; DISPLAY_ZERO_SPEED_FANS=0
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
ENABLE_SYSTEM_INFO=0; SYSTEM_INFO_TYPE=1; TEMP_UNIT="C"; IGNORE_TEMP_BELOW=5
local lm_sensors_ok=false
local sensors_detected=false
@ -294,6 +295,26 @@ node_info_configure() {
[fF]) TEMP_UNIT="F"; info "Using Fahrenheit." ;;
*) TEMP_UNIT="C"; info "Using Celsius." ;;
esac
msgb "\n=== Ignore threshold ==="
local default_c=5 default_display entered
if [[ "$TEMP_UNIT" == "F" ]]; then
default_display=$(awk -v c="$default_c" 'BEGIN{printf "%.0f", c*9/5+32}')
else
default_display="$default_c"
fi
while true; do
entered=$(ask "Hide temperature readings below this value (°${TEMP_UNIT}) [${default_display}]")
[[ -z "$entered" ]] && entered="$default_display"
[[ "$entered" =~ ^-?[0-9]+(\.[0-9]+)?$ ]] && break
warn "Invalid number, please try again."
done
if [[ "$TEMP_UNIT" == "F" ]]; then
IGNORE_TEMP_BELOW=$(awk -v f="$entered" 'BEGIN{printf "%.1f", (f-32)*5/9}')
else
IGNORE_TEMP_BELOW="$entered"
fi
info "Temperature readings below ${entered}°${TEMP_UNIT} will be hidden."
fi
#endregion Temperature unit
fi
@ -478,7 +499,6 @@ 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}
@ -487,6 +507,8 @@ device_name=${UPS_DEVICE_NAME}
[system_info]
enabled=${ENABLE_SYSTEM_INFO}
type=${SYSTEM_INFO_TYPE}
temp_unit=${TEMP_UNIT}
ignore_temp_below=${IGNORE_TEMP_BELOW}
# 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.