From 0c25030a218d1fad41cdf0f8d546590f5c7b2421 Mon Sep 17 00:00:00 2001 From: Meliox <5264368+Meliox@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:07:33 +0200 Subject: [PATCH] Migrate file patching from sed/python into generic patching scheme (#181) * Start patching * rework apply and revert patches to patch * don't let a patch failure abort apt install * roll the mod back if its post-apply hook errors * add patch for gpu history * Start patching * rework apply and revert patches to patch * don't let a patch failure abort apt install * roll the mod back if its post-apply hook errors * add patch for gpu history * fix rrd history patch for compilation * add test workflows * Move test and CI to separate PR * Start patching * rework apply and revert patches to patch * don't let a patch failure abort apt install * roll the mod back if its post-apply hook errors * add patch for gpu history * fix rrd history patch for compilation * add test workflows * Move test and CI to separate PR * readd test-release * realign test-release with main --------- Co-authored-by: Meliox --- debian/control | 2 +- debian/pve-mod.conffiles | 2 + debian/pve-mod.postinst | 57 +- debian/rules | 39 +- src/NagScreen/nag_screen.conf | 3 + .../patches/01-proxmoxlib-js-nagscreen.patch | 1 + src/NagScreen/patches/patches.list | 4 + src/NagScreen/patches/post-apply.sh | 26 + src/NagScreen/patches/post-revert.sh | 23 + src/PVENodeInfo/apply-patches.sh | 261 ----- src/PVENodeInfo/node_info.conf | 45 + .../patches/01-nodes-pm-sensors.patch | 15 + .../patches/02-nodes-pm-GPU-RRD-history.patch | 68 ++ .../patches/03-pvemanager-js-sensors.patch | 1013 +++++++++++++++++ src/PVENodeInfo/patches/patches.list | 9 + src/PVENodeInfo/patches/post-apply.sh | 34 + src/PVENodeInfo/revert-patches.sh | 69 -- src/Scripts/apply-patches.sh | 220 ++++ src/Scripts/pve-mod-configure | 52 +- src/Scripts/revert-patches.sh | 101 ++ src/pve-mod.conf | 52 +- 21 files changed, 1686 insertions(+), 410 deletions(-) create mode 100644 src/NagScreen/nag_screen.conf create mode 100644 src/NagScreen/patches/01-proxmoxlib-js-nagscreen.patch create mode 100644 src/NagScreen/patches/patches.list create mode 100644 src/NagScreen/patches/post-apply.sh create mode 100644 src/NagScreen/patches/post-revert.sh delete mode 100644 src/PVENodeInfo/apply-patches.sh create mode 100644 src/PVENodeInfo/node_info.conf create mode 100644 src/PVENodeInfo/patches/01-nodes-pm-sensors.patch create mode 100644 src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch create mode 100644 src/PVENodeInfo/patches/03-pvemanager-js-sensors.patch create mode 100644 src/PVENodeInfo/patches/patches.list create mode 100644 src/PVENodeInfo/patches/post-apply.sh delete mode 100644 src/PVENodeInfo/revert-patches.sh create mode 100644 src/Scripts/apply-patches.sh create mode 100644 src/Scripts/revert-patches.sh diff --git a/debian/control b/debian/control index 5f1ab58..3315227 100644 --- a/debian/control +++ b/debian/control @@ -8,7 +8,7 @@ Homepage: https://github.com/Meliox/PVE-mods Package: pve-mod Architecture: all -Depends: ${misc:Depends}, perl, librrds-perl +Depends: ${misc:Depends}, perl, librrds-perl, patch Recommends: lm-sensors, nut-client Suggests: intel-gpu-tools Description: Proxmox VE UI modifications and sensor monitoring diff --git a/debian/pve-mod.conffiles b/debian/pve-mod.conffiles index 0c4d9d3..1db9445 100644 --- a/debian/pve-mod.conffiles +++ b/debian/pve-mod.conffiles @@ -1 +1,3 @@ /etc/pve-mod/pve-mod.conf +/etc/pve-mod/conf.d/node_info.conf +/etc/pve-mod/conf.d/nag_screen.conf diff --git a/debian/pve-mod.postinst b/debian/pve-mod.postinst index 7ca0c76..2a33baa 100644 --- a/debian/pve-mod.postinst +++ b/debian/pve-mod.postinst @@ -1,9 +1,15 @@ #!/bin/bash set -e -NODES_PM="/usr/share/perl5/PVE/API2/Nodes.pm" -DEFAULT_CONF="/usr/share/pve-mod/pve-mod.conf.default" -USER_CONF="/etc/pve-mod/pve-mod.conf" +DEFAULT_DIR="/usr/share/pve-mod" +CONF_DIR="/etc/pve-mod" + +# Config file pairs to compare on upgrade: "|" +CONF_PAIRS=" +${DEFAULT_DIR}/pve-mod.conf.default|${CONF_DIR}/pve-mod.conf +${DEFAULT_DIR}/conf.d/node_info.conf.default|${CONF_DIR}/conf.d/node_info.conf +${DEFAULT_DIR}/conf.d/nag_screen.conf.default|${CONF_DIR}/conf.d/nag_screen.conf +" # Extracts "section.key" pairs from an INI file, one per line. _extract_conf_keys() { @@ -17,38 +23,59 @@ _extract_conf_keys() { done < "$file" } -# Warns about keys present in the default config but missing from the user's config. -_check_new_config_keys() { - [ -f "$DEFAULT_CONF" ] || return 0 - [ -f "$USER_CONF" ] || return 0 +# Warns about keys present in a default config but missing from the user's copy. +_check_one_pair() { + local default_conf="$1" user_conf="$2" + [ -f "$default_conf" ] || return 0 + [ -f "$user_conf" ] || return 0 - local new_keys="" section="" key default_val + local new_keys="" section="" key line while IFS= read -r line; do case "$line" in '#'*|'') continue ;; '['*']') section="${line#[}"; section="${section%]}"; continue ;; *'='*) key="${line%%=*}" - if ! _extract_conf_keys "$USER_CONF" | grep -qF "${section}.${key}"; then - new_keys="${new_keys} [${section}] ${line}\n" + if ! _extract_conf_keys "$user_conf" | grep -qF "${section}.${key}"; then + new_keys="${new_keys} [${section}] ${line} (${user_conf})\n" fi ;; esac - done < "$DEFAULT_CONF" + done < "$default_conf" if [ -n "$new_keys" ]; then + printf "%b" "$new_keys" + fi +} + +# Warns about any new config keys across the main config and all per-mod configs. +_check_new_config_keys() { + local all_new="" pair default_conf user_conf + while IFS= read -r pair; do + [ -n "$pair" ] || continue + default_conf="${pair%%|*}" + user_conf="${pair#*|}" + all_new="${all_new}$(_check_one_pair "$default_conf" "$user_conf")" + done <&2 + fi if [ -z "$2" ]; then echo "" diff --git a/debian/rules b/debian/rules index f816056..c74f86a 100644 --- a/debian/rules +++ b/debian/rules @@ -31,17 +31,46 @@ override_dh_install: # JS module (rename to match loader reference) install -Dm644 src/PVENodeInfo/PveMod_pvemanagerlib.js \ debian/pve-mod/usr/share/pve-manager/js/PveMod_PveNodeStatusView.js - # Patch helpers - install -Dm755 src/PVENodeInfo/apply-patches.sh \ + # Patch helpers (generic, manifest-driven) + install -Dm755 src/Scripts/apply-patches.sh \ debian/pve-mod/usr/lib/pve-mod/apply-patches.sh - install -Dm755 src/PVENodeInfo/revert-patches.sh \ + install -Dm755 src/Scripts/revert-patches.sh \ debian/pve-mod/usr/lib/pve-mod/revert-patches.sh + # node_info mod patches + manifest + hooks + install -Dm644 src/PVENodeInfo/patches/patches.list \ + debian/pve-mod/usr/lib/pve-mod/patches/node_info/patches.list + install -Dm644 src/PVENodeInfo/patches/01-nodes-pm-sensors.patch \ + debian/pve-mod/usr/lib/pve-mod/patches/node_info/01-nodes-pm-sensors.patch + install -Dm644 src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch \ + debian/pve-mod/usr/lib/pve-mod/patches/node_info/02-nodes-pm-GPU-RRD-history.patch + install -Dm644 src/PVENodeInfo/patches/03-pvemanager-js-sensors.patch \ + debian/pve-mod/usr/lib/pve-mod/patches/node_info/03-pvemanager-js-sensors.patch + install -Dm755 src/PVENodeInfo/patches/post-apply.sh \ + debian/pve-mod/usr/lib/pve-mod/patches/node_info/post-apply.sh + # nag_screen mod patches + manifest + hooks + install -Dm644 src/NagScreen/patches/patches.list \ + debian/pve-mod/usr/lib/pve-mod/patches/nag_screen/patches.list + install -Dm644 src/NagScreen/patches/01-proxmoxlib-js-nagscreen.patch \ + debian/pve-mod/usr/lib/pve-mod/patches/nag_screen/01-proxmoxlib-js-nagscreen.patch + install -Dm755 src/NagScreen/patches/post-apply.sh \ + debian/pve-mod/usr/lib/pve-mod/patches/nag_screen/post-apply.sh + install -Dm755 src/NagScreen/patches/post-revert.sh \ + debian/pve-mod/usr/lib/pve-mod/patches/nag_screen/post-revert.sh # Configure tool install -Dm755 src/Scripts/pve-mod-configure \ debian/pve-mod/usr/sbin/pve-mod-configure - # Default config (conffile for user edits) + # Main config (conffile for user edits) install -Dm644 src/pve-mod.conf \ debian/pve-mod/etc/pve-mod/pve-mod.conf - # Reference copy for upgrade key-diff (not a conffile — always updated) + # Per-mod configs under conf.d (conffiles for user edits) + install -Dm644 src/PVENodeInfo/node_info.conf \ + debian/pve-mod/etc/pve-mod/conf.d/node_info.conf + install -Dm644 src/NagScreen/nag_screen.conf \ + debian/pve-mod/etc/pve-mod/conf.d/nag_screen.conf + # Reference copies for upgrade key-diff (not conffiles — always updated) install -Dm644 src/pve-mod.conf \ debian/pve-mod/usr/share/pve-mod/pve-mod.conf.default + install -Dm644 src/PVENodeInfo/node_info.conf \ + debian/pve-mod/usr/share/pve-mod/conf.d/node_info.conf.default + install -Dm644 src/NagScreen/nag_screen.conf \ + debian/pve-mod/usr/share/pve-mod/conf.d/nag_screen.conf.default diff --git a/src/NagScreen/nag_screen.conf b/src/NagScreen/nag_screen.conf new file mode 100644 index 0000000..7143096 --- /dev/null +++ b/src/NagScreen/nag_screen.conf @@ -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. diff --git a/src/NagScreen/patches/01-proxmoxlib-js-nagscreen.patch b/src/NagScreen/patches/01-proxmoxlib-js-nagscreen.patch new file mode 100644 index 0000000..33052a3 --- /dev/null +++ b/src/NagScreen/patches/01-proxmoxlib-js-nagscreen.patch @@ -0,0 +1 @@ +gain \ No newline at end of file diff --git a/src/NagScreen/patches/patches.list b/src/NagScreen/patches/patches.list new file mode 100644 index 0000000..ba7233a --- /dev/null +++ b/src/NagScreen/patches/patches.list @@ -0,0 +1,4 @@ +# pve-mod :: nag_screen patch manifest +# Format: [section.key=value] + +01-proxmoxlib-js-nagscreen.patch diff --git a/src/NagScreen/patches/post-apply.sh b/src/NagScreen/patches/post-apply.sh new file mode 100644 index 0000000..c4f0b96 --- /dev/null +++ b/src/NagScreen/patches/post-apply.sh @@ -0,0 +1,26 @@ +#!/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. Replaces the minified proxmoxlib with a symlink to the patched +# unminified copy so the browser serves the patched code. The original minified +# file is stashed (it is a non-patch binary replacement, so `patch -R` cannot +# restore it). Exit codes: 0 = no change, 100 = changed, other = error. + +set -u + +PROXMOXLIB_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js" +PROXMOXLIB_MIN_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js" +STASH_DIR="${STASH_DIR:-/var/lib/pve-mod/backup}" + +if [[ ! -L "$PROXMOXLIB_MIN_JS" ]]; then + mkdir -p "$STASH_DIR" + if [[ -f "$PROXMOXLIB_MIN_JS" ]]; then + mv "$PROXMOXLIB_MIN_JS" \ + "$STASH_DIR/proxmoxlib.min.js.$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true + fi + ln -sf "$PROXMOXLIB_JS" "$PROXMOXLIB_MIN_JS" + echo "[pve-mod] Symlinked proxmoxlib.min.js -> proxmoxlib.js" + exit 100 +fi + +exit 0 diff --git a/src/NagScreen/patches/post-revert.sh b/src/NagScreen/patches/post-revert.sh new file mode 100644 index 0000000..45c9df3 --- /dev/null +++ b/src/NagScreen/patches/post-revert.sh @@ -0,0 +1,23 @@ +#!/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. Removes the min.js symlink and restores the original +# minified file from the stash. Exit codes: 0 = no change, 100 = changed. + +set -u + +PROXMOXLIB_MIN_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js" +STASH_DIR="${STASH_DIR:-/var/lib/pve-mod/backup}" + +if [[ -L "$PROXMOXLIB_MIN_JS" ]]; then + rm -f "$PROXMOXLIB_MIN_JS" + latest=$(find "$STASH_DIR" -name "proxmoxlib.min.js.*" -type f -printf '%T+ %p\n' 2>/dev/null \ + | sort -r | head -n1 | awk '{print $2}') + if [[ -n "$latest" ]]; then + cp "$latest" "$PROXMOXLIB_MIN_JS" + echo "[pve-mod] Restored proxmoxlib.min.js from stash" + fi + exit 100 +fi + +exit 0 diff --git a/src/PVENodeInfo/apply-patches.sh b/src/PVENodeInfo/apply-patches.sh deleted file mode 100644 index cba11d7..0000000 --- a/src/PVENodeInfo/apply-patches.sh +++ /dev/null @@ -1,261 +0,0 @@ -#!/usr/bin/env bash -# /usr/lib/pve-mod/apply-patches.sh -# -# Applies pve-mod patches to Proxmox VE system files. -# Reads /etc/pve-mod/pve-mod.conf to determine which modules are enabled. -# Idempotent: safe to call multiple times (e.g. from apt hook after PVE upgrade). - -CONF_FILE="/etc/pve-mod/pve-mod.conf" -BACKUP_DIR="/var/lib/pve-mod/backup" -NODES_PM="/usr/share/perl5/PVE/API2/Nodes.pm" -PVE_MANAGER_JS="/usr/share/pve-manager/js/pvemanagerlib.js" -PVE_MOD_JS="/usr/share/pve-manager/js/PveMod_PveNodeStatusView.js" -PROXMOXLIB_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js" -PROXMOXLIB_MIN_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js" -GPU_RRD_DIR="/var/lib/rrdcached/db/pve-mod-gpu" - -info() { echo "[pve-mod] $*"; } -warn() { echo "[pve-mod] WARNING: $*" >&2; } - -# Read one value from the INI config file; prints $default if not found. -read_conf() { - local section="$1" key="$2" default="${3:-0}" - if [[ ! -f "$CONF_FILE" ]]; then - echo "$default" - return - fi - 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 - } - } - ' "$CONF_FILE") - echo "${val:-$default}" -} - -backup_file() { - local src="$1" - [[ -f "$src" ]] || return 0 - local name ts - name=$(basename "$src") - ts=$(date +%Y%m%d_%H%M%S) - mkdir -p "$BACKUP_DIR" - cp "$src" "$BACKUP_DIR/${name}.${ts}" - info "Backed up $(basename "$src") → $BACKUP_DIR/${name}.${ts}" -} - -# ── Read enabled modules ────────────────────────────────────────────────────── -NODE_INFO=$(read_conf modules node_info 0) -NAG_SCREEN=$(read_conf modules nag_screen 0) -GPU_HISTORY=$(read_conf gpu gpu_history 0) - -CHANGED=false - -# ── node-info: Nodes.pm ─────────────────────────────────────────────────────── -if [[ "$NODE_INFO" == "1" ]]; then - if ! grep -qF "use PVE::API2::PVEMod_SensorInfo" "$NODES_PM" 2>/dev/null; then - backup_file "$NODES_PM" - python3 - "$NODES_PM" <<'PYEOF' -import sys, re - -path = sys.argv[1] -content = open(path).read() - -if 'use PVE::API2::PVEMod_SensorInfo' in content: - sys.exit(0) - -m = re.search(r'^([ \t]*)my \$dinfo = df\(\'\/\', 1\);', content, re.MULTILINE) -if not m: - print("ERROR: Anchor 'my $dinfo = df' not found in Nodes.pm", file=sys.stderr) - sys.exit(1) - -indent = m.group(1) -insertion = ( - f"{indent}# Collect sensor data from PveMod_SensorInfo\n" - f"{indent}use PVE::API2::PVEMod_SensorInfo;\n" - f"{indent}$res->{{PveMod_JsonSensorInfo}} = PVE::API2::PVEMod_SensorInfo::get_sensors_info();\n" - f"{indent}$res->{{PveMod_Version}} = PVE::API2::PVEMod_SensorInfo::get_pve_mod_version();\n" - f"{indent}$res->{{PveMod_graphicsInfo}} = PVE::API2::PVEMod_SensorInfo::get_graphics_info();\n" - f"{indent}$res->{{PveMod_upsInfo}} = PVE::API2::PVEMod_SensorInfo::get_ups_info();\n" - f"{indent}$res->{{PveMod_systemInfo}} = PVE::API2::PVEMod_SensorInfo::get_system_information();\n" -) -content = content[:m.start()] + insertion + content[m.start():] -open(path, 'w').write(content) -PYEOF - info "Patched Nodes.pm" - CHANGED=true - fi - - # ── node-info: pvemanagerlib.js ─────────────────────────────────────────── - if ! grep -qF "PveMod_PveNodeStatusView.js" "$PVE_MANAGER_JS" 2>/dev/null; then - backup_file "$PVE_MANAGER_JS" - python3 - "$PVE_MANAGER_JS" <<'PYEOF' -import sys, re - -path = sys.argv[1] -content = open(path).read() - -if 'PveMod_PveNodeStatusView.js' in content: - sys.exit(0) - -# Comment out original StatusView definition -content = re.sub( - r"(?m)^(Ext\.define\('PVE\.node\.StatusView',.*?^}\);)", - lambda m: '\n'.join('// ' + line for line in m.group(1).split('\n')), - content, flags=re.DOTALL -) - -# Comment out original Summary definition -content = re.sub( - r"(?m)^(Ext\.define\('PVE\.node\.Summary',.*?^}\);)", - lambda m: '\n'.join('// ' + line for line in m.group(1).split('\n')), - content, flags=re.DOTALL -) - -# Insert dynamic loader before the now-commented StatusView block -loader = ( - "// Load custom PVE.node.StatusView from external module\n" - "Ext.Loader.loadScript({\n" - " url: '/pve2/js/PveMod_PveNodeStatusView.js',\n" - " onLoad: function() { },\n" - " onError: function() { console.error('Failed to load PveMod_PveNodeStatusView.js'); }\n" - "});\n" -) -content = re.sub( - r"(// Ext\.define\('PVE\.node\.StatusView',)", - loader + r'\1', - content, count=1 -) -open(path, 'w').write(content) -PYEOF - info "Patched pvemanagerlib.js" - CHANGED=true - fi -fi - -# ── node-info: GPU RRD history ──────────────────────────────────────────────── -if [[ "$NODE_INFO" == "1" && "$GPU_HISTORY" == "1" ]]; then - if ! grep -qF "gpurrddata" "$NODES_PM" 2>/dev/null; then - # Register method in the node sub-path list - sed -i "s/{ name => 'rrddata' },/{ name => 'rrddata' },\n { name => 'gpurrddata' },/" "$NODES_PM" - - # Append gpurrddata method definition after the rrddata code block - python3 - "$NODES_PM" <<'PYEOF' -import sys, re - -path = sys.argv[1] -content = open(path).read() - -if 'gpurrddata' in content: - sys.exit(0) - -method = r""" -__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}, - ); - }, -}); -""" - -# Insert before the final 1; at end of file -content = re.sub(r'\n1;\s*$', method + '\n1;\n', content) -open(path, 'w').write(content) -PYEOF - - mkdir -p "$GPU_RRD_DIR" - chown www-data:www-data "$GPU_RRD_DIR" 2>/dev/null || true - info "Installed gpurrddata API endpoint" - CHANGED=true - fi -fi - -# ── nag-screen: proxmoxlib.js ───────────────────────────────────────────────── -if [[ "$NAG_SCREEN" == "1" ]]; then - if ! grep -qF "// disable subscription nag screen" "$PROXMOXLIB_JS" 2>/dev/null; then - backup_file "$PROXMOXLIB_JS" - python3 - "$PROXMOXLIB_JS" <<'PYEOF' -import sys, re - -path = sys.argv[1] -content = open(path).read() - -if '// disable subscription nag screen' in content: - sys.exit(0) - -m = re.search(r'(checked_command:\s*function\s*\(orig_cmd\)\s*\{)', content) -if not m: - print("ERROR: checked_command pattern not found in proxmoxlib.js", file=sys.stderr) - sys.exit(1) - -insert = "\n\t\t\t// disable subscription nag screen\n\t\t\torig_cmd();\n\t\t\treturn;" -pos = m.end() -content = content[:pos] + insert + content[pos:] -open(path, 'w').write(content) -PYEOF - info "Patched proxmoxlib.js (nag screen)" - CHANGED=true - fi - - if [[ ! -L "$PROXMOXLIB_MIN_JS" ]]; then - backup_file "$PROXMOXLIB_MIN_JS" - mv "$PROXMOXLIB_MIN_JS" "$BACKUP_DIR/proxmoxlib.min.js.$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true - ln -sf "$PROXMOXLIB_JS" "$PROXMOXLIB_MIN_JS" - info "Symlinked proxmoxlib.min.js → proxmoxlib.js" - CHANGED=true - fi -fi - -# ── restart pveproxy if anything changed ────────────────────────────────────── -if [[ "$CHANGED" == "true" ]]; then - info "Restarting pveproxy..." - systemctl restart pveproxy 2>/dev/null || true -fi diff --git a/src/PVENodeInfo/node_info.conf b/src/PVENodeInfo/node_info.conf new file mode 100644 index 0000000..13c4ed4 --- /dev/null +++ b/src/PVENodeInfo/node_info.conf @@ -0,0 +1,45 @@ +# pve-mod :: node_info mod configuration +# Settings for the node-info / sensor-monitoring mod. +# Managed by pve-mod-configure. Re-run to update. + +[gpu] +intel_enabled=0 +nvidia_enabled=0 +amd_enabled=0 +gpu_history=0 + +[lm_sensors] +enabled=0 +enable_cpu=0 +cpu_temp_target=Core +enable_ram_temp=0 +enable_hdd_temp=0 +enable_nvme_temp=0 +enable_fan_speed=0 +display_zero_speed_fans=0 +temp_unit=C + +[ups] +enabled=0 +device_name=ups@localhost + +[system_info] +enabled=0 +type=1 + +# Debug mode: when a collector's mode is 1, the real tool is not required. +# Data is read from the file path instead. Useful for development/testing. +[debug] +lm_sensors_mode=0 +lm_sensors_output_file=/tmp/sensors-output.json +intel_mode=0 +intel_devices_file=/tmp/intel-gpu-devices.json +nvidia_mode=0 +nvidia_devices_file=/tmp/nvidia-smi-devices.csv +nvidia_output_file=/tmp/nvidia-smi-output.csv +amd_mode=0 +amd_devices_file=/tmp/amd-gpu-devices.json +ups_mode=0 +ups_output_file=/tmp/ups-output.json +log_enabled=0 +log_file=/tmp/pve-mod-debug.log diff --git a/src/PVENodeInfo/patches/01-nodes-pm-sensors.patch b/src/PVENodeInfo/patches/01-nodes-pm-sensors.patch new file mode 100644 index 0000000..272e6f8 --- /dev/null +++ b/src/PVENodeInfo/patches/01-nodes-pm-sensors.patch @@ -0,0 +1,15 @@ +--- usr/share/perl5/PVE/API2/Nodes.pm 2026-05-24 02:05:58.000000000 +0200 ++++ /usr/share/perl5/PVE/API2/Nodes.pm 2026-06-08 20:38:56.988900119 +0200 +@@ -536,6 +536,12 @@ + + $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_systemInfo} = PVE::API2::PVEMod_SensorInfo::get_system_info(); + my $dinfo = df('/', 1); # output is bytes + + $res->{rootfs} = { diff --git a/src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch b/src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch new file mode 100644 index 0000000..141ae72 --- /dev/null +++ b/src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch @@ -0,0 +1,68 @@ +--- usr/share/perl5/PVE/API2/Nodes.pm 2026-05-24 02:05:58.000000000 +0200 ++++ /usr/share/perl5/PVE/API2/Nodes.pm 2026-06-09 22:30:12.187854456 +0200 +@@ -251,6 +251,7 @@ + { name => 'report' }, + { name => 'rrd' }, # fixme: remove? + { name => 'rrddata' }, ++ { name => 'gpurrddata' }, + { name => 'scan' }, + { name => 'sdn' }, + { name => 'services' }, +@@ -878,6 +879,57 @@ + "pve-node-9.0/$param->{node}", $param->{timeframe}, $param->{cf}, + ); + }, +-}); ++}); ++ ++__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({ \ No newline at end of file diff --git a/src/PVENodeInfo/patches/03-pvemanager-js-sensors.patch b/src/PVENodeInfo/patches/03-pvemanager-js-sensors.patch new file mode 100644 index 0000000..0252331 --- /dev/null +++ b/src/PVENodeInfo/patches/03-pvemanager-js-sensors.patch @@ -0,0 +1,1013 @@ +--- usr/share/pve-manager/js/pvemanagerlib.js 2026-05-24 02:05:58.000000000 +0200 ++++ /usr/share/pve-manager/js/pvemanagerlib.js 2026-06-07 15:44:28.581927600 +0200 +@@ -50737,175 +50737,181 @@ + me.reload(); + }, + }); +-Ext.define('PVE.node.StatusView', { +- extend: 'Proxmox.panel.StatusView', +- alias: 'widget.pveNodeStatus', +- +- height: 350, +- bodyPadding: '15 5 15 5', +- +- layout: { +- type: 'table', +- columns: 2, +- tableAttrs: { +- style: { +- width: '100%', +- }, +- }, +- }, +- +- defaults: { +- xtype: 'pmxInfoWidget', +- padding: '0 10 5 10', +- }, +- +- items: [ +- { +- itemId: 'cpu', +- iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon', +- title: gettext('CPU usage'), +- valueField: 'cpu', +- maxField: 'cpuinfo', +- renderer: Proxmox.Utils.render_node_cpu_usage, +- }, +- { +- itemId: 'wait', +- iconCls: 'fa fa-fw fa-clock-o', +- title: gettext('IO delay'), +- valueField: 'wait', +- rowspan: 2, +- }, +- { +- itemId: 'load', +- iconCls: 'fa fa-fw fa-tasks', +- title: gettext('Load average'), +- printBar: false, +- textField: 'loadavg', +- }, +- { +- xtype: 'box', +- colspan: 2, +- padding: '0 0 20 0', +- }, +- { +- iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', +- itemId: 'memory', +- title: gettext('RAM usage'), +- valueField: 'memory', +- maxField: 'memory', +- warningThreshold: 0.9, +- criticalThreshold: 0.975, +- // TODO: split out ARC usage +- renderer: Proxmox.Utils.render_node_size_usage, +- }, +- { +- itemId: 'ksm', +- printBar: false, +- title: gettext('KSM sharing'), +- textField: 'ksm', +- renderer: (record) => Proxmox.Utils.render_size(record.shared), +- padding: '0 10 10 10', +- }, +- { +- iconCls: 'fa fa-fw fa-hdd-o', +- itemId: 'rootfs', +- title: '/ ' + gettext('HD space'), +- valueField: 'rootfs', +- maxField: 'rootfs', +- renderer: Proxmox.Utils.render_node_size_usage, +- }, +- { +- iconCls: 'fa fa-fw fa-refresh', +- itemId: 'swap', +- printSize: true, +- title: gettext('SWAP usage'), +- valueField: 'swap', +- maxField: 'swap', +- renderer: Proxmox.Utils.render_node_size_usage, +- }, +- { +- xtype: 'box', +- colspan: 2, +- padding: '0 0 20 0', +- }, +- { +- itemId: 'cpus', +- colspan: 2, +- printBar: false, +- title: gettext('CPU(s)'), +- textField: 'cpuinfo', +- renderer: Proxmox.Utils.render_cpu_model, +- value: '', +- }, +- { +- colspan: 2, +- title: gettext('Kernel Version'), +- printBar: false, +- // TODO: remove with next major and only use newish current-kernel textfield +- multiField: true, +- //textField: 'current-kernel', +- renderer: ({ data }) => { +- if (!data['current-kernel']) { +- return data.kversion; +- } +- let kernel = data['current-kernel']; +- let buildDate = kernel.version.match(/\((.+)\)\s*$/)?.[1] ?? 'unknown'; +- return `${kernel.sysname} ${kernel.release} (${buildDate})`; +- }, +- value: '', +- }, +- { +- colspan: 2, +- title: gettext('Boot Mode'), +- printBar: false, +- textField: 'boot-info', +- renderer: (boot) => { +- if (boot.mode === 'legacy-bios') { +- return 'Legacy BIOS'; +- } else if (boot.mode === 'efi') { +- return `EFI${boot.secureboot ? ' (Secure Boot)' : ''}`; +- } +- return Proxmox.Utils.unknownText; +- }, +- value: '', +- }, +- { +- itemId: 'version', +- colspan: 2, +- printBar: false, +- title: gettext('Manager Version'), +- textField: 'pveversion', +- value: '', +- }, +- ], +- +- updateTitle: function () { +- var me = this; +- var uptime = Proxmox.Utils.render_uptime(me.getRecordValue('uptime')); +- me.setTitle(me.pveSelNode.data.node + ' (' + gettext('Uptime') + ': ' + uptime + ')'); +- }, +- +- initComponent: function () { +- let me = this; +- +- let stateProvider = Ext.state.Manager.getProvider(); +- let repoLink = stateProvider.encodeHToken({ +- view: 'server', +- rid: `node/${me.pveSelNode.data.node}`, +- ltab: 'tasks', +- nodetab: 'aptrepositories', +- }); +- +- me.items.push({ +- xtype: 'pmxNodeInfoRepoStatus', +- itemId: 'repositoryStatus', +- product: 'Proxmox VE', +- repoLink: `#${repoLink}`, +- }); +- +- me.callParent(); +- }, ++// Load custom PVE.node.StatusView from external module ++Ext.Loader.loadScript({ ++ url: '/pve2/js/PveMod_PveNodeStatusView.js', ++ onLoad: function() { }, ++ onError: function() { console.error('Failed to load PveMod_PveNodeStatusView.js'); } + }); ++// Ext.define('PVE.node.StatusView', { ++// extend: 'Proxmox.panel.StatusView', ++// alias: 'widget.pveNodeStatus', ++// ++// height: 350, ++// bodyPadding: '15 5 15 5', ++// ++// layout: { ++// type: 'table', ++// columns: 2, ++// tableAttrs: { ++// style: { ++// width: '100%', ++// }, ++// }, ++// }, ++// ++// defaults: { ++// xtype: 'pmxInfoWidget', ++// padding: '0 10 5 10', ++// }, ++// ++// items: [ ++// { ++// itemId: 'cpu', ++// iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon', ++// title: gettext('CPU usage'), ++// valueField: 'cpu', ++// maxField: 'cpuinfo', ++// renderer: Proxmox.Utils.render_node_cpu_usage, ++// }, ++// { ++// itemId: 'wait', ++// iconCls: 'fa fa-fw fa-clock-o', ++// title: gettext('IO delay'), ++// valueField: 'wait', ++// rowspan: 2, ++// }, ++// { ++// itemId: 'load', ++// iconCls: 'fa fa-fw fa-tasks', ++// title: gettext('Load average'), ++// printBar: false, ++// textField: 'loadavg', ++// }, ++// { ++// xtype: 'box', ++// colspan: 2, ++// padding: '0 0 20 0', ++// }, ++// { ++// iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', ++// itemId: 'memory', ++// title: gettext('RAM usage'), ++// valueField: 'memory', ++// maxField: 'memory', ++// warningThreshold: 0.9, ++// criticalThreshold: 0.975, ++// // TODO: split out ARC usage ++// renderer: Proxmox.Utils.render_node_size_usage, ++// }, ++// { ++// itemId: 'ksm', ++// printBar: false, ++// title: gettext('KSM sharing'), ++// textField: 'ksm', ++// renderer: (record) => Proxmox.Utils.render_size(record.shared), ++// padding: '0 10 10 10', ++// }, ++// { ++// iconCls: 'fa fa-fw fa-hdd-o', ++// itemId: 'rootfs', ++// title: '/ ' + gettext('HD space'), ++// valueField: 'rootfs', ++// maxField: 'rootfs', ++// renderer: Proxmox.Utils.render_node_size_usage, ++// }, ++// { ++// iconCls: 'fa fa-fw fa-refresh', ++// itemId: 'swap', ++// printSize: true, ++// title: gettext('SWAP usage'), ++// valueField: 'swap', ++// maxField: 'swap', ++// renderer: Proxmox.Utils.render_node_size_usage, ++// }, ++// { ++// xtype: 'box', ++// colspan: 2, ++// padding: '0 0 20 0', ++// }, ++// { ++// itemId: 'cpus', ++// colspan: 2, ++// printBar: false, ++// title: gettext('CPU(s)'), ++// textField: 'cpuinfo', ++// renderer: Proxmox.Utils.render_cpu_model, ++// value: '', ++// }, ++// { ++// colspan: 2, ++// title: gettext('Kernel Version'), ++// printBar: false, ++// // TODO: remove with next major and only use newish current-kernel textfield ++// multiField: true, ++// //textField: 'current-kernel', ++// renderer: ({ data }) => { ++// if (!data['current-kernel']) { ++// return data.kversion; ++// } ++// let kernel = data['current-kernel']; ++// let buildDate = kernel.version.match(/\((.+)\)\s*$/)?.[1] ?? 'unknown'; ++// return `${kernel.sysname} ${kernel.release} (${buildDate})`; ++// }, ++// value: '', ++// }, ++// { ++// colspan: 2, ++// title: gettext('Boot Mode'), ++// printBar: false, ++// textField: 'boot-info', ++// renderer: (boot) => { ++// if (boot.mode === 'legacy-bios') { ++// return 'Legacy BIOS'; ++// } else if (boot.mode === 'efi') { ++// return `EFI${boot.secureboot ? ' (Secure Boot)' : ''}`; ++// } ++// return Proxmox.Utils.unknownText; ++// }, ++// value: '', ++// }, ++// { ++// itemId: 'version', ++// colspan: 2, ++// printBar: false, ++// title: gettext('Manager Version'), ++// textField: 'pveversion', ++// value: '', ++// }, ++// ], ++// ++// updateTitle: function () { ++// var me = this; ++// var uptime = Proxmox.Utils.render_uptime(me.getRecordValue('uptime')); ++// me.setTitle(me.pveSelNode.data.node + ' (' + gettext('Uptime') + ': ' + uptime + ')'); ++// }, ++// ++// initComponent: function () { ++// let me = this; ++// ++// let stateProvider = Ext.state.Manager.getProvider(); ++// let repoLink = stateProvider.encodeHToken({ ++// view: 'server', ++// rid: `node/${me.pveSelNode.data.node}`, ++// ltab: 'tasks', ++// nodetab: 'aptrepositories', ++// }); ++// ++// me.items.push({ ++// xtype: 'pmxNodeInfoRepoStatus', ++// itemId: 'repositoryStatus', ++// product: 'Proxmox VE', ++// repoLink: `#${repoLink}`, ++// }); ++// ++// me.callParent(); ++// }, ++// }); + Ext.define('PVE.node.SubscriptionKeyEdit', { + extend: 'Proxmox.window.Edit', + +@@ -51112,333 +51118,333 @@ + me.callParent(); + }, + }); +-Ext.define('PVE.node.Summary', { +- extend: 'Ext.panel.Panel', +- alias: 'widget.pveNodeSummary', +- +- scrollable: true, +- bodyPadding: 5, +- +- showVersions: function () { +- var me = this; +- +- // Note: we use simply text/html here, because ExtJS grid has problems +- // with cut&paste +- +- var nodename = me.pveSelNode.data.node; +- +- var view = Ext.createWidget('component', { +- autoScroll: true, +- id: 'pkgversions', +- padding: 5, +- style: { +- 'white-space': 'pre', +- 'font-family': 'monospace', +- }, +- }); +- +- var win = Ext.create('Ext.window.Window', { +- title: gettext('Package versions'), +- width: 600, +- height: 600, +- layout: 'fit', +- modal: true, +- items: [view], +- buttons: [ +- { +- xtype: 'button', +- iconCls: 'fa fa-clipboard', +- handler: function (button) { +- window +- .getSelection() +- .selectAllChildren(document.getElementById('pkgversions')); +- document.execCommand('copy'); +- }, +- text: gettext('Copy'), +- }, +- { +- text: gettext('Ok'), +- handler: function () { +- this.up('window').close(); +- }, +- }, +- ], +- }); +- +- Proxmox.Utils.API2Request({ +- waitMsgTarget: me, +- url: `/nodes/${nodename}/apt/versions`, +- method: 'GET', +- failure: function (response, opts) { +- win.close(); +- Ext.Msg.alert(gettext('Error'), response.htmlStatus); +- }, +- success: function (response, opts) { +- win.show(); +- let text = ''; +- Ext.Array.each(response.result.data, function (rec) { +- let version = 'not correctly installed'; +- let pkg = rec.Package; +- if (rec.OldVersion && rec.CurrentState === 'Installed') { +- version = rec.OldVersion; +- } +- if (rec.RunningKernel) { +- text += `${pkg}: ${version} (running kernel: ${rec.RunningKernel})\n`; +- } else if (rec.ManagerVersion) { +- text += `${pkg}: ${version} (running version: ${rec.ManagerVersion})\n`; +- } else { +- text += `${pkg}: ${version}\n`; +- } +- }); +- +- view.update(Ext.htmlEncode(text)); +- }, +- }); +- }, +- +- updateRepositoryStatus: function () { +- let me = this; +- let repoStatus = me.nodeStatus.down('#repositoryStatus'); +- +- let nodename = me.pveSelNode.data.node; +- +- Proxmox.Utils.API2Request({ +- url: `/nodes/${nodename}/apt/repositories`, +- method: 'GET', +- failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), +- success: (response) => +- repoStatus.setRepositoryInfo(response.result.data['standard-repos']), +- }); +- +- Proxmox.Utils.API2Request({ +- url: `/nodes/${nodename}/subscription`, +- method: 'GET', +- failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), +- success: function (response, opts) { +- const res = response.result; +- const subscription = res?.data?.status.toLowerCase() === 'active'; +- repoStatus.setSubscriptionStatus(subscription); +- }, +- }); +- }, +- +- initComponent: function () { +- var me = this; +- +- var nodename = me.pveSelNode.data.node; +- if (!nodename) { +- throw 'no node name specified'; +- } +- +- if (!me.statusStore) { +- throw 'no status storage specified'; +- } +- +- var rstore = me.statusStore; +- +- var version_btn = new Ext.Button({ +- text: gettext('Package versions'), +- handler: function () { +- Proxmox.Utils.checked_command(function () { +- me.showVersions(); +- }); +- }, +- }); +- +- var rrdstore = Ext.create('Proxmox.data.RRDStore', { +- rrdurl: '/api2/json/nodes/' + nodename + '/rrddata', +- model: 'pve-rrd-node', +- }); +- +- let nodeStatus = Ext.create('PVE.node.StatusView', { +- xtype: 'pveNodeStatus', +- rstore: rstore, +- width: 770, +- pveSelNode: me.pveSelNode, +- }); +- +- Ext.apply(me, { +- tbar: [version_btn, '->', { xtype: 'proxmoxRRDTypeSelector' }], +- nodeStatus: nodeStatus, +- items: [ +- { +- xtype: 'container', +- itemId: 'itemcontainer', +- layout: 'column', +- minWidth: 700, +- defaults: { +- minHeight: 360, +- padding: 5, +- columnWidth: 1, +- }, +- items: [ +- nodeStatus, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('CPU Usage'), +- fields: ['cpu', 'iowait'], +- fieldTitles: [gettext('CPU usage'), gettext('IO delay')], +- unit: 'percent', +- store: rrdstore, +- }, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('Server Load'), +- fields: ['loadavg'], +- fieldTitles: [gettext('Load average')], +- store: rrdstore, +- }, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('Memory usage'), +- fields: [ +- { +- yField: 'memtotal', +- title: gettext('Total'), +- tooltip: { +- trackMouse: true, +- renderer: function (toolTip, record, item) { +- let value = record.get('memtotal'); +- +- if (value === null) { +- toolTip.setHtml(gettext('No Data')); +- } else { +- let total = Proxmox.Utils.format_size(value); +- let time = new Date(record.get('time')); +- +- let avail = record.get('memavailable'); +- let availText = ''; +- if (Ext.isNumeric(avail)) { +- let v = Proxmox.Utils.format_size(avail); +- availText = ` (${gettext('Available')}: ${v})`; +- } +- +- toolTip.setHtml( +- `${gettext('Total')}: ${total}${availText}
${time}`, +- ); +- } +- }, +- }, +- }, +- { +- yField: 'memused', +- title: gettext('Used'), +- tooltip: { +- trackMouse: true, +- renderer: function (toolTip, record, item) { +- let value = record.get('memused'); +- +- if (value === null) { +- toolTip.setHtml(gettext('No Data')); +- } else { +- let total = Proxmox.Utils.format_size(value); +- let time = new Date(record.get('time')); +- +- let arc = record.get('arcsize'); +- let arcText = ''; +- if (Ext.isNumeric(arc) && arc > 1024 * 1024) { +- let v = Proxmox.Utils.format_size(value - arc); +- arcText = ` (${gettext('Without ZFS ARC')}: ${v})`; +- } +- +- toolTip.setHtml( +- `${gettext('Used')}: ${total}${arcText}
${time}`, +- ); +- } +- }, +- }, +- }, +- 'arcsize', +- { +- type: 'line', +- fill: false, +- yField: 'memavailable', +- title: gettext('Available'), +- style: { +- lineWidth: 2.5, +- opacity: 1, +- }, +- }, +- ], +- fieldTitles: [ +- gettext('Total'), +- gettext('Used'), +- gettext('ZFS ARC'), +- gettext('Available'), +- ], +- colors: ['#94ae0a', '#115fa6', '#24AD9A', '#bbde0d'], +- unit: 'bytes', +- powerOfTwo: true, +- store: rrdstore, +- }, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('Network Traffic'), +- fields: ['netin', 'netout'], +- fieldTitles: [gettext('Incoming'), gettext('Outgoing')], +- store: rrdstore, +- }, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('CPU Pressure Stall'), +- fieldTitles: ['Some'], +- fields: ['pressurecpusome'], +- colors: ['#FFD13E', '#A61120'], +- store: rrdstore, +- unit: 'percent', +- }, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('IO Pressure Stall'), +- fieldTitles: ['Some', 'Full'], +- fields: ['pressureiosome', 'pressureiofull'], +- colors: ['#FFD13E', '#A61120'], +- store: rrdstore, +- unit: 'percent', +- }, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('Memory Pressure Stall'), +- fieldTitles: ['Some', 'Full'], +- fields: ['pressurememorysome', 'pressurememoryfull'], +- colors: ['#FFD13E', '#A61120'], +- store: rrdstore, +- unit: 'percent', +- }, +- ], +- listeners: { +- resize: function (panel) { +- Proxmox.Utils.updateColumns(panel); +- }, +- }, +- }, +- ], +- listeners: { +- activate: function () { +- rstore.setInterval(1000); +- rstore.startUpdate(); // just to be sure +- rrdstore.startUpdate(); +- }, +- destroy: function () { +- rstore.setInterval(5000); // don't stop it, it's not ours! +- rrdstore.stopUpdate(); +- }, +- }, +- }); +- +- me.updateRepositoryStatus(); +- +- me.callParent(); +- +- let sp = Ext.state.Manager.getProvider(); +- me.mon(sp, 'statechange', function (provider, key, value) { +- if (key !== 'summarycolumns') { +- return; +- } +- Proxmox.Utils.updateColumns(me.getComponent('itemcontainer')); +- }); +- }, +-}); ++// Ext.define('PVE.node.Summary', { ++// extend: 'Ext.panel.Panel', ++// alias: 'widget.pveNodeSummary', ++// ++// scrollable: true, ++// bodyPadding: 5, ++// ++// showVersions: function () { ++// var me = this; ++// ++// // Note: we use simply text/html here, because ExtJS grid has problems ++// // with cut&paste ++// ++// var nodename = me.pveSelNode.data.node; ++// ++// var view = Ext.createWidget('component', { ++// autoScroll: true, ++// id: 'pkgversions', ++// padding: 5, ++// style: { ++// 'white-space': 'pre', ++// 'font-family': 'monospace', ++// }, ++// }); ++// ++// var win = Ext.create('Ext.window.Window', { ++// title: gettext('Package versions'), ++// width: 600, ++// height: 600, ++// layout: 'fit', ++// modal: true, ++// items: [view], ++// buttons: [ ++// { ++// xtype: 'button', ++// iconCls: 'fa fa-clipboard', ++// handler: function (button) { ++// window ++// .getSelection() ++// .selectAllChildren(document.getElementById('pkgversions')); ++// document.execCommand('copy'); ++// }, ++// text: gettext('Copy'), ++// }, ++// { ++// text: gettext('Ok'), ++// handler: function () { ++// this.up('window').close(); ++// }, ++// }, ++// ], ++// }); ++// ++// Proxmox.Utils.API2Request({ ++// waitMsgTarget: me, ++// url: `/nodes/${nodename}/apt/versions`, ++// method: 'GET', ++// failure: function (response, opts) { ++// win.close(); ++// Ext.Msg.alert(gettext('Error'), response.htmlStatus); ++// }, ++// success: function (response, opts) { ++// win.show(); ++// let text = ''; ++// Ext.Array.each(response.result.data, function (rec) { ++// let version = 'not correctly installed'; ++// let pkg = rec.Package; ++// if (rec.OldVersion && rec.CurrentState === 'Installed') { ++// version = rec.OldVersion; ++// } ++// if (rec.RunningKernel) { ++// text += `${pkg}: ${version} (running kernel: ${rec.RunningKernel})\n`; ++// } else if (rec.ManagerVersion) { ++// text += `${pkg}: ${version} (running version: ${rec.ManagerVersion})\n`; ++// } else { ++// text += `${pkg}: ${version}\n`; ++// } ++// }); ++// ++// view.update(Ext.htmlEncode(text)); ++// }, ++// }); ++// }, ++// ++// updateRepositoryStatus: function () { ++// let me = this; ++// let repoStatus = me.nodeStatus.down('#repositoryStatus'); ++// ++// let nodename = me.pveSelNode.data.node; ++// ++// Proxmox.Utils.API2Request({ ++// url: `/nodes/${nodename}/apt/repositories`, ++// method: 'GET', ++// failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), ++// success: (response) => ++// repoStatus.setRepositoryInfo(response.result.data['standard-repos']), ++// }); ++// ++// Proxmox.Utils.API2Request({ ++// url: `/nodes/${nodename}/subscription`, ++// method: 'GET', ++// failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), ++// success: function (response, opts) { ++// const res = response.result; ++// const subscription = res?.data?.status.toLowerCase() === 'active'; ++// repoStatus.setSubscriptionStatus(subscription); ++// }, ++// }); ++// }, ++// ++// initComponent: function () { ++// var me = this; ++// ++// var nodename = me.pveSelNode.data.node; ++// if (!nodename) { ++// throw 'no node name specified'; ++// } ++// ++// if (!me.statusStore) { ++// throw 'no status storage specified'; ++// } ++// ++// var rstore = me.statusStore; ++// ++// var version_btn = new Ext.Button({ ++// text: gettext('Package versions'), ++// handler: function () { ++// Proxmox.Utils.checked_command(function () { ++// me.showVersions(); ++// }); ++// }, ++// }); ++// ++// var rrdstore = Ext.create('Proxmox.data.RRDStore', { ++// rrdurl: '/api2/json/nodes/' + nodename + '/rrddata', ++// model: 'pve-rrd-node', ++// }); ++// ++// let nodeStatus = Ext.create('PVE.node.StatusView', { ++// xtype: 'pveNodeStatus', ++// rstore: rstore, ++// width: 770, ++// pveSelNode: me.pveSelNode, ++// }); ++// ++// Ext.apply(me, { ++// tbar: [version_btn, '->', { xtype: 'proxmoxRRDTypeSelector' }], ++// nodeStatus: nodeStatus, ++// items: [ ++// { ++// xtype: 'container', ++// itemId: 'itemcontainer', ++// layout: 'column', ++// minWidth: 700, ++// defaults: { ++// minHeight: 360, ++// padding: 5, ++// columnWidth: 1, ++// }, ++// items: [ ++// nodeStatus, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('CPU Usage'), ++// fields: ['cpu', 'iowait'], ++// fieldTitles: [gettext('CPU usage'), gettext('IO delay')], ++// unit: 'percent', ++// store: rrdstore, ++// }, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('Server Load'), ++// fields: ['loadavg'], ++// fieldTitles: [gettext('Load average')], ++// store: rrdstore, ++// }, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('Memory usage'), ++// fields: [ ++// { ++// yField: 'memtotal', ++// title: gettext('Total'), ++// tooltip: { ++// trackMouse: true, ++// renderer: function (toolTip, record, item) { ++// let value = record.get('memtotal'); ++// ++// if (value === null) { ++// toolTip.setHtml(gettext('No Data')); ++// } else { ++// let total = Proxmox.Utils.format_size(value); ++// let time = new Date(record.get('time')); ++// ++// let avail = record.get('memavailable'); ++// let availText = ''; ++// if (Ext.isNumeric(avail)) { ++// let v = Proxmox.Utils.format_size(avail); ++// availText = ` (${gettext('Available')}: ${v})`; ++// } ++// ++// toolTip.setHtml( ++// `${gettext('Total')}: ${total}${availText}
${time}`, ++// ); ++// } ++// }, ++// }, ++// }, ++// { ++// yField: 'memused', ++// title: gettext('Used'), ++// tooltip: { ++// trackMouse: true, ++// renderer: function (toolTip, record, item) { ++// let value = record.get('memused'); ++// ++// if (value === null) { ++// toolTip.setHtml(gettext('No Data')); ++// } else { ++// let total = Proxmox.Utils.format_size(value); ++// let time = new Date(record.get('time')); ++// ++// let arc = record.get('arcsize'); ++// let arcText = ''; ++// if (Ext.isNumeric(arc) && arc > 1024 * 1024) { ++// let v = Proxmox.Utils.format_size(value - arc); ++// arcText = ` (${gettext('Without ZFS ARC')}: ${v})`; ++// } ++// ++// toolTip.setHtml( ++// `${gettext('Used')}: ${total}${arcText}
${time}`, ++// ); ++// } ++// }, ++// }, ++// }, ++// 'arcsize', ++// { ++// type: 'line', ++// fill: false, ++// yField: 'memavailable', ++// title: gettext('Available'), ++// style: { ++// lineWidth: 2.5, ++// opacity: 1, ++// }, ++// }, ++// ], ++// fieldTitles: [ ++// gettext('Total'), ++// gettext('Used'), ++// gettext('ZFS ARC'), ++// gettext('Available'), ++// ], ++// colors: ['#94ae0a', '#115fa6', '#24AD9A', '#bbde0d'], ++// unit: 'bytes', ++// powerOfTwo: true, ++// store: rrdstore, ++// }, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('Network Traffic'), ++// fields: ['netin', 'netout'], ++// fieldTitles: [gettext('Incoming'), gettext('Outgoing')], ++// store: rrdstore, ++// }, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('CPU Pressure Stall'), ++// fieldTitles: ['Some'], ++// fields: ['pressurecpusome'], ++// colors: ['#FFD13E', '#A61120'], ++// store: rrdstore, ++// unit: 'percent', ++// }, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('IO Pressure Stall'), ++// fieldTitles: ['Some', 'Full'], ++// fields: ['pressureiosome', 'pressureiofull'], ++// colors: ['#FFD13E', '#A61120'], ++// store: rrdstore, ++// unit: 'percent', ++// }, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('Memory Pressure Stall'), ++// fieldTitles: ['Some', 'Full'], ++// fields: ['pressurememorysome', 'pressurememoryfull'], ++// colors: ['#FFD13E', '#A61120'], ++// store: rrdstore, ++// unit: 'percent', ++// }, ++// ], ++// listeners: { ++// resize: function (panel) { ++// Proxmox.Utils.updateColumns(panel); ++// }, ++// }, ++// }, ++// ], ++// listeners: { ++// activate: function () { ++// rstore.setInterval(1000); ++// rstore.startUpdate(); // just to be sure ++// rrdstore.startUpdate(); ++// }, ++// destroy: function () { ++// rstore.setInterval(5000); // don't stop it, it's not ours! ++// rrdstore.stopUpdate(); ++// }, ++// }, ++// }); ++// ++// me.updateRepositoryStatus(); ++// ++// me.callParent(); ++// ++// let sp = Ext.state.Manager.getProvider(); ++// me.mon(sp, 'statechange', function (provider, key, value) { ++// if (key !== 'summarycolumns') { ++// return; ++// } ++// Proxmox.Utils.updateColumns(me.getComponent('itemcontainer')); ++// }); ++// }, ++// }); + Ext.define('PVE.node.CreateZFS', { + extend: 'Proxmox.window.Edit', + xtype: 'pveCreateZFS', diff --git a/src/PVENodeInfo/patches/patches.list b/src/PVENodeInfo/patches/patches.list new file mode 100644 index 0000000..9049189 --- /dev/null +++ b/src/PVENodeInfo/patches/patches.list @@ -0,0 +1,9 @@ +# pve-mod :: node_info patch manifest +# Format: [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 diff --git a/src/PVENodeInfo/patches/post-apply.sh b/src/PVENodeInfo/patches/post-apply.sh new file mode 100644 index 0000000..62b3b5e --- /dev/null +++ b/src/PVENodeInfo/patches/post-apply.sh @@ -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 diff --git a/src/PVENodeInfo/revert-patches.sh b/src/PVENodeInfo/revert-patches.sh deleted file mode 100644 index d86097a..0000000 --- a/src/PVENodeInfo/revert-patches.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash -# /usr/lib/pve-mod/revert-patches.sh -# -# Reverts all patches applied by apply-patches.sh. -# Restores PVE system files from backups in /var/lib/pve-mod/backup/. -# Called by prerm before package files are removed. - -BACKUP_DIR="/var/lib/pve-mod/backup" -NODES_PM="/usr/share/perl5/PVE/API2/Nodes.pm" -PVE_MANAGER_JS="/usr/share/pve-manager/js/pvemanagerlib.js" -PVE_MOD_JS="/usr/share/pve-manager/js/PveMod_PveNodeStatusView.js" -PROXMOXLIB_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js" -PROXMOXLIB_MIN_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js" - -info() { echo "[pve-mod] $*"; } -warn() { echo "[pve-mod] WARNING: $*" >&2; } - -restore_latest() { - local name="$1" target="$2" - local latest - latest=$(find "$BACKUP_DIR" -name "${name}.*" -type f -printf '%T+ %p\n' 2>/dev/null \ - | sort -r | head -n1 | awk '{print $2}') - if [[ -n "$latest" ]]; then - cp "$latest" "$target" - info "Restored $(basename "$target") from backup" - else - warn "No backup found for ${name}; $target not restored" - fi -} - -CHANGED=false - -# ── node-info: Nodes.pm ─────────────────────────────────────────────────────── -if grep -qF "use PVE::API2::PVEMod_SensorInfo" "$NODES_PM" 2>/dev/null; then - restore_latest "Nodes.pm" "$NODES_PM" - CHANGED=true -fi - -# ── node-info: pvemanagerlib.js ─────────────────────────────────────────────── -if grep -qF "PveMod_PveNodeStatusView.js" "$PVE_MANAGER_JS" 2>/dev/null; then - restore_latest "pvemanagerlib.js" "$PVE_MANAGER_JS" - CHANGED=true -fi - -# ── node-info: JS module file ───────────────────────────────────────────────── -if [[ -f "$PVE_MOD_JS" ]]; then - rm -f "$PVE_MOD_JS" - info "Removed PveMod_PveNodeStatusView.js" - CHANGED=true -fi - -# ── nag-screen: proxmoxlib.min.js symlink ──────────────────────────────────── -if [[ -L "$PROXMOXLIB_MIN_JS" ]]; then - rm -f "$PROXMOXLIB_MIN_JS" - restore_latest "proxmoxlib.min.js" "$PROXMOXLIB_MIN_JS" - CHANGED=true -fi - -# ── nag-screen: proxmoxlib.js ──────────────────────────────────────────────── -if grep -qF "// disable subscription nag screen" "$PROXMOXLIB_JS" 2>/dev/null; then - restore_latest "proxmoxlib.js" "$PROXMOXLIB_JS" - CHANGED=true -fi - -# ── restart pveproxy if anything changed ───────────────────────────────────── -if [[ "$CHANGED" == "true" ]]; then - info "Restarting pveproxy..." - systemctl restart pveproxy 2>/dev/null || true -fi diff --git a/src/Scripts/apply-patches.sh b/src/Scripts/apply-patches.sh new file mode 100644 index 0000000..5224ae8 --- /dev/null +++ b/src/Scripts/apply-patches.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +# /usr/lib/pve-mod/apply-patches.sh +# +# Generic patch applier for pve-mod. +# +# Reads /etc/pve-mod/pve-mod.conf [modules] to learn which mods are enabled, +# then applies each enabled mod's patches from /usr/lib/pve-mod/patches//. +# +# Patch convention: every .patch uses a/ b/ headers and is applied +# with `patch -p1 -F0 -d /` (zero fuzz: line offsets tolerated, fuzzy context +# matching disabled). Each mod folder contains a 'patches.list' manifest and +# optional 'post-apply.sh' / 'post-revert.sh' hooks. +# +# Per-mod atomicity: before applying, every active patch of a mod is dry-run. +# If any one cannot be applied (or is already partially/broken-applied), the +# whole mod is reverted to a clean state and reported as failed - a mod is +# never left half-applied. +# +# Idempotent: a patch that is already applied is detected (reverse dry-run) and +# skipped, so this script is safe to run repeatedly (e.g. from the dpkg trigger +# after a pve-manager upgrade). +# +# Hook exit-code convention: a hook returns 0 (no change), 100 (made a change, +# triggers a pveproxy restart), or any other code (error). + +set -u + +# Root paths (overridable via environment, mainly for testing). +PVE_MOD_ROOT="${PVE_MOD_ROOT:-/}" +MAIN_CONF="${PVE_MOD_MAIN_CONF:-/etc/pve-mod/pve-mod.conf}" +CONFD_DIR="${PVE_MOD_CONFD_DIR:-/etc/pve-mod/conf.d}" +PATCHES_DIR="${PVE_MOD_PATCHES_DIR:-/usr/lib/pve-mod/patches}" +# Storage for non-patch replaced files (e.g. nag-screen's minified proxmoxlib). +# Patched text files need no backups - `patch -R` reverts them. +STASH_DIR="${PVE_MOD_STASH_DIR:-/var/lib/pve-mod/backup}" + +info() { echo "[pve-mod] $*"; } +warn() { echo "[pve-mod] WARNING: $*" >&2; } + +# read_conf
[default] +# Prints one value from an INI file, or the default if absent. +read_conf() { + local file="$1" section="$2" key="$3" default="${4:-0}" + if [[ ! -f "$file" ]]; then + echo "$default" + return + fi + 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}" +} + +# List the keys of the [modules] section in the main config, one per line. +list_modules() { + [[ -f "$MAIN_CONF" ]] || return 0 + awk -F= ' + /^\[/ { in_sec = ($0 == "[modules]") } + in_sec && /^[^#=]+=/ { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1) + print $1 + } + ' "$MAIN_CONF" +} + +# Patch-state helpers (all use zero fuzz for deterministic detection). +_dry_forward() { patch -p1 -F0 -d "$PVE_MOD_ROOT" -f --dry-run -s < "$1" >/dev/null 2>&1; } +_dry_reverse() { patch -R -p1 -F0 -d "$PVE_MOD_ROOT" -f --dry-run -s < "$1" >/dev/null 2>&1; } +is_applied() { _dry_reverse "$1"; } +can_apply() { _dry_forward "$1"; } +do_apply() { patch -p1 -F0 -d "$PVE_MOD_ROOT" -f -s < "$1"; } +do_revert() { patch -R -p1 -F0 -d "$PVE_MOD_ROOT" -f -s < "$1"; } + +CHANGED=false +FAILED=false + +# Run a mod hook honouring the exit-code convention. Sets CHANGED / FAILED. +run_hook() { + local hook="$1" mod_conf="$2" + [[ -x "$hook" ]] || return 0 + STASH_DIR="$STASH_DIR" MOD_CONF="$mod_conf" CONFD_DIR="$CONFD_DIR" "$hook" + local rc=$? + case "$rc" in + 0) ;; + 100) CHANGED=true ;; + *) warn " hook $(basename "$hook") reported an error (exit $rc)"; FAILED=true; return 1 ;; + esac + return 0 +} + +# ── main ────────────────────────────────────────────────────────────────────── +if ! command -v patch >/dev/null 2>&1; then + warn "'patch' command not found; cannot apply mods. Install the 'patch' package." + exit 1 +fi + +for mod in $(list_modules); do + [[ "$(read_conf "$MAIN_CONF" modules "$mod" 0)" == "1" ]] || continue + + mod_dir="$PATCHES_DIR/$mod" + manifest="$mod_dir/patches.list" + mod_conf="$CONFD_DIR/$mod.conf" + + if [[ ! -f "$manifest" ]]; then + warn "No patch manifest for enabled mod '$mod' ($manifest); skipping." + FAILED=true + continue + fi + + info "Checking mod: $mod" + + # Build the list of active patch files (those whose condition is met). + active=() + preflight_ok=true + while IFS= read -r line; do + # Strip comments and surrounding whitespace; skip blanks. + line="${line%%#*}" + line="$(echo "$line" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')" + [[ -z "$line" ]] && continue + + # Format: [section.key=value] + patch_name="${line%%[[:space:]]*}" + condition="" + if [[ "$line" == *[[:space:]]* ]]; then + condition="$(echo "${line#"$patch_name"}" | sed -E 's/^[[:space:]]+//')" + fi + + # Evaluate optional condition against the mod's conf.d file. + if [[ -n "$condition" ]]; then + local_key="${condition%%=*}" + want="${condition#*=}" + sect="${local_key%%.*}" + ckey="${local_key#*.}" + if [[ "$(read_conf "$mod_conf" "$sect" "$ckey" 0)" != "$want" ]]; then + info " skip $patch_name (condition $condition not met)" + continue + fi + fi + + patch_file="$mod_dir/$patch_name" + if [[ ! -f "$patch_file" ]]; then + warn " patch file missing: $patch_file" + preflight_ok=false + continue + fi + active+=("$patch_file") + done < "$manifest" + + # Preflight dry-run: every active patch must be already applied or cleanly + # appliable. Otherwise the mod cannot be installed atomically. + to_apply=() + if [[ "$preflight_ok" == "true" ]]; then + for pf in "${active[@]}"; do + if is_applied "$pf"; then + continue + elif can_apply "$pf"; then + to_apply+=("$pf") + else + warn " $(basename "$pf") does not apply cleanly" + preflight_ok=false + break + fi + done + fi + + # If preflight failed, revert the whole mod back to a clean state so it is + # never left half-applied. + if [[ "$preflight_ok" != "true" ]]; then + warn "Mod '$mod': preflight failed - reverting mod to clean state." + for (( i=${#active[@]}-1 ; i>=0 ; i-- )); do + pf="${active[$i]}" + if is_applied "$pf"; then + do_revert "$pf" && { info " reverted $(basename "$pf")"; CHANGED=true; } + fi + done + run_hook "$mod_dir/post-revert.sh" "$mod_conf" + FAILED=true + continue + fi + + # Apply the outstanding patches. + if [[ ${#to_apply[@]} -gt 0 ]]; then + for pf in "${to_apply[@]}"; do + do_apply "$pf" && { info " applied $(basename "$pf")"; CHANGED=true; } + done + else + info " already up to date" + fi + + # Run the post-apply hook. If it errors, roll the mod back so it is never + # left half-applied (matches the preflight atomicity contract). + if ! run_hook "$mod_dir/post-apply.sh" "$mod_conf"; then + warn "Mod '$mod': post-apply hook failed - reverting mod to clean state." + run_hook "$mod_dir/post-revert.sh" "$mod_conf" + for (( i=${#active[@]}-1 ; i>=0 ; i-- )); do + pf="${active[$i]}" + if is_applied "$pf"; then + do_revert "$pf" && { info " reverted $(basename "$pf")"; CHANGED=true; } + fi + done + FAILED=true + continue + fi +done + +if [[ "$CHANGED" == "true" ]]; then + info "Restarting pveproxy..." + systemctl restart pveproxy 2>/dev/null || true +fi + +[[ "$FAILED" == "true" ]] && exit 1 +exit 0 diff --git a/src/Scripts/pve-mod-configure b/src/Scripts/pve-mod-configure index 584ad51..9001143 100644 --- a/src/Scripts/pve-mod-configure +++ b/src/Scripts/pve-mod-configure @@ -7,6 +7,9 @@ set -euo pipefail CONF_FILE="/etc/pve-mod/pve-mod.conf" +CONFD_DIR="/etc/pve-mod/conf.d" +NODE_INFO_CONF="${CONFD_DIR}/node_info.conf" +NAG_SCREEN_CONF="${CONFD_DIR}/nag_screen.conf" APPLY_PATCHES="/usr/lib/pve-mod/apply-patches.sh" NODES_PM="/usr/share/perl5/PVE/API2/Nodes.pm" @@ -24,11 +27,9 @@ ask() { } bool() { [[ "$1" == true ]] && echo 1 || echo 0; } -# Loads every value from an existing config file into the wizard's variables, -# so that re-running the tool preserves settings the user does not change. -_load_conf() { - [[ -f "$CONF_FILE" ]] || return 0 - local section="" line key val +_load_conf_debug() { + [[ -f "$NODE_INFO_CONF" ]] || return 0 + local in_debug=0 line key val while IFS= read -r line; do case "$line" in '#'*|'') continue ;; @@ -78,7 +79,7 @@ _load_conf() { debug.log_enabled) DEBUG_LOG="$val" ;; debug.log_file) DEBUG_LOG_FILE="$val" ;; esac - done < "$CONF_FILE" + done < "$NODE_INFO_CONF" } #endregion helpers @@ -390,15 +391,33 @@ configure_node_info() { #region write config write_config() { - mkdir -p "$(dirname "$CONF_FILE")" + mkdir -p "$(dirname "$CONF_FILE")" "$CONFD_DIR" + + # Main config: which mods are enabled + global trigger/service settings. cat > "$CONF_FILE" <.conf [modules] node_info=${MOD_NODE_INFO} nag_screen=${MOD_NAG_SCREEN} +[pve_trigger] +enabled=${PVE_TRIGGER_ENABLED} + +[service] +mode=embedded +EOF + info "Main configuration saved to $CONF_FILE" + + # node_info mod config. + cat > "$NODE_INFO_CONF" < "$NAG_SCREEN_CONF" </ (regardless +# of whether the mod is currently enabled), using `patch -R -p1 -F0 -d /`. +# Patches are reverted in reverse manifest order. Patched text files need no +# backups - `patch -R` restores them exactly. Non-patch actions (e.g. the +# nag-screen min.js symlink) are undone by each mod's 'post-revert.sh' hook. +# +# Called by prerm before the package's files are removed. +# +# Hook exit-code convention: a hook returns 0 (no change), 100 (made a change, +# triggers a pveproxy restart), or any other code (error). + +set -u + +# Root paths (overridable via environment, mainly for testing). +PVE_MOD_ROOT="${PVE_MOD_ROOT:-/}" +CONFD_DIR="${PVE_MOD_CONFD_DIR:-/etc/pve-mod/conf.d}" +PATCHES_DIR="${PVE_MOD_PATCHES_DIR:-/usr/lib/pve-mod/patches}" +# Storage for non-patch replaced files (e.g. nag-screen's minified proxmoxlib). +STASH_DIR="${PVE_MOD_STASH_DIR:-/var/lib/pve-mod/backup}" + +info() { echo "[pve-mod] $*"; } +warn() { echo "[pve-mod] WARNING: $*" >&2; } + +# Revert a single patch. Returns 0 if a change was made, 1 otherwise. +revert_one_patch() { + local patch="$1" + # Not applied? (a clean forward apply means the change is absent) + if patch -p1 -F0 -d "$PVE_MOD_ROOT" -f --dry-run -s < "$patch" >/dev/null 2>&1; then + return 1 + fi + if patch -R -p1 -F0 -d "$PVE_MOD_ROOT" -f --dry-run -s < "$patch" >/dev/null 2>&1; then + patch -R -p1 -F0 -d "$PVE_MOD_ROOT" -f -s < "$patch" + return 0 + fi + warn " $(basename "$patch") could not be reverted cleanly; manual cleanup may be needed" + return 1 +} + +# ── main ────────────────────────────────────────────────────────────────────── +if ! command -v patch >/dev/null 2>&1; then + warn "'patch' command not found; cannot revert mods." + exit 0 +fi + +[[ -d "$PATCHES_DIR" ]] || exit 0 + +CHANGED=false + +for mod_dir in "$PATCHES_DIR"/*/; do + [[ -d "$mod_dir" ]] || continue + mod="$(basename "$mod_dir")" + manifest="$mod_dir/patches.list" + mod_conf="$CONFD_DIR/$mod.conf" + + # Run the post-revert hook first (undo non-patch actions such as symlinks). + hook="$mod_dir/post-revert.sh" + if [[ -x "$hook" ]]; then + info "Running post-revert hook for $mod" + STASH_DIR="$STASH_DIR" MOD_CONF="$mod_conf" CONFD_DIR="$CONFD_DIR" "$hook" + rc=$? + case "$rc" in + 0) ;; + 100) CHANGED=true ;; + *) warn "post-revert hook for $mod reported an error (exit $rc)" ;; + esac + fi + + [[ -f "$manifest" ]] || continue + info "Reverting mod: $mod" + + # Collect patch names (ignore conditions and comments), then reverse order. + mapfile -t patches < <( + while IFS= read -r line; do + line="${line%%#*}" + line="$(echo "$line" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')" + [[ -z "$line" ]] && continue + echo "${line%%[[:space:]]*}" + done < "$manifest" + ) + + for (( i=${#patches[@]}-1 ; i>=0 ; i-- )); do + patch_name="${patches[$i]}" + patch_file="$mod_dir/$patch_name" + [[ -f "$patch_file" ]] || continue + if revert_one_patch "$patch_file"; then + info " reverted $patch_name" + CHANGED=true + fi + done +done + +if [[ "$CHANGED" == "true" ]]; then + info "Restarting pveproxy..." + systemctl restart pveproxy 2>/dev/null || true +fi +exit 0 diff --git a/src/pve-mod.conf b/src/pve-mod.conf index 40c3022..2757128 100644 --- a/src/pve-mod.conf +++ b/src/pve-mod.conf @@ -1,55 +1,19 @@ -# pve-mod configuration file +# pve-mod main configuration file # Run 'pve-mod-configure' to set values interactively. -# All flags are 0 (disabled) by default; pve-mod-configure enables them. +# +# This file only declares which mods are enabled. Each mod keeps its own +# settings in /etc/pve-mod/conf.d/.conf +# +# When a mod flag below is 1, its patches are (re)applied on install and, +# if [pve_trigger] enabled=1, after every pve-manager upgrade. [modules] node_info=0 nag_screen=0 -[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 - +# Re-apply patches automatically after a pve-manager upgrade (dpkg trigger). [pve_trigger] enabled=0 [service] mode=embedded - -# Debug mode: when a mode is 1, the real tool is not required. -# Data is read from the file path instead. Useful for development/testing. -[debug] -lm_sensors_mode=0 -lm_sensors_output_file=/tmp/sensors-output.json -intel_mode=0 -intel_devices_file=/tmp/intel-gpu-devices.json -nvidia_mode=0 -nvidia_devices_file=/tmp/nvidia-smi-devices.csv -nvidia_output_file=/tmp/nvidia-smi-output.csv -amd_mode=0 -amd_devices_file=/tmp/amd-gpu-devices.json -ups_mode=0 -ups_output_file=/tmp/ups-output.json -log_enabled=0 -log_file=/tmp/pve-mod-debug.log