From e05ff6f2fbd023a01f8c5f7a66a6b2d376d93fbe Mon Sep 17 00:00:00 2001 From: Meliox <5264368+Meliox@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:34:25 +0200 Subject: [PATCH] change all text files to use LF (#205) * test t * change all files to use LF * g --------- Co-authored-by: Meliox --- .gitattributes | 2 + src/Scripts/pve-mod-configure | 1284 +++---- src/nag_screen/files/files.list | 20 +- src/nag_screen/nag_screen.conf | 6 +- src/nag_screen/patches/patches.list | 10 +- src/node_info/files/Collector/Amd.pm | 66 +- src/node_info/files/Collector/Intel.pm | 358 +- src/node_info/files/Collector/LmSensors.pm | 834 ++--- src/node_info/files/Collector/Nvidia.pm | 424 +-- src/node_info/files/Collector/Ups.pm | 230 +- .../files/Collector/systemInformation.pm | 192 +- src/node_info/files/Config.pm | 302 +- src/node_info/files/ProcessManager.pm | 938 ++--- src/node_info/files/PveMod_SensorInfo.pm | 436 +-- src/node_info/files/PveMod_pvemanagerlib.js | 3066 ++++++++--------- src/node_info/files/Store.pm | 332 +- src/node_info/files/Utils.pm | 548 +-- src/node_info/files/files.list | 54 +- src/node_info/node_info.conf | 90 +- src/node_info/patches/patches.list | 18 +- src/node_info/readme.md | 4 +- src/pve-mod.conf | 38 +- src/test.yml | 2 +- 23 files changed, 4628 insertions(+), 4626 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..feb6da9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# All files must use LF +* text=auto eol=lf diff --git a/src/Scripts/pve-mod-configure b/src/Scripts/pve-mod-configure index 9001143..c10e2fa 100644 --- a/src/Scripts/pve-mod-configure +++ b/src/Scripts/pve-mod-configure @@ -1,642 +1,642 @@ -#!/usr/bin/env bash -# -# pve-mod-configure - Interactive configuration tool for pve-mod -# Detects hardware, asks the user which features to enable, writes -# /etc/pve-mod/pve-mod.conf, and applies patches to PVE system files. - -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" - -KNOWN_CPU_SENSORS=("coretemp-isa-" "k10temp-pci-") - -#region helpers -msgb() { echo -e "\e[1m${1}\e[0m"; } -info() { echo -e "\e[0;32m[info] ${1}\e[0m"; } -warn() { echo -e "\e[0;33m[warning] ${1}\e[0m"; } -err() { echo -e "\e[0;31m[error] ${1}\e[0m"; exit 1; } -ask() { - local prompt="$1" response - read -r -p $'\n\e[1;36m'"${prompt}:"$'\e[0m ' response - echo "$response" -} -bool() { [[ "$1" == true ]] && echo 1 || echo 0; } - -_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 ;; - '['*']') section="${line#[}"; section="${section%]}"; continue ;; - esac - [[ "$line" == *=* ]] || continue - key="${line%%=*}"; val="${line#*=}" - case "${section}.${key}" in - # [modules] - modules.node_info) MOD_NODE_INFO="$val" ;; - modules.nag_screen) MOD_NAG_SCREEN="$val" ;; - # [gpu] - gpu.intel_enabled) ENABLE_INTEL_GPU_INFO="$val" ;; - gpu.nvidia_enabled) ENABLE_NVIDIA_GPU_INFO="$val" ;; - gpu.amd_enabled) ENABLE_AMD_GPU_INFO="$val" ;; - gpu.gpu_history) ENABLE_GPU_HISTORY="$val" ;; - # [lm_sensors] - lm_sensors.enabled) LM_SENSORS_ENABLED="$val" ;; - lm_sensors.enable_cpu) ENABLE_CPU="$val" ;; - lm_sensors.cpu_temp_target) CPU_TEMP_TARGET="$val" ;; - lm_sensors.enable_ram_temp) ENABLE_RAM_TEMP="$val" ;; - lm_sensors.enable_hdd_temp) ENABLE_HDD_TEMP="$val" ;; - lm_sensors.enable_nvme_temp) ENABLE_NVME_TEMP="$val" ;; - lm_sensors.enable_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] - ups.enabled) ENABLE_UPS="$val" ;; - ups.device_name) UPS_DEVICE_NAME="$val" ;; - # [system_info] - system_info.enabled) ENABLE_SYSTEM_INFO="$val" ;; - system_info.type) SYSTEM_INFO_TYPE="$val" ;; - # [pve_trigger] - pve_trigger.enabled) PVE_TRIGGER_ENABLED="$val" ;; - # [debug] - debug.lm_sensors_mode) DEBUG_LM_SENSORS="$val" ;; - debug.lm_sensors_output_file) DEBUG_LM_SENSORS_FILE="$val" ;; - debug.intel_mode) DEBUG_INTEL="$val" ;; - debug.intel_devices_file) DEBUG_INTEL_FILE="$val" ;; - debug.nvidia_mode) DEBUG_NVIDIA="$val" ;; - debug.nvidia_devices_file) DEBUG_NVIDIA_DEVICES_FILE="$val" ;; - debug.nvidia_output_file) DEBUG_NVIDIA_OUTPUT_FILE="$val" ;; - debug.amd_mode) DEBUG_AMD="$val" ;; - debug.amd_devices_file) DEBUG_AMD_FILE="$val" ;; - debug.ups_mode) DEBUG_UPS="$val" ;; - debug.ups_output_file) DEBUG_UPS_FILE="$val" ;; - debug.log_enabled) DEBUG_LOG="$val" ;; - debug.log_file) DEBUG_LOG_FILE="$val" ;; - esac - done < "$NODE_INFO_CONF" -} -#endregion helpers - -sanitize_sensors_output() { - local input="$1" - echo "$input" | perl -0777 -pe ' - s/ERROR:.+\s(\w+):\s(.+)/"$1": 0.000,/g; - s/ERROR:.+\s(\w+)!/"$1": 0.000,/g; - s/,\s*(\})/$1/g; - s/\bNaN\b/null/g; - s/"SODIMM"\s*:\s*\{\s*"temp(\d+)_input"/"SODIMM $1": {\n "temp$1_input"/g; - s/"([^"]*Fan[^"]*)"\s*:\s*\{\s*"fan(\d+)_input"/"$1 $2": {\n "fan$2_input"/g; - ' | python3 -m json.tool 2>/dev/null || echo "$input" -} - -_check_or_install_tool() { - local cmd="$1" pkg="$2" description="$3" - if command -v "$cmd" &>/dev/null; then - info "$description is installed." - return 0 - fi - local choice - choice=$(ask "$description is not installed. Install it now? (y/N)") - case "$choice" in - [yY]) - apt-get update -qq - apt-get install -y "$pkg" - command -v "$cmd" &>/dev/null && { info "$description installed."; return 0; } || \ - { warn "$description installation failed. Section will be skipped."; return 1; } - ;; - *) - info "Skipping $description." - return 1 - ;; - esac -} - -_check_nvidia_tool() { - if command -v nvidia-smi &>/dev/null; then - info "nvidia-smi is installed." - return 0 - fi - warn "nvidia-smi not found. NVIDIA monitoring requires NVIDIA drivers (not installable via apt)." - return 1 -} - -#region node-info wizard -configure_node_info() { - # Initialize all variables to off - LM_SENSORS_ENABLED=0 - ENABLE_CPU=0; CPU_TEMP_TARGET="Core" - ENABLE_RAM_TEMP=0; ENABLE_HDD_TEMP=0; ENABLE_NVME_TEMP=0 - ENABLE_FAN_SPEED=0; DISPLAY_ZERO_SPEED_FANS=0; TEMP_UNIT="C" - ENABLE_INTEL_GPU_INFO=0; ENABLE_NVIDIA_GPU_INFO=0; ENABLE_AMD_GPU_INFO=0 - ENABLE_GPU_HISTORY=0 - ENABLE_UPS=0; UPS_DEVICE_NAME="ups@localhost" - ENABLE_SYSTEM_INFO=0; SYSTEM_INFO_TYPE=1 - - local lm_sensors_ok=false - local sensors_detected=false - - if [[ "$DEBUG_LM_SENSORS" -eq 1 && -f "$DEBUG_LM_SENSORS_FILE" ]]; then - info "[debug] Using sensor data from $DEBUG_LM_SENSORS_FILE" - lm_sensors_ok=true; LM_SENSORS_ENABLED=1 - else - _check_or_install_tool sensors lm-sensors "lm-sensors" && lm_sensors_ok=true && LM_SENSORS_ENABLED=1 - fi - - if [[ "$lm_sensors_ok" == true ]]; then - local sensorsOutput - if [[ "$DEBUG_LM_SENSORS" -eq 1 ]]; then - sensorsOutput=$(cat "$DEBUG_LM_SENSORS_FILE") - else - sensorsOutput=$(sensors -j 2>/dev/null) || true - fi - - local trimmedSensorsOutput - trimmedSensorsOutput=$(echo "$sensorsOutput" | tr -d '[:space:]') - if [[ -z "$trimmedSensorsOutput" || "$trimmedSensorsOutput" == "{}" ]]; then - warn "lm-sensors is installed but reported no sensors." - warn "No kernel sensor drivers appear to be loaded." - warn "Run 'sensors-detect' and load the suggested modules, then re-run this configurator." - warn "lm-sensors output is the foundation for this mod. Mod cannot be enabled without it." - exit 0 - lm_sensors_ok=false - LM_SENSORS_ENABLED=0 - fi - fi - - if [[ "$lm_sensors_ok" == true ]]; then - local sanitisedSensorsOutput - sanitisedSensorsOutput=$(sanitize_sensors_output "$sensorsOutput") - - #region CPU - msgb "\n=== Detecting CPU temperature sensors ===" - local cpuList="" cpuCount=0 - for pattern in "${KNOWN_CPU_SENSORS[@]}"; do - local found_cpus - found_cpus=$(echo "$sanitisedSensorsOutput" | grep -o "\"${pattern}[^\"]*\"" || true | sed 's/"//g') - if [[ -n "$found_cpus" ]]; then - while read -r sensor; do - [[ -z "$sensor" ]] && continue - cpuCount=$((cpuCount + 1)) - cpuList="${cpuList:+$cpuList,}$sensor" - ENABLE_CPU=1 - done <<< "$found_cpus" - fi - done - if [[ "$ENABLE_CPU" -eq 1 ]]; then - info "Detected CPU sensors ($cpuCount): $cpuList" - sensors_detected=true - while true; do - local choice - choice=$(ask "Display temperatures for all cores [C] or average per CPU [a]? (C/a)") - case "$choice" in - [cC]|"") CPU_TEMP_TARGET="Core"; info "Showing per-core temperatures."; break ;; - [aA]) CPU_TEMP_TARGET="Package"; info "Showing average per-CPU temperature."; break ;; - *) warn "Invalid input, choose C or a." ;; - esac - done - else - warn "No CPU temperature sensors found." - fi - #endregion CPU - - #region RAM - msgb "\n=== Detecting RAM temperature sensors ===" - local ramCount - ramCount=$(grep -c '"SODIMM[^"]*"' <<<"$sanitisedSensorsOutput" || true) - if [[ "$ramCount" -gt 0 ]]; then - info "Detected $ramCount RAM sensor(s)." - ENABLE_RAM_TEMP=1; sensors_detected=true - else - warn "No RAM temperature sensors found." - fi - #endregion RAM - - #region HDD/SSD - msgb "\n=== Detecting HDD/SSD temperature sensors ===" - local hddList - hddList=$(echo "$sanitisedSensorsOutput" | grep -o '"drivetemp-scsi[^"]*"' | sed 's/"//g' | wc -l || true) - if [[ "$hddList" -gt 0 ]]; then - info "Detected $hddList HDD/SSD sensor(s)." - ENABLE_HDD_TEMP=1; sensors_detected=true - else - warn "No HDD/SSD temperature sensors found. (Requires kernel module 'drivetemp'.)" - fi - #endregion HDD/SSD - - #region NVMe - msgb "\n=== Detecting NVMe temperature sensors ===" - local nvmeCount - nvmeCount=$(echo "$sanitisedSensorsOutput" | grep -c '"nvme[^"]*"' || true) - if [[ "$nvmeCount" -gt 0 ]]; then - info "Detected $nvmeCount NVMe sensor(s)." - ENABLE_NVME_TEMP=1; sensors_detected=true - else - warn "No NVMe temperature sensors found." - fi - #endregion NVMe - - #region Fans - msgb "\n=== Detecting fan speed sensors ===" - local fanCount - fanCount=$(grep -c 'fan[0-9]\+_input' <<<"$sanitisedSensorsOutput" || true) - if [[ "$fanCount" -gt 0 ]]; then - info "Detected $fanCount fan speed reading(s)." - ENABLE_FAN_SPEED=1; sensors_detected=true - local choice - choice=$(ask "Display fans reporting zero speed? (Y/n)") - case "$choice" in - [nN]) DISPLAY_ZERO_SPEED_FANS=0; info "Zero-speed fans will be hidden." ;; - *) DISPLAY_ZERO_SPEED_FANS=1; info "Zero-speed fans will be shown." ;; - esac - else - warn "No fan speed sensors found." - fi - #endregion Fans - - #region Temperature unit - if [[ "$sensors_detected" == true ]]; then - msgb "\n=== Temperature unit ===" - local unit - unit=$(ask "Display temperatures in Celsius [C] or Fahrenheit [f]? (C/f)") - case "$unit" in - [fF]) TEMP_UNIT="F"; info "Using Fahrenheit." ;; - *) TEMP_UNIT="C"; info "Using Celsius." ;; - esac - fi - #endregion Temperature unit - fi - - #region Intel GPU - msgb "\n=== Detecting Intel GPU ===" - local intelCards="" - if [[ "$DEBUG_INTEL" -eq 1 && -f "$DEBUG_INTEL_FILE" ]]; then - info "[debug] Using Intel GPU data from $DEBUG_INTEL_FILE" - intelCards=$(cat "$DEBUG_INTEL_FILE") - if [[ -n "$intelCards" ]]; then - info "Intel GPU(s) detected (debug):" - echo "$intelCards" | while IFS= read -r line; do echo " $line"; done - ENABLE_INTEL_GPU_INFO=1 - else - warn "No Intel GPUs in debug file." - fi - elif _check_or_install_tool intel_gpu_top intel-gpu-tools "Intel GPU tools (intel-gpu-tools)"; then - intelCards=$(intel_gpu_top -L 2>/dev/null | grep -E '^card[0-9]+' || true) - if [[ -n "$intelCards" ]]; then - info "Intel GPU(s) detected:" - echo "$intelCards" | while IFS= read -r line; do echo " $line"; done - ENABLE_INTEL_GPU_INFO=1 - else - warn "No Intel GPUs detected by intel_gpu_top." - fi - fi - #endregion Intel GPU - - #region NVIDIA GPU - msgb "\n=== Detecting NVIDIA GPU ===" - if [[ "$DEBUG_NVIDIA" -eq 1 && -f "$DEBUG_NVIDIA_DEVICES_FILE" ]]; then - info "[debug] Using NVIDIA GPU data from $DEBUG_NVIDIA_DEVICES_FILE" - local nvidiaCards - nvidiaCards=$(cat "$DEBUG_NVIDIA_DEVICES_FILE") - if [[ -n "$nvidiaCards" ]]; then - info "NVIDIA GPU(s) detected (debug):" - echo "$nvidiaCards" | while IFS= read -r line; do echo " $line"; done - ENABLE_NVIDIA_GPU_INFO=1 - else - warn "No NVIDIA GPUs in debug file." - fi - elif _check_nvidia_tool; then - local nvidiaCards - nvidiaCards=$(nvidia-smi -L 2>/dev/null || true) - if [[ -n "$nvidiaCards" ]]; then - info "NVIDIA GPU(s) detected:" - echo "$nvidiaCards" | while IFS= read -r line; do echo " $line"; done - ENABLE_NVIDIA_GPU_INFO=1 - else - warn "No NVIDIA GPUs detected by nvidia-smi." - fi - fi - #endregion NVIDIA GPU - - #region AMD GPU (placeholder) - ENABLE_AMD_GPU_INFO=0 - #endregion AMD GPU - - #region GPU history - if [[ "$ENABLE_INTEL_GPU_INFO" -eq 1 || "$ENABLE_NVIDIA_GPU_INFO" -eq 1 ]]; then - msgb "\n=== GPU Historical Data ===" - local choice - choice=$(ask "Store historical GPU data for graphs? (y/N)") - case "$choice" in - [yY]) ENABLE_GPU_HISTORY=1; info "Historical GPU data will be stored." ;; - *) info "Historical GPU data disabled." ;; - esac - fi - #endregion GPU history - - #region UPS - msgb "\n=== UPS Information ===" - local choiceUPS - choiceUPS=$(ask "Enable UPS information? (y/N)") - case "$choiceUPS" in - [yY]) - local upsConn modelName upsOutput - upsConn=$(ask "Enter UPS connection string (e.g. upsname@hostname[:port])") - if [[ "$DEBUG_UPS" -eq 1 && -f "$DEBUG_UPS_FILE" ]]; then - info "[debug] Using UPS data from $DEBUG_UPS_FILE" - upsOutput=$(cat "$DEBUG_UPS_FILE") - else - if ! command -v upsc &>/dev/null; then - err "'upsc' is not available. Install 'nut-client' first." - fi - upsOutput=$(upsc "$upsConn" 2>&1) - fi - if echo "$upsOutput" | grep -q "device.model:"; then - modelName=$(echo "$upsOutput" | grep "device.model:" | cut -d: -f2- | xargs) - ENABLE_UPS=1 - UPS_DEVICE_NAME="$upsConn" - info "Connected to UPS: $modelName at $upsConn" - else - warn "Could not connect to UPS at '$upsConn'. UPS info will be disabled." - ENABLE_UPS=0 - fi - ;; - *) info "UPS information disabled." ;; - esac - #endregion UPS - - #region System info - msgb "\n=== System Information ===" - echo " type 1) System information (manufacturer, product, serial)" - dmidecode -t 1 2>/dev/null | awk -F': ' '/Manufacturer|Product Name|Serial Number/ {print " "$0}' || true - echo " type 2) Baseboard/Motherboard information" - dmidecode -t 2 2>/dev/null | awk -F': ' '/Manufacturer|Product Name|Serial Number/ {print " "$0}' || true - - local choiceSys - choiceSys=$(ask "Enable system information? (1/2/n)") - case "$choiceSys" in - 1|"") ENABLE_SYSTEM_INFO=1; SYSTEM_INFO_TYPE=1; info "System information (type 1) will be shown." ;; - 2) ENABLE_SYSTEM_INFO=1; SYSTEM_INFO_TYPE=2; info "Baseboard information (type 2) will be shown." ;; - [nN]) info "System information disabled." ;; - *) warn "Invalid selection. Defaulting to type 1."; ENABLE_SYSTEM_INFO=1; SYSTEM_INFO_TYPE=1 ;; - esac - #endregion System info -} -#endregion node-info wizard - -#region write config -write_config() { - 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" </dev/null || echo "unknown") - echo -e "\e[1;34m" - echo " ██████╗ ██╗ ██╗███████╗ ███╗ ███╗ ██████╗ ██████╗ ███████╗" - echo " ██╔══██╗██║ ██║██╔════╝ ████╗ ████║██╔═══██╗██╔══██╗██╔════╝" - echo " ██████╔╝██║ ██║█████╗ ██╔████╔██║██║ ██║██║ ██║███████╗" - echo " ██╔═══╝ ╚██╗ ██╔╝██╔══╝ ██║╚██╔╝██║██║ ██║██║ ██║╚════██║" - echo " ██║ ╚████╔╝ ███████╗ ██║ ╚═╝ ██║╚██████╔╝██████╔╝███████║" - echo " ╚═╝ ╚═══╝ ╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝" - echo -e "\e[0m" - echo -e "\e[1m PVE-mods Configurator \e[0;36mv${_version}\e[0m" - echo -e " Proxmox VE enhancement suite — hardware monitoring, GPU info & more\n" - - # ── Root check ──────────────────────────────────────────────────────────── - [[ $EUID -eq 0 ]] || err "This script must be run as root." - - # ── Prerequisite check ──────────────────────────────────────────────────── - if [[ ! -x "$APPLY_PATCHES" ]]; then - err "pve-mod is not installed (cannot find $APPLY_PATCHES).\nInstall it first: dpkg -i pve-mod_*.deb" - fi - - # ── Legacy installation check ───────────────────────────────────────────── - if grep -qF '$res->{sensorsJSONOutput}' "$NODES_PM" 2>/dev/null || \ - grep -qF '$res->{systemInfo}' "$NODES_PM" 2>/dev/null; then - err "A legacy pve-mod bash-script installation was detected in Nodes.pm.\nRemove it first:\n bash /path/to/pve-mod-gui-sensors.sh uninstall" - fi - - # ── Existing config notice ──────────────────────────────────────────────── - if [[ -f "$CONF_FILE" ]]; then - local choice - choice=$(ask "Existing configuration found at $CONF_FILE — Reconfigure? (Y/n)") - case "$choice" in - [nN]) info "Keeping existing configuration."; exit 0 ;; - esac - fi - - # ── Initialize all config variables with safe defaults ──────────────────── - MOD_NODE_INFO=0; MOD_NAG_SCREEN=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" - 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 - PVE_TRIGGER_ENABLED=0 - DEBUG_LM_SENSORS=0; DEBUG_LM_SENSORS_FILE="/tmp/sensors-output.json" - DEBUG_INTEL=0; DEBUG_INTEL_FILE="/tmp/intel-gpu-devices.json" - DEBUG_NVIDIA=0; DEBUG_NVIDIA_OUTPUT_FILE="/tmp/nvidia-smi-output.csv" - DEBUG_NVIDIA_DEVICES_FILE="/tmp/nvidia-smi-devices.csv" - DEBUG_AMD=0; DEBUG_AMD_FILE="/tmp/amd-gpu-devices.json" - DEBUG_UPS=0; DEBUG_UPS_FILE="/tmp/ups-output.json" - DEBUG_LOG=0; DEBUG_LOG_FILE="/tmp/pve-mod-debug.log" - _load_conf - - # ── Module / feature selection ──────────────────────────────────────────── - # Existing settings are loaded above; the user picks ONE option to (re)configure - # this run, and all other options retain their previously saved values. - msgb "\n=== pve-mod Module Selection ===" - echo "Available options (select one):" - echo " [1] Node Info — sensor readings, GPU stats, UPS, system information" - echo " [2] Nag Screen — remove Proxmox subscription nag screen" - echo " [3] Auto re-patch on PVE upgrade — automatically re-apply patches when" - echo " pve-manager is upgraded" - echo " [n] None / cancel" - local modChoice - modChoice=$(ask "Select an option to enable (1/2/3/n)") - case "$modChoice" in - 1) - MOD_NODE_INFO=1 - msgb "\n=== Node Info Configuration ===" - configure_node_info - ;; - 2) - MOD_NAG_SCREEN=1 - msgb "\n=== Nag Screen ===" - info "Subscription nag screen removal will be applied." - ;; - 3) - PVE_TRIGGER_ENABLED=1 - info "Auto re-patching on PVE upgrade enabled." - ;; - [nN]) info "No option selected. Exiting."; exit 0 ;; - *) warn "Invalid selection. Exiting."; exit 0 ;; - esac - - # ── Debug mode ──────────────────────────────────────────────────────────── - configure_debug - - # ── Write config and apply ──────────────────────────────────────────────── - write_config - - msgb "\n=== Applying patches ===" - "$APPLY_PATCHES" - - msgb "\n=== Done ===" - info "pve-mod is configured and active." - info "Clear your browser cache to see the changes." -} - -main +#!/usr/bin/env bash +# +# pve-mod-configure - Interactive configuration tool for pve-mod +# Detects hardware, asks the user which features to enable, writes +# /etc/pve-mod/pve-mod.conf, and applies patches to PVE system files. + +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" + +KNOWN_CPU_SENSORS=("coretemp-isa-" "k10temp-pci-") + +#region helpers +msgb() { echo -e "\e[1m${1}\e[0m"; } +info() { echo -e "\e[0;32m[info] ${1}\e[0m"; } +warn() { echo -e "\e[0;33m[warning] ${1}\e[0m"; } +err() { echo -e "\e[0;31m[error] ${1}\e[0m"; exit 1; } +ask() { + local prompt="$1" response + read -r -p $'\n\e[1;36m'"${prompt}:"$'\e[0m ' response + echo "$response" +} +bool() { [[ "$1" == true ]] && echo 1 || echo 0; } + +_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 ;; + '['*']') section="${line#[}"; section="${section%]}"; continue ;; + esac + [[ "$line" == *=* ]] || continue + key="${line%%=*}"; val="${line#*=}" + case "${section}.${key}" in + # [modules] + modules.node_info) MOD_NODE_INFO="$val" ;; + modules.nag_screen) MOD_NAG_SCREEN="$val" ;; + # [gpu] + gpu.intel_enabled) ENABLE_INTEL_GPU_INFO="$val" ;; + gpu.nvidia_enabled) ENABLE_NVIDIA_GPU_INFO="$val" ;; + gpu.amd_enabled) ENABLE_AMD_GPU_INFO="$val" ;; + gpu.gpu_history) ENABLE_GPU_HISTORY="$val" ;; + # [lm_sensors] + lm_sensors.enabled) LM_SENSORS_ENABLED="$val" ;; + lm_sensors.enable_cpu) ENABLE_CPU="$val" ;; + lm_sensors.cpu_temp_target) CPU_TEMP_TARGET="$val" ;; + lm_sensors.enable_ram_temp) ENABLE_RAM_TEMP="$val" ;; + lm_sensors.enable_hdd_temp) ENABLE_HDD_TEMP="$val" ;; + lm_sensors.enable_nvme_temp) ENABLE_NVME_TEMP="$val" ;; + lm_sensors.enable_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] + ups.enabled) ENABLE_UPS="$val" ;; + ups.device_name) UPS_DEVICE_NAME="$val" ;; + # [system_info] + system_info.enabled) ENABLE_SYSTEM_INFO="$val" ;; + system_info.type) SYSTEM_INFO_TYPE="$val" ;; + # [pve_trigger] + pve_trigger.enabled) PVE_TRIGGER_ENABLED="$val" ;; + # [debug] + debug.lm_sensors_mode) DEBUG_LM_SENSORS="$val" ;; + debug.lm_sensors_output_file) DEBUG_LM_SENSORS_FILE="$val" ;; + debug.intel_mode) DEBUG_INTEL="$val" ;; + debug.intel_devices_file) DEBUG_INTEL_FILE="$val" ;; + debug.nvidia_mode) DEBUG_NVIDIA="$val" ;; + debug.nvidia_devices_file) DEBUG_NVIDIA_DEVICES_FILE="$val" ;; + debug.nvidia_output_file) DEBUG_NVIDIA_OUTPUT_FILE="$val" ;; + debug.amd_mode) DEBUG_AMD="$val" ;; + debug.amd_devices_file) DEBUG_AMD_FILE="$val" ;; + debug.ups_mode) DEBUG_UPS="$val" ;; + debug.ups_output_file) DEBUG_UPS_FILE="$val" ;; + debug.log_enabled) DEBUG_LOG="$val" ;; + debug.log_file) DEBUG_LOG_FILE="$val" ;; + esac + done < "$NODE_INFO_CONF" +} +#endregion helpers + +sanitize_sensors_output() { + local input="$1" + echo "$input" | perl -0777 -pe ' + s/ERROR:.+\s(\w+):\s(.+)/"$1": 0.000,/g; + s/ERROR:.+\s(\w+)!/"$1": 0.000,/g; + s/,\s*(\})/$1/g; + s/\bNaN\b/null/g; + s/"SODIMM"\s*:\s*\{\s*"temp(\d+)_input"/"SODIMM $1": {\n "temp$1_input"/g; + s/"([^"]*Fan[^"]*)"\s*:\s*\{\s*"fan(\d+)_input"/"$1 $2": {\n "fan$2_input"/g; + ' | python3 -m json.tool 2>/dev/null || echo "$input" +} + +_check_or_install_tool() { + local cmd="$1" pkg="$2" description="$3" + if command -v "$cmd" &>/dev/null; then + info "$description is installed." + return 0 + fi + local choice + choice=$(ask "$description is not installed. Install it now? (y/N)") + case "$choice" in + [yY]) + apt-get update -qq + apt-get install -y "$pkg" + command -v "$cmd" &>/dev/null && { info "$description installed."; return 0; } || \ + { warn "$description installation failed. Section will be skipped."; return 1; } + ;; + *) + info "Skipping $description." + return 1 + ;; + esac +} + +_check_nvidia_tool() { + if command -v nvidia-smi &>/dev/null; then + info "nvidia-smi is installed." + return 0 + fi + warn "nvidia-smi not found. NVIDIA monitoring requires NVIDIA drivers (not installable via apt)." + return 1 +} + +#region node-info wizard +configure_node_info() { + # Initialize all variables to off + LM_SENSORS_ENABLED=0 + ENABLE_CPU=0; CPU_TEMP_TARGET="Core" + ENABLE_RAM_TEMP=0; ENABLE_HDD_TEMP=0; ENABLE_NVME_TEMP=0 + ENABLE_FAN_SPEED=0; DISPLAY_ZERO_SPEED_FANS=0; TEMP_UNIT="C" + ENABLE_INTEL_GPU_INFO=0; ENABLE_NVIDIA_GPU_INFO=0; ENABLE_AMD_GPU_INFO=0 + ENABLE_GPU_HISTORY=0 + ENABLE_UPS=0; UPS_DEVICE_NAME="ups@localhost" + ENABLE_SYSTEM_INFO=0; SYSTEM_INFO_TYPE=1 + + local lm_sensors_ok=false + local sensors_detected=false + + if [[ "$DEBUG_LM_SENSORS" -eq 1 && -f "$DEBUG_LM_SENSORS_FILE" ]]; then + info "[debug] Using sensor data from $DEBUG_LM_SENSORS_FILE" + lm_sensors_ok=true; LM_SENSORS_ENABLED=1 + else + _check_or_install_tool sensors lm-sensors "lm-sensors" && lm_sensors_ok=true && LM_SENSORS_ENABLED=1 + fi + + if [[ "$lm_sensors_ok" == true ]]; then + local sensorsOutput + if [[ "$DEBUG_LM_SENSORS" -eq 1 ]]; then + sensorsOutput=$(cat "$DEBUG_LM_SENSORS_FILE") + else + sensorsOutput=$(sensors -j 2>/dev/null) || true + fi + + local trimmedSensorsOutput + trimmedSensorsOutput=$(echo "$sensorsOutput" | tr -d '[:space:]') + if [[ -z "$trimmedSensorsOutput" || "$trimmedSensorsOutput" == "{}" ]]; then + warn "lm-sensors is installed but reported no sensors." + warn "No kernel sensor drivers appear to be loaded." + warn "Run 'sensors-detect' and load the suggested modules, then re-run this configurator." + warn "lm-sensors output is the foundation for this mod. Mod cannot be enabled without it." + exit 0 + lm_sensors_ok=false + LM_SENSORS_ENABLED=0 + fi + fi + + if [[ "$lm_sensors_ok" == true ]]; then + local sanitisedSensorsOutput + sanitisedSensorsOutput=$(sanitize_sensors_output "$sensorsOutput") + + #region CPU + msgb "\n=== Detecting CPU temperature sensors ===" + local cpuList="" cpuCount=0 + for pattern in "${KNOWN_CPU_SENSORS[@]}"; do + local found_cpus + found_cpus=$(echo "$sanitisedSensorsOutput" | grep -o "\"${pattern}[^\"]*\"" || true | sed 's/"//g') + if [[ -n "$found_cpus" ]]; then + while read -r sensor; do + [[ -z "$sensor" ]] && continue + cpuCount=$((cpuCount + 1)) + cpuList="${cpuList:+$cpuList,}$sensor" + ENABLE_CPU=1 + done <<< "$found_cpus" + fi + done + if [[ "$ENABLE_CPU" -eq 1 ]]; then + info "Detected CPU sensors ($cpuCount): $cpuList" + sensors_detected=true + while true; do + local choice + choice=$(ask "Display temperatures for all cores [C] or average per CPU [a]? (C/a)") + case "$choice" in + [cC]|"") CPU_TEMP_TARGET="Core"; info "Showing per-core temperatures."; break ;; + [aA]) CPU_TEMP_TARGET="Package"; info "Showing average per-CPU temperature."; break ;; + *) warn "Invalid input, choose C or a." ;; + esac + done + else + warn "No CPU temperature sensors found." + fi + #endregion CPU + + #region RAM + msgb "\n=== Detecting RAM temperature sensors ===" + local ramCount + ramCount=$(grep -c '"SODIMM[^"]*"' <<<"$sanitisedSensorsOutput" || true) + if [[ "$ramCount" -gt 0 ]]; then + info "Detected $ramCount RAM sensor(s)." + ENABLE_RAM_TEMP=1; sensors_detected=true + else + warn "No RAM temperature sensors found." + fi + #endregion RAM + + #region HDD/SSD + msgb "\n=== Detecting HDD/SSD temperature sensors ===" + local hddList + hddList=$(echo "$sanitisedSensorsOutput" | grep -o '"drivetemp-scsi[^"]*"' | sed 's/"//g' | wc -l || true) + if [[ "$hddList" -gt 0 ]]; then + info "Detected $hddList HDD/SSD sensor(s)." + ENABLE_HDD_TEMP=1; sensors_detected=true + else + warn "No HDD/SSD temperature sensors found. (Requires kernel module 'drivetemp'.)" + fi + #endregion HDD/SSD + + #region NVMe + msgb "\n=== Detecting NVMe temperature sensors ===" + local nvmeCount + nvmeCount=$(echo "$sanitisedSensorsOutput" | grep -c '"nvme[^"]*"' || true) + if [[ "$nvmeCount" -gt 0 ]]; then + info "Detected $nvmeCount NVMe sensor(s)." + ENABLE_NVME_TEMP=1; sensors_detected=true + else + warn "No NVMe temperature sensors found." + fi + #endregion NVMe + + #region Fans + msgb "\n=== Detecting fan speed sensors ===" + local fanCount + fanCount=$(grep -c 'fan[0-9]\+_input' <<<"$sanitisedSensorsOutput" || true) + if [[ "$fanCount" -gt 0 ]]; then + info "Detected $fanCount fan speed reading(s)." + ENABLE_FAN_SPEED=1; sensors_detected=true + local choice + choice=$(ask "Display fans reporting zero speed? (Y/n)") + case "$choice" in + [nN]) DISPLAY_ZERO_SPEED_FANS=0; info "Zero-speed fans will be hidden." ;; + *) DISPLAY_ZERO_SPEED_FANS=1; info "Zero-speed fans will be shown." ;; + esac + else + warn "No fan speed sensors found." + fi + #endregion Fans + + #region Temperature unit + if [[ "$sensors_detected" == true ]]; then + msgb "\n=== Temperature unit ===" + local unit + unit=$(ask "Display temperatures in Celsius [C] or Fahrenheit [f]? (C/f)") + case "$unit" in + [fF]) TEMP_UNIT="F"; info "Using Fahrenheit." ;; + *) TEMP_UNIT="C"; info "Using Celsius." ;; + esac + fi + #endregion Temperature unit + fi + + #region Intel GPU + msgb "\n=== Detecting Intel GPU ===" + local intelCards="" + if [[ "$DEBUG_INTEL" -eq 1 && -f "$DEBUG_INTEL_FILE" ]]; then + info "[debug] Using Intel GPU data from $DEBUG_INTEL_FILE" + intelCards=$(cat "$DEBUG_INTEL_FILE") + if [[ -n "$intelCards" ]]; then + info "Intel GPU(s) detected (debug):" + echo "$intelCards" | while IFS= read -r line; do echo " $line"; done + ENABLE_INTEL_GPU_INFO=1 + else + warn "No Intel GPUs in debug file." + fi + elif _check_or_install_tool intel_gpu_top intel-gpu-tools "Intel GPU tools (intel-gpu-tools)"; then + intelCards=$(intel_gpu_top -L 2>/dev/null | grep -E '^card[0-9]+' || true) + if [[ -n "$intelCards" ]]; then + info "Intel GPU(s) detected:" + echo "$intelCards" | while IFS= read -r line; do echo " $line"; done + ENABLE_INTEL_GPU_INFO=1 + else + warn "No Intel GPUs detected by intel_gpu_top." + fi + fi + #endregion Intel GPU + + #region NVIDIA GPU + msgb "\n=== Detecting NVIDIA GPU ===" + if [[ "$DEBUG_NVIDIA" -eq 1 && -f "$DEBUG_NVIDIA_DEVICES_FILE" ]]; then + info "[debug] Using NVIDIA GPU data from $DEBUG_NVIDIA_DEVICES_FILE" + local nvidiaCards + nvidiaCards=$(cat "$DEBUG_NVIDIA_DEVICES_FILE") + if [[ -n "$nvidiaCards" ]]; then + info "NVIDIA GPU(s) detected (debug):" + echo "$nvidiaCards" | while IFS= read -r line; do echo " $line"; done + ENABLE_NVIDIA_GPU_INFO=1 + else + warn "No NVIDIA GPUs in debug file." + fi + elif _check_nvidia_tool; then + local nvidiaCards + nvidiaCards=$(nvidia-smi -L 2>/dev/null || true) + if [[ -n "$nvidiaCards" ]]; then + info "NVIDIA GPU(s) detected:" + echo "$nvidiaCards" | while IFS= read -r line; do echo " $line"; done + ENABLE_NVIDIA_GPU_INFO=1 + else + warn "No NVIDIA GPUs detected by nvidia-smi." + fi + fi + #endregion NVIDIA GPU + + #region AMD GPU (placeholder) + ENABLE_AMD_GPU_INFO=0 + #endregion AMD GPU + + #region GPU history + if [[ "$ENABLE_INTEL_GPU_INFO" -eq 1 || "$ENABLE_NVIDIA_GPU_INFO" -eq 1 ]]; then + msgb "\n=== GPU Historical Data ===" + local choice + choice=$(ask "Store historical GPU data for graphs? (y/N)") + case "$choice" in + [yY]) ENABLE_GPU_HISTORY=1; info "Historical GPU data will be stored." ;; + *) info "Historical GPU data disabled." ;; + esac + fi + #endregion GPU history + + #region UPS + msgb "\n=== UPS Information ===" + local choiceUPS + choiceUPS=$(ask "Enable UPS information? (y/N)") + case "$choiceUPS" in + [yY]) + local upsConn modelName upsOutput + upsConn=$(ask "Enter UPS connection string (e.g. upsname@hostname[:port])") + if [[ "$DEBUG_UPS" -eq 1 && -f "$DEBUG_UPS_FILE" ]]; then + info "[debug] Using UPS data from $DEBUG_UPS_FILE" + upsOutput=$(cat "$DEBUG_UPS_FILE") + else + if ! command -v upsc &>/dev/null; then + err "'upsc' is not available. Install 'nut-client' first." + fi + upsOutput=$(upsc "$upsConn" 2>&1) + fi + if echo "$upsOutput" | grep -q "device.model:"; then + modelName=$(echo "$upsOutput" | grep "device.model:" | cut -d: -f2- | xargs) + ENABLE_UPS=1 + UPS_DEVICE_NAME="$upsConn" + info "Connected to UPS: $modelName at $upsConn" + else + warn "Could not connect to UPS at '$upsConn'. UPS info will be disabled." + ENABLE_UPS=0 + fi + ;; + *) info "UPS information disabled." ;; + esac + #endregion UPS + + #region System info + msgb "\n=== System Information ===" + echo " type 1) System information (manufacturer, product, serial)" + dmidecode -t 1 2>/dev/null | awk -F': ' '/Manufacturer|Product Name|Serial Number/ {print " "$0}' || true + echo " type 2) Baseboard/Motherboard information" + dmidecode -t 2 2>/dev/null | awk -F': ' '/Manufacturer|Product Name|Serial Number/ {print " "$0}' || true + + local choiceSys + choiceSys=$(ask "Enable system information? (1/2/n)") + case "$choiceSys" in + 1|"") ENABLE_SYSTEM_INFO=1; SYSTEM_INFO_TYPE=1; info "System information (type 1) will be shown." ;; + 2) ENABLE_SYSTEM_INFO=1; SYSTEM_INFO_TYPE=2; info "Baseboard information (type 2) will be shown." ;; + [nN]) info "System information disabled." ;; + *) warn "Invalid selection. Defaulting to type 1."; ENABLE_SYSTEM_INFO=1; SYSTEM_INFO_TYPE=1 ;; + esac + #endregion System info +} +#endregion node-info wizard + +#region write config +write_config() { + 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" </dev/null || echo "unknown") + echo -e "\e[1;34m" + echo " ██████╗ ██╗ ██╗███████╗ ███╗ ███╗ ██████╗ ██████╗ ███████╗" + echo " ██╔══██╗██║ ██║██╔════╝ ████╗ ████║██╔═══██╗██╔══██╗██╔════╝" + echo " ██████╔╝██║ ██║█████╗ ██╔████╔██║██║ ██║██║ ██║███████╗" + echo " ██╔═══╝ ╚██╗ ██╔╝██╔══╝ ██║╚██╔╝██║██║ ██║██║ ██║╚════██║" + echo " ██║ ╚████╔╝ ███████╗ ██║ ╚═╝ ██║╚██████╔╝██████╔╝███████║" + echo " ╚═╝ ╚═══╝ ╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝" + echo -e "\e[0m" + echo -e "\e[1m PVE-mods Configurator \e[0;36mv${_version}\e[0m" + echo -e " Proxmox VE enhancement suite — hardware monitoring, GPU info & more\n" + + # ── Root check ──────────────────────────────────────────────────────────── + [[ $EUID -eq 0 ]] || err "This script must be run as root." + + # ── Prerequisite check ──────────────────────────────────────────────────── + if [[ ! -x "$APPLY_PATCHES" ]]; then + err "pve-mod is not installed (cannot find $APPLY_PATCHES).\nInstall it first: dpkg -i pve-mod_*.deb" + fi + + # ── Legacy installation check ───────────────────────────────────────────── + if grep -qF '$res->{sensorsJSONOutput}' "$NODES_PM" 2>/dev/null || \ + grep -qF '$res->{systemInfo}' "$NODES_PM" 2>/dev/null; then + err "A legacy pve-mod bash-script installation was detected in Nodes.pm.\nRemove it first:\n bash /path/to/pve-mod-gui-sensors.sh uninstall" + fi + + # ── Existing config notice ──────────────────────────────────────────────── + if [[ -f "$CONF_FILE" ]]; then + local choice + choice=$(ask "Existing configuration found at $CONF_FILE — Reconfigure? (Y/n)") + case "$choice" in + [nN]) info "Keeping existing configuration."; exit 0 ;; + esac + fi + + # ── Initialize all config variables with safe defaults ──────────────────── + MOD_NODE_INFO=0; MOD_NAG_SCREEN=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" + 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 + PVE_TRIGGER_ENABLED=0 + DEBUG_LM_SENSORS=0; DEBUG_LM_SENSORS_FILE="/tmp/sensors-output.json" + DEBUG_INTEL=0; DEBUG_INTEL_FILE="/tmp/intel-gpu-devices.json" + DEBUG_NVIDIA=0; DEBUG_NVIDIA_OUTPUT_FILE="/tmp/nvidia-smi-output.csv" + DEBUG_NVIDIA_DEVICES_FILE="/tmp/nvidia-smi-devices.csv" + DEBUG_AMD=0; DEBUG_AMD_FILE="/tmp/amd-gpu-devices.json" + DEBUG_UPS=0; DEBUG_UPS_FILE="/tmp/ups-output.json" + DEBUG_LOG=0; DEBUG_LOG_FILE="/tmp/pve-mod-debug.log" + _load_conf + + # ── Module / feature selection ──────────────────────────────────────────── + # Existing settings are loaded above; the user picks ONE option to (re)configure + # this run, and all other options retain their previously saved values. + msgb "\n=== pve-mod Module Selection ===" + echo "Available options (select one):" + echo " [1] Node Info — sensor readings, GPU stats, UPS, system information" + echo " [2] Nag Screen — remove Proxmox subscription nag screen" + echo " [3] Auto re-patch on PVE upgrade — automatically re-apply patches when" + echo " pve-manager is upgraded" + echo " [n] None / cancel" + local modChoice + modChoice=$(ask "Select an option to enable (1/2/3/n)") + case "$modChoice" in + 1) + MOD_NODE_INFO=1 + msgb "\n=== Node Info Configuration ===" + configure_node_info + ;; + 2) + MOD_NAG_SCREEN=1 + msgb "\n=== Nag Screen ===" + info "Subscription nag screen removal will be applied." + ;; + 3) + PVE_TRIGGER_ENABLED=1 + info "Auto re-patching on PVE upgrade enabled." + ;; + [nN]) info "No option selected. Exiting."; exit 0 ;; + *) warn "Invalid selection. Exiting."; exit 0 ;; + esac + + # ── Debug mode ──────────────────────────────────────────────────────────── + configure_debug + + # ── Write config and apply ──────────────────────────────────────────────── + write_config + + msgb "\n=== Applying patches ===" + "$APPLY_PATCHES" + + msgb "\n=== Done ===" + info "pve-mod is configured and active." + info "Clear your browser cache to see the changes." +} + +main diff --git a/src/nag_screen/files/files.list b/src/nag_screen/files/files.list index 0b4bec3..0690241 100644 --- a/src/nag_screen/files/files.list +++ b/src/nag_screen/files/files.list @@ -1,10 +1,10 @@ -# pve-mod :: nag_screen file manifest -# Maps files in this directory to their installation destinations. -# Format: [permission] -# source - path relative to this files/ directory -# destination - path relative to the package root (no leading slash) -# permission - octal mode, optional (defaults to 644) -# Read by src/gen-rules.sh to generate the per-module debian install rules. -# -# The nag_screen mod ships no new files - it only patches existing Proxmox -# files - so this manifest is intentionally empty. +# pve-mod :: nag_screen file manifest +# Maps files in this directory to their installation destinations. +# Format: [permission] +# source - path relative to this files/ directory +# destination - path relative to the package root (no leading slash) +# permission - octal mode, optional (defaults to 644) +# Read by src/gen-rules.sh to generate the per-module debian install rules. +# +# The nag_screen mod ships no new files - it only patches existing Proxmox +# files - so this manifest is intentionally empty. diff --git a/src/nag_screen/nag_screen.conf b/src/nag_screen/nag_screen.conf index 7143096..5661b80 100644 --- a/src/nag_screen/nag_screen.conf +++ b/src/nag_screen/nag_screen.conf @@ -1,3 +1,3 @@ -# pve-mod :: nag_screen mod configuration -# The nag-screen mod has no tunable settings; this file is a placeholder -# kept for consistency with the per-mod conf.d layout. +# pve-mod :: nag_screen mod configuration +# The nag-screen mod has no tunable settings; this file is a placeholder +# kept for consistency with the per-mod conf.d layout. diff --git a/src/nag_screen/patches/patches.list b/src/nag_screen/patches/patches.list index 2de8bfe..292abb7 100644 --- a/src/nag_screen/patches/patches.list +++ b/src/nag_screen/patches/patches.list @@ -1,5 +1,5 @@ -# pve-mod :: nag_screen patch manifest -# Format: [section.key=value] - -01-proxmoxlib-js-nagscreen.patch -02-index-html-tpl-mobilenag.patch +# pve-mod :: nag_screen patch manifest +# Format: [section.key=value] + +01-proxmoxlib-js-nagscreen.patch +02-index-html-tpl-mobilenag.patch diff --git a/src/node_info/files/Collector/Amd.pm b/src/node_info/files/Collector/Amd.pm index 7cacf04..29de219 100644 --- a/src/node_info/files/Collector/Amd.pm +++ b/src/node_info/files/Collector/Amd.pm @@ -1,33 +1,33 @@ -package PVE::PVEMod::Collector::Amd; - -use strict; -use warnings; -use Exporter 'import'; - -use PVE::PVEMod::Config qw($process_type); -use PVE::PVEMod::Utils qw(debug); - -our @EXPORT_OK = qw( - get_amd_gpu_devices - collector_for_amd_device -); - -# ============================================================================ -# AMD GPU — placeholders (not yet implemented) -# ============================================================================ - -sub get_amd_gpu_devices { - # TODO: Implement AMD GPU detection using rocminfo or rocm-smi - debug(__LINE__, "AMD GPU support not yet implemented"); - return (); -} - -sub collector_for_amd_device { - my ($device) = @_; - $process_type = 'collector'; - # TODO: Implement AMD GPU collector - debug(__LINE__, "AMD GPU collector not yet implemented"); - exit 0; -} - -1; +package PVE::PVEMod::Collector::Amd; + +use strict; +use warnings; +use Exporter 'import'; + +use PVE::PVEMod::Config qw($process_type); +use PVE::PVEMod::Utils qw(debug); + +our @EXPORT_OK = qw( + get_amd_gpu_devices + collector_for_amd_device +); + +# ============================================================================ +# AMD GPU — placeholders (not yet implemented) +# ============================================================================ + +sub get_amd_gpu_devices { + # TODO: Implement AMD GPU detection using rocminfo or rocm-smi + debug(__LINE__, "AMD GPU support not yet implemented"); + return (); +} + +sub collector_for_amd_device { + my ($device) = @_; + $process_type = 'collector'; + # TODO: Implement AMD GPU collector + debug(__LINE__, "AMD GPU collector not yet implemented"); + exit 0; +} + +1; diff --git a/src/node_info/files/Collector/Intel.pm b/src/node_info/files/Collector/Intel.pm index 988ec60..6bb47da 100644 --- a/src/node_info/files/Collector/Intel.pm +++ b/src/node_info/files/Collector/Intel.pm @@ -1,179 +1,179 @@ -package PVE::PVEMod::Collector::Intel; - -use strict; -use warnings; -use Exporter 'import'; - -use PVE::PVEMod::Config qw(%config $process_type $pve_mod_working_dir); -use PVE::PVEMod::Utils qw(debug check_executable setup_collector_signals safe_write_json); -use PVE::PVEMod::Store qw(update_intel_gpu_rrd); - -our @EXPORT_OK = qw( - get_intel_gpu_devices - collector_for_intel_device -); - -# ============================================================================ -# Intel GPU — device discovery -# ============================================================================ - -sub get_intel_gpu_devices { - my @devices = (); - - debug(__LINE__, "Getting Intel GPU devices"); - if (open my $fh, '-|', 'intel_gpu_top -L') { - while (<$fh>) { - chomp; - # Parse: "card0 Intel Alderlake_n (Gen12) pci:vendor=8086,device=46D0,card=0" - # or: "card0 Intel Alderlake_n (Gen12) pci:0000:00:02.0" - if (/^(card\d+)\s+(.+?)\s+(pci:[^\s]+)/) { - my ($card, $name, $path) = ($1, $2, $3); - push @devices, { - card => $card, - name => $name, - path => $path, - drm_path => "/dev/dri/$card", - }; - debug(__LINE__, "Found Intel device: $card -> $name ($path)"); - } - } - close $fh; - } else { - debug(__LINE__, "Failed to run intel_gpu_top -L: $!"); - } - - return @devices; -} - -# ============================================================================ -# Intel GPU — data parsing -# ============================================================================ - -sub _parse_intel_gpu_line { - my ($line) = @_; - - # Expected format (whitespace-aligned columns): - # Freq MHz IRQ RC6 Power W RCS BCS VCS VECS - # req act /s % gpu pkg % se wa % se wa % se wa % se wa - # 0 0 0 0 0.00 7.47 0.00 0 0 0.00 0 0 0.00 0 0 0.00 0 0 - - $line =~ s/^\s+|\s+$//g; - my @values = grep { $_ ne '' } split(/\s+/, $line); - - return unless @values >= 18; - - return { - frequency => { - requested => $values[0] + 0.0, - actual => $values[1] + 0.0, - unit => "MHz", - }, - interrupts => { - count => $values[2] + 0.0, - unit => "irq/s", - }, - rc6 => { - value => $values[3] + 0.0, - unit => "%", - }, - power => { - GPU => $values[4] + 0.0, - Package => $values[5] + 0.0, - unit => "W", - }, - engines => { - 'Render/3D' => { - busy => $values[6] + 0.0, - sema => $values[7] + 0.0, - wait => $values[8] + 0.0, - unit => "%", - }, - Blitter => { - busy => $values[9] + 0.0, - sema => $values[10] + 0.0, - wait => $values[11] + 0.0, - unit => "%", - }, - Video => { - busy => $values[12] + 0.0, - sema => $values[13] + 0.0, - wait => $values[14] + 0.0, - unit => "%", - }, - VideoEnhance => { - busy => $values[15] + 0.0, - sema => $values[16] + 0.0, - wait => $values[17] + 0.0, - unit => "%", - }, - }, - clients => {}, - }; -} - -# ============================================================================ -# Intel GPU — long-running collector -# ============================================================================ - -sub collector_for_intel_device { - my ($device) = @_; - $process_type = 'collector'; - $0 = "collector-gpu-intel-$device->{card}"; - - my $drm_dev = "drm:/dev/dri/$device->{card}"; - my $intel_gpu_top_pid = undef; - my $device_state_file = "$pve_mod_working_dir/stats-$device->{card}.json"; - - debug(__LINE__, "Collector started for device: $drm_dev, writing to $device_state_file"); - - my $shutdown = 0; - setup_collector_signals($device->{card}, \$shutdown, sub { - kill 'TERM', $intel_gpu_top_pid - if defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0; - }); - - debug(__LINE__, "About to open pipe to intel_gpu_top"); - my $intel_pull_interval = $config{intervals}{data_pull} * 1000; # milliseconds - $intel_gpu_top_pid = open(my $fh, '-|', - "intel_gpu_top -d $drm_dev -s $intel_pull_interval -l 2>&1"); - - unless (defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0) { - debug(__LINE__, "Failed to run intel_gpu_top for $drm_dev: $!"); - exit 1; - } - - debug(__LINE__, "Pipe opened successfully, PID=$intel_gpu_top_pid"); - - my $node_name = "node0"; - - while (my $line = <$fh>) { - last if $shutdown; - chomp $line; - - next if $line =~ /MHz|IRQ|RC6|Power|RCS|BCS|VCS|VECS|req\s+act|^\s*$/; - - if ($line =~ /^\s*[\d\s\.]+$/) { - my $stats = _parse_intel_gpu_line($line); - - if ($stats) { - my $device_data = { - $node_name => { - name => $device->{name}, - device_path => $device->{path}, - drm_path => $device->{drm_path}, - stats => $stats, - } - }; - - safe_write_json($device_state_file, $device_data); - update_intel_gpu_rrd($device->{card}, $stats); - } - } - } - - close $fh; - debug(__LINE__, "Collector for $device->{card} shutting down"); - exit 0; -} - -1; +package PVE::PVEMod::Collector::Intel; + +use strict; +use warnings; +use Exporter 'import'; + +use PVE::PVEMod::Config qw(%config $process_type $pve_mod_working_dir); +use PVE::PVEMod::Utils qw(debug check_executable setup_collector_signals safe_write_json); +use PVE::PVEMod::Store qw(update_intel_gpu_rrd); + +our @EXPORT_OK = qw( + get_intel_gpu_devices + collector_for_intel_device +); + +# ============================================================================ +# Intel GPU — device discovery +# ============================================================================ + +sub get_intel_gpu_devices { + my @devices = (); + + debug(__LINE__, "Getting Intel GPU devices"); + if (open my $fh, '-|', 'intel_gpu_top -L') { + while (<$fh>) { + chomp; + # Parse: "card0 Intel Alderlake_n (Gen12) pci:vendor=8086,device=46D0,card=0" + # or: "card0 Intel Alderlake_n (Gen12) pci:0000:00:02.0" + if (/^(card\d+)\s+(.+?)\s+(pci:[^\s]+)/) { + my ($card, $name, $path) = ($1, $2, $3); + push @devices, { + card => $card, + name => $name, + path => $path, + drm_path => "/dev/dri/$card", + }; + debug(__LINE__, "Found Intel device: $card -> $name ($path)"); + } + } + close $fh; + } else { + debug(__LINE__, "Failed to run intel_gpu_top -L: $!"); + } + + return @devices; +} + +# ============================================================================ +# Intel GPU — data parsing +# ============================================================================ + +sub _parse_intel_gpu_line { + my ($line) = @_; + + # Expected format (whitespace-aligned columns): + # Freq MHz IRQ RC6 Power W RCS BCS VCS VECS + # req act /s % gpu pkg % se wa % se wa % se wa % se wa + # 0 0 0 0 0.00 7.47 0.00 0 0 0.00 0 0 0.00 0 0 0.00 0 0 + + $line =~ s/^\s+|\s+$//g; + my @values = grep { $_ ne '' } split(/\s+/, $line); + + return unless @values >= 18; + + return { + frequency => { + requested => $values[0] + 0.0, + actual => $values[1] + 0.0, + unit => "MHz", + }, + interrupts => { + count => $values[2] + 0.0, + unit => "irq/s", + }, + rc6 => { + value => $values[3] + 0.0, + unit => "%", + }, + power => { + GPU => $values[4] + 0.0, + Package => $values[5] + 0.0, + unit => "W", + }, + engines => { + 'Render/3D' => { + busy => $values[6] + 0.0, + sema => $values[7] + 0.0, + wait => $values[8] + 0.0, + unit => "%", + }, + Blitter => { + busy => $values[9] + 0.0, + sema => $values[10] + 0.0, + wait => $values[11] + 0.0, + unit => "%", + }, + Video => { + busy => $values[12] + 0.0, + sema => $values[13] + 0.0, + wait => $values[14] + 0.0, + unit => "%", + }, + VideoEnhance => { + busy => $values[15] + 0.0, + sema => $values[16] + 0.0, + wait => $values[17] + 0.0, + unit => "%", + }, + }, + clients => {}, + }; +} + +# ============================================================================ +# Intel GPU — long-running collector +# ============================================================================ + +sub collector_for_intel_device { + my ($device) = @_; + $process_type = 'collector'; + $0 = "collector-gpu-intel-$device->{card}"; + + my $drm_dev = "drm:/dev/dri/$device->{card}"; + my $intel_gpu_top_pid = undef; + my $device_state_file = "$pve_mod_working_dir/stats-$device->{card}.json"; + + debug(__LINE__, "Collector started for device: $drm_dev, writing to $device_state_file"); + + my $shutdown = 0; + setup_collector_signals($device->{card}, \$shutdown, sub { + kill 'TERM', $intel_gpu_top_pid + if defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0; + }); + + debug(__LINE__, "About to open pipe to intel_gpu_top"); + my $intel_pull_interval = $config{intervals}{data_pull} * 1000; # milliseconds + $intel_gpu_top_pid = open(my $fh, '-|', + "intel_gpu_top -d $drm_dev -s $intel_pull_interval -l 2>&1"); + + unless (defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0) { + debug(__LINE__, "Failed to run intel_gpu_top for $drm_dev: $!"); + exit 1; + } + + debug(__LINE__, "Pipe opened successfully, PID=$intel_gpu_top_pid"); + + my $node_name = "node0"; + + while (my $line = <$fh>) { + last if $shutdown; + chomp $line; + + next if $line =~ /MHz|IRQ|RC6|Power|RCS|BCS|VCS|VECS|req\s+act|^\s*$/; + + if ($line =~ /^\s*[\d\s\.]+$/) { + my $stats = _parse_intel_gpu_line($line); + + if ($stats) { + my $device_data = { + $node_name => { + name => $device->{name}, + device_path => $device->{path}, + drm_path => $device->{drm_path}, + stats => $stats, + } + }; + + safe_write_json($device_state_file, $device_data); + update_intel_gpu_rrd($device->{card}, $stats); + } + } + } + + close $fh; + debug(__LINE__, "Collector for $device->{card} shutting down"); + exit 0; +} + +1; diff --git a/src/node_info/files/Collector/LmSensors.pm b/src/node_info/files/Collector/LmSensors.pm index 6372918..5f64d88 100644 --- a/src/node_info/files/Collector/LmSensors.pm +++ b/src/node_info/files/Collector/LmSensors.pm @@ -1,417 +1,417 @@ -package PVE::PVEMod::Collector::LmSensors; - -use strict; -use warnings; -use Exporter 'import'; - -use JSON; - -use PVE::PVEMod::Config qw(%config $process_type $sensors_state_file); -use PVE::PVEMod::Utils qw(debug check_executable setup_collector_signals read_sysfs); - -our @EXPORT_OK = qw( - collector_for_temperature_sensors -); - -# ============================================================================ -# Temperature Sensors — long-running collector -# ============================================================================ - -sub collector_for_temperature_sensors { - my ($device) = @_; - $process_type = 'collector'; - $0 = "collector-temperature-sensors"; - my %cache; - my $shutdown = 0; - setup_collector_signals('temperature-sensors', \$shutdown); - - while (!$shutdown) { - my $sensors_data = _get_temperature_sensors(\%cache); - - eval { - open my $ofh, '>', $sensors_state_file - or die "Failed to open $sensors_state_file: $!"; - print $ofh $sensors_data; - close $ofh; - debug(__LINE__, "Wrote temperature sensor data to $sensors_state_file"); - }; - if ($@) { - debug(__LINE__, "Error writing temperature sensor data: $@"); - } - - sleep $config{intervals}{data_pull} unless $shutdown; - } - - debug(__LINE__, "Temperature sensor collector shutting down"); - exit 0; -} - -# ============================================================================ -# Temperature Sensors — pipeline -# ============================================================================ - -sub _get_temperature_sensors { - my ($cache_ref) = @_; - - my $sensors_output; - - if ($config{debug}{lm_sensors_mode} && -f $config{debug}{lm_sensors_output_file}) { - debug(__LINE__, "Debug mode: reading lm-sensors data from $config{debug}{lm_sensors_output_file}"); - if (open my $fh, '<', $config{debug}{lm_sensors_output_file}) { - local $/; - $sensors_output = <$fh>; - close $fh; - debug(__LINE__, "Read lm-sensors data from debug file, length: " - . length($sensors_output) . " bytes"); - } else { - debug(__LINE__, "Failed to open debug file $config{debug}{lm_sensors_output_file}: $!"); - $sensors_output = '{}'; - } - } else { - $sensors_output = `sensors -j 2>/dev/null | python3 -m json.tool`; - debug(__LINE__, "Raw lm-sensors output collected from command"); - } - - debug(__LINE__, "Raw lm-sensors output collected"); - - my $data = _sanitize_sensors($sensors_output); - debug(__LINE__, "Sanitized lm-sensors output"); - - $data = _get_drive_names($data, $cache_ref); - debug(__LINE__, "Translated drive names in lm-sensors output"); - - $data = _get_cpu_name($data, $cache_ref); - debug(__LINE__, "Translated CPU names in lm-sensors output"); - - # Wrap in top-level key - my $sensors_json; - eval { $sensors_json = decode_json($data); }; - if ($@) { - debug(__LINE__, "Failed to parse final lm-sensors JSON: $@"); - return $data; - } - - $data = JSON->new->pretty->encode({ "PVE MOD lm-sensors Enhanced" => $sensors_json }); - - return $data; -} - -# ============================================================================ -# Sanitize raw lm-sensors JSON -# ============================================================================ - -sub _sanitize_sensors { - my ($sensors_output) = @_; - - $sensors_output =~ s/ERROR:.+\s(\w+):\s(.+)/\"$1\": 0.000,/g; - $sensors_output =~ s/ERROR:.+\s(\w+)!/\"$1\": 0.000,/g; - $sensors_output =~ s/,\s*(})/$1/g; - $sensors_output =~ s/\bNaN\b/null/g; - - # Fix duplicate SODIMM keys: "SODIMM":{"temp3_input":34.0} → "SODIMM3":{...} - $sensors_output =~ - s/\"SODIMM\":\{\"temp(\d+)_input\"/\"SODIMM$1\":\{\"temp$1_input\"/g; - - return $sensors_output; -} - -# ============================================================================ -# Enrich lm-sensors data with drive device info -# ============================================================================ - -sub _get_drive_names { - my ($sensors_output, $cache_ref) = @_; - $cache_ref //= {}; - - my $sensors_data; - eval { $sensors_data = decode_json($sensors_output); }; - if ($@) { - debug(__LINE__, "Failed to parse sensors JSON: $@"); - return $sensors_output; - } - - my @entries = grep { - /^drivetemp-scsi-/ || /^drivetemp-nvme-/ || /^nvme-pci-/ - } keys %{$sensors_data}; - - debug(__LINE__, "Found " . scalar(@entries) . " drive entries in lm-sensors output"); - - my @drive_names; - - foreach my $entry (@entries) { - my ($dev_path, $model, $serial) = ("unknown", "unknown", "unknown"); - - if (exists $cache_ref->{$entry}) { - my $cached = $cache_ref->{$entry}; - $dev_path = $cached->{device_path}; - $model = $cached->{model}; - $serial = $cached->{serial}; - debug(__LINE__, "Using cached drive info for $entry"); - } else { - # ----- SCSI/SATA ----- - if ($entry =~ /^drivetemp-scsi-(\d+)-(\d+)/) { - my ($host, $id) = ($1, $2); - my $scsi_path = "/sys/class/scsi_disk/$host:$id:0:0/device/block"; - - if (opendir(my $sdh, $scsi_path)) { - my @devs = grep { /^sd/ } readdir($sdh); - closedir($sdh); - if (@devs) { - $dev_path = "/dev/$devs[0]"; - $model = read_sysfs("/sys/class/block/$devs[0]/device/model"); - $serial = read_sysfs("/sys/class/block/$devs[0]/device/serial"); - } - } - - # ----- Numeric NVMe ----- - } elsif ($entry =~ /^drivetemp-nvme-(\d+)/) { - my $nvme_index = $1; - $dev_path = "/dev/nvme${nvme_index}n1"; - if (-e $dev_path) { - $model = read_sysfs("/sys/class/block/nvme${nvme_index}n1/device/model"); - $serial = read_sysfs("/sys/class/block/nvme${nvme_index}n1/device/serial"); - } - - # ----- PCI-style NVMe ----- - } elsif ($entry =~ /^nvme-pci-(\w+)/) { - my $pci_addr = $1; - - # Convert short PCI address (e.g. "0600") to pattern (e.g. "0000:06:00") - my $pci_pattern; - if ($pci_addr =~ /^([0-9a-f]{2})([0-9a-f]{2})$/i) { - my ($bus, $dev) = ($1, $2); - $pci_pattern = sprintf("%04x:%02x:%02x", 0, hex($bus), hex($dev)); - debug(__LINE__, "Converted PCI address $pci_addr to pattern $pci_pattern"); - } else { - $pci_pattern = $pci_addr; - } - - my $found = 0; - my $nvme_dir = "/sys/class/nvme"; - - debug(__LINE__, - "Searching for NVMe devices in $nvme_dir matching PCI pattern $pci_pattern"); - - if (opendir(my $ndh, $nvme_dir)) { - my @nvme_devs = - grep { /^nvme\d+$/ && -d "$nvme_dir/$_" } readdir($ndh); - closedir($ndh); - - debug(__LINE__, "Found NVMe devices: " . join(", ", @nvme_devs)); - - foreach my $nvme_dev (@nvme_devs) { - my $device_link = readlink("$nvme_dir/$nvme_dev/device"); - if ($device_link && $device_link =~ /$pci_pattern/) { - debug(__LINE__, - "NVMe device $nvme_dev matches PCI pattern $pci_pattern"); - $dev_path = "/dev/${nvme_dev}n1"; - $model = read_sysfs("$nvme_dir/$nvme_dev/model"); - $serial = read_sysfs("$nvme_dir/$nvme_dev/serial"); - $found = 1; - debug(__LINE__, - "Found NVMe device via /sys/class/nvme: $dev_path"); - last; - } - debug(__LINE__, - "NVMe device $nvme_dev did not match PCI pattern $pci_pattern"); - } - } - - # Fallback: scan /sys/class/block - if (!$found && opendir(my $bdh, "/sys/class/block")) { - my @block_devs = grep { /^nvme\d+n\d+$/ } readdir($bdh); - closedir($bdh); - - foreach my $block_dev (@block_devs) { - my $device_link = - readlink("/sys/class/block/$block_dev/device"); - if ($device_link && $device_link =~ /$pci_pattern/) { - $dev_path = "/dev/$block_dev"; - (my $nvme_ctrl = $block_dev) =~ s/n\d+$//; - $model = read_sysfs("/sys/class/nvme/$nvme_ctrl/model"); - $serial = read_sysfs("/sys/class/nvme/$nvme_ctrl/serial"); - $found = 1; - debug(__LINE__, - "Found NVMe device via /sys/class/block: $dev_path"); - last; - } - } - } - - unless ($found) { - debug(__LINE__, - "Could not find device for nvme-pci-$pci_addr (pattern: $pci_pattern)"); - } - } else { - next; - } - - $cache_ref->{$entry} = { - device_path => $dev_path, - model => $model, - serial => $serial, - }; - - debug(__LINE__, "Drive: $entry -> $dev_path (Model: $model, Serial: $serial)"); - } - - push @drive_names, [$entry, $dev_path, $model, $serial]; - } - - foreach my $drive_entry (@drive_names) { - my ($original_name, $dev_path, $model, $serial) = @$drive_entry; - if (exists $sensors_data->{$original_name}) { - $sensors_data->{$original_name}->{device_path} = $dev_path; - $sensors_data->{$original_name}->{model} = $model; - $sensors_data->{$original_name}->{serial} = $serial; - debug(__LINE__, "Enhanced $original_name with drive info"); - } - } - - return JSON->new->pretty->canonical->encode($sensors_data); -} - -# ============================================================================ -# Enrich lm-sensors data with CPU model info -# ============================================================================ - -sub _get_cpu_name { - my ($sensors_output, $cache_ref) = @_; - $cache_ref //= {}; - - my $sensors_data; - eval { $sensors_data = decode_json($sensors_output); }; - if ($@) { - debug(__LINE__, "Failed to parse sensors JSON: $@"); - return $sensors_output; - } - - my @entries = - grep { /^coretemp-isa-/ || /^k10temp-pci-/ } keys %{$sensors_data}; - - debug(__LINE__, "Found " . scalar(@entries) . " CPU entries in sensors output"); - - foreach my $entry (@entries) { - my ($cpu_model, $pkg) = ("unknown", "unknown"); - - if (exists $cache_ref->{$entry}) { - my $cached = $cache_ref->{$entry}; - $cpu_model = $cached->{model}; - $pkg = $cached->{package}; - debug(__LINE__, "Using cached CPU info for $entry"); - } else { - # ----- Intel coretemp ----- - if ($entry =~ /^coretemp-isa-(\d+)/) { - for my $hwmon (glob "/sys/class/hwmon/hwmon*") { - my $name = read_sysfs("$hwmon/name"); - next unless $name eq 'coretemp'; - - my $dev = readlink("$hwmon/device"); - next unless $dev; - - if ($dev =~ /\.([0-9]+)$/) { - $pkg = $1; - $cpu_model = _cpu_model_by_package($pkg); - debug(__LINE__, - "Found Intel CPU: $entry -> Package $pkg, Model: $cpu_model"); - last; - } - } - } - - # ----- AMD k10temp ----- - elsif ($entry =~ /^k10temp-pci-(\w+)/) { - my $pci_addr = $1; - my $pci_pattern = $pci_addr; - - if ($pci_addr =~ /^([0-9a-f]{2})([0-9a-f]{2})$/i) { - my ($bus, $dev_func) = ($1, $2); - $pci_pattern = - sprintf("%04x:%02x:%02x", 0, hex($bus), hex($dev_func)); - debug(__LINE__, - "Converted PCI address $pci_addr to pattern $pci_pattern"); - } - - for my $hwmon (glob "/sys/class/hwmon/hwmon*") { - my $name = read_sysfs("$hwmon/name"); - next unless $name eq 'k10temp'; - - my $dev = readlink("$hwmon/device"); - next unless $dev; - - if ($dev =~ /$pci_pattern/ || $dev =~ /$pci_addr/) { - $pkg = 0; - - if (opendir(my $dh, "/sys/devices/system/cpu")) { - my @cpus = grep { /^cpu\d+$/ } readdir($dh); - closedir($dh); - - foreach my $cpu (@cpus) { - my $cpu_pkg = read_sysfs( - "/sys/devices/system/cpu/$cpu/topology/physical_package_id"); - if ($cpu_pkg ne "unknown" && $cpu_pkg =~ /^\d+$/) { - $pkg = $cpu_pkg; - last; - } - } - } - - $cpu_model = _cpu_model_by_package($pkg); - debug(__LINE__, - "Found AMD CPU: $entry -> Package $pkg, Model: $cpu_model"); - last; - } - } - } - - $cache_ref->{$entry} = { model => $cpu_model, package => $pkg }; - debug(__LINE__, "CPU: $entry -> Package $pkg (Model: $cpu_model)"); - } - - if (exists $sensors_data->{$entry}) { - $sensors_data->{$entry}->{cpu_model} = $cpu_model; - $sensors_data->{$entry}->{cpu_package} = $pkg; - debug(__LINE__, "Enhanced $entry with CPU info"); - } - } - - return JSON->new->pretty->canonical->encode($sensors_data); -} - -# ============================================================================ -# CPU model lookup helper -# ============================================================================ - -sub _cpu_model_by_package { - my ($pkg) = @_; - - if (open my $fh, '<', '/proc/cpuinfo') { - my $current_pkg = -1; - my $model_name = "unknown"; - - while (my $line = <$fh>) { - chomp $line; - - if ($line =~ /^physical id\s+:\s+(\d+)/) { - $current_pkg = $1; - } - - if ($line =~ /^model name\s+:\s+(.+)$/) { - $model_name = $1; - $model_name =~ s/^\s+|\s+$//g; - - if ($current_pkg == $pkg) { - close($fh); - return $model_name; - } - } - } - close($fh); - - return $model_name if $model_name ne "unknown"; - } - - return "unknown"; -} - -1; +package PVE::PVEMod::Collector::LmSensors; + +use strict; +use warnings; +use Exporter 'import'; + +use JSON; + +use PVE::PVEMod::Config qw(%config $process_type $sensors_state_file); +use PVE::PVEMod::Utils qw(debug check_executable setup_collector_signals read_sysfs); + +our @EXPORT_OK = qw( + collector_for_temperature_sensors +); + +# ============================================================================ +# Temperature Sensors — long-running collector +# ============================================================================ + +sub collector_for_temperature_sensors { + my ($device) = @_; + $process_type = 'collector'; + $0 = "collector-temperature-sensors"; + my %cache; + my $shutdown = 0; + setup_collector_signals('temperature-sensors', \$shutdown); + + while (!$shutdown) { + my $sensors_data = _get_temperature_sensors(\%cache); + + eval { + open my $ofh, '>', $sensors_state_file + or die "Failed to open $sensors_state_file: $!"; + print $ofh $sensors_data; + close $ofh; + debug(__LINE__, "Wrote temperature sensor data to $sensors_state_file"); + }; + if ($@) { + debug(__LINE__, "Error writing temperature sensor data: $@"); + } + + sleep $config{intervals}{data_pull} unless $shutdown; + } + + debug(__LINE__, "Temperature sensor collector shutting down"); + exit 0; +} + +# ============================================================================ +# Temperature Sensors — pipeline +# ============================================================================ + +sub _get_temperature_sensors { + my ($cache_ref) = @_; + + my $sensors_output; + + if ($config{debug}{lm_sensors_mode} && -f $config{debug}{lm_sensors_output_file}) { + debug(__LINE__, "Debug mode: reading lm-sensors data from $config{debug}{lm_sensors_output_file}"); + if (open my $fh, '<', $config{debug}{lm_sensors_output_file}) { + local $/; + $sensors_output = <$fh>; + close $fh; + debug(__LINE__, "Read lm-sensors data from debug file, length: " + . length($sensors_output) . " bytes"); + } else { + debug(__LINE__, "Failed to open debug file $config{debug}{lm_sensors_output_file}: $!"); + $sensors_output = '{}'; + } + } else { + $sensors_output = `sensors -j 2>/dev/null | python3 -m json.tool`; + debug(__LINE__, "Raw lm-sensors output collected from command"); + } + + debug(__LINE__, "Raw lm-sensors output collected"); + + my $data = _sanitize_sensors($sensors_output); + debug(__LINE__, "Sanitized lm-sensors output"); + + $data = _get_drive_names($data, $cache_ref); + debug(__LINE__, "Translated drive names in lm-sensors output"); + + $data = _get_cpu_name($data, $cache_ref); + debug(__LINE__, "Translated CPU names in lm-sensors output"); + + # Wrap in top-level key + my $sensors_json; + eval { $sensors_json = decode_json($data); }; + if ($@) { + debug(__LINE__, "Failed to parse final lm-sensors JSON: $@"); + return $data; + } + + $data = JSON->new->pretty->encode({ "PVE MOD lm-sensors Enhanced" => $sensors_json }); + + return $data; +} + +# ============================================================================ +# Sanitize raw lm-sensors JSON +# ============================================================================ + +sub _sanitize_sensors { + my ($sensors_output) = @_; + + $sensors_output =~ s/ERROR:.+\s(\w+):\s(.+)/\"$1\": 0.000,/g; + $sensors_output =~ s/ERROR:.+\s(\w+)!/\"$1\": 0.000,/g; + $sensors_output =~ s/,\s*(})/$1/g; + $sensors_output =~ s/\bNaN\b/null/g; + + # Fix duplicate SODIMM keys: "SODIMM":{"temp3_input":34.0} → "SODIMM3":{...} + $sensors_output =~ + s/\"SODIMM\":\{\"temp(\d+)_input\"/\"SODIMM$1\":\{\"temp$1_input\"/g; + + return $sensors_output; +} + +# ============================================================================ +# Enrich lm-sensors data with drive device info +# ============================================================================ + +sub _get_drive_names { + my ($sensors_output, $cache_ref) = @_; + $cache_ref //= {}; + + my $sensors_data; + eval { $sensors_data = decode_json($sensors_output); }; + if ($@) { + debug(__LINE__, "Failed to parse sensors JSON: $@"); + return $sensors_output; + } + + my @entries = grep { + /^drivetemp-scsi-/ || /^drivetemp-nvme-/ || /^nvme-pci-/ + } keys %{$sensors_data}; + + debug(__LINE__, "Found " . scalar(@entries) . " drive entries in lm-sensors output"); + + my @drive_names; + + foreach my $entry (@entries) { + my ($dev_path, $model, $serial) = ("unknown", "unknown", "unknown"); + + if (exists $cache_ref->{$entry}) { + my $cached = $cache_ref->{$entry}; + $dev_path = $cached->{device_path}; + $model = $cached->{model}; + $serial = $cached->{serial}; + debug(__LINE__, "Using cached drive info for $entry"); + } else { + # ----- SCSI/SATA ----- + if ($entry =~ /^drivetemp-scsi-(\d+)-(\d+)/) { + my ($host, $id) = ($1, $2); + my $scsi_path = "/sys/class/scsi_disk/$host:$id:0:0/device/block"; + + if (opendir(my $sdh, $scsi_path)) { + my @devs = grep { /^sd/ } readdir($sdh); + closedir($sdh); + if (@devs) { + $dev_path = "/dev/$devs[0]"; + $model = read_sysfs("/sys/class/block/$devs[0]/device/model"); + $serial = read_sysfs("/sys/class/block/$devs[0]/device/serial"); + } + } + + # ----- Numeric NVMe ----- + } elsif ($entry =~ /^drivetemp-nvme-(\d+)/) { + my $nvme_index = $1; + $dev_path = "/dev/nvme${nvme_index}n1"; + if (-e $dev_path) { + $model = read_sysfs("/sys/class/block/nvme${nvme_index}n1/device/model"); + $serial = read_sysfs("/sys/class/block/nvme${nvme_index}n1/device/serial"); + } + + # ----- PCI-style NVMe ----- + } elsif ($entry =~ /^nvme-pci-(\w+)/) { + my $pci_addr = $1; + + # Convert short PCI address (e.g. "0600") to pattern (e.g. "0000:06:00") + my $pci_pattern; + if ($pci_addr =~ /^([0-9a-f]{2})([0-9a-f]{2})$/i) { + my ($bus, $dev) = ($1, $2); + $pci_pattern = sprintf("%04x:%02x:%02x", 0, hex($bus), hex($dev)); + debug(__LINE__, "Converted PCI address $pci_addr to pattern $pci_pattern"); + } else { + $pci_pattern = $pci_addr; + } + + my $found = 0; + my $nvme_dir = "/sys/class/nvme"; + + debug(__LINE__, + "Searching for NVMe devices in $nvme_dir matching PCI pattern $pci_pattern"); + + if (opendir(my $ndh, $nvme_dir)) { + my @nvme_devs = + grep { /^nvme\d+$/ && -d "$nvme_dir/$_" } readdir($ndh); + closedir($ndh); + + debug(__LINE__, "Found NVMe devices: " . join(", ", @nvme_devs)); + + foreach my $nvme_dev (@nvme_devs) { + my $device_link = readlink("$nvme_dir/$nvme_dev/device"); + if ($device_link && $device_link =~ /$pci_pattern/) { + debug(__LINE__, + "NVMe device $nvme_dev matches PCI pattern $pci_pattern"); + $dev_path = "/dev/${nvme_dev}n1"; + $model = read_sysfs("$nvme_dir/$nvme_dev/model"); + $serial = read_sysfs("$nvme_dir/$nvme_dev/serial"); + $found = 1; + debug(__LINE__, + "Found NVMe device via /sys/class/nvme: $dev_path"); + last; + } + debug(__LINE__, + "NVMe device $nvme_dev did not match PCI pattern $pci_pattern"); + } + } + + # Fallback: scan /sys/class/block + if (!$found && opendir(my $bdh, "/sys/class/block")) { + my @block_devs = grep { /^nvme\d+n\d+$/ } readdir($bdh); + closedir($bdh); + + foreach my $block_dev (@block_devs) { + my $device_link = + readlink("/sys/class/block/$block_dev/device"); + if ($device_link && $device_link =~ /$pci_pattern/) { + $dev_path = "/dev/$block_dev"; + (my $nvme_ctrl = $block_dev) =~ s/n\d+$//; + $model = read_sysfs("/sys/class/nvme/$nvme_ctrl/model"); + $serial = read_sysfs("/sys/class/nvme/$nvme_ctrl/serial"); + $found = 1; + debug(__LINE__, + "Found NVMe device via /sys/class/block: $dev_path"); + last; + } + } + } + + unless ($found) { + debug(__LINE__, + "Could not find device for nvme-pci-$pci_addr (pattern: $pci_pattern)"); + } + } else { + next; + } + + $cache_ref->{$entry} = { + device_path => $dev_path, + model => $model, + serial => $serial, + }; + + debug(__LINE__, "Drive: $entry -> $dev_path (Model: $model, Serial: $serial)"); + } + + push @drive_names, [$entry, $dev_path, $model, $serial]; + } + + foreach my $drive_entry (@drive_names) { + my ($original_name, $dev_path, $model, $serial) = @$drive_entry; + if (exists $sensors_data->{$original_name}) { + $sensors_data->{$original_name}->{device_path} = $dev_path; + $sensors_data->{$original_name}->{model} = $model; + $sensors_data->{$original_name}->{serial} = $serial; + debug(__LINE__, "Enhanced $original_name with drive info"); + } + } + + return JSON->new->pretty->canonical->encode($sensors_data); +} + +# ============================================================================ +# Enrich lm-sensors data with CPU model info +# ============================================================================ + +sub _get_cpu_name { + my ($sensors_output, $cache_ref) = @_; + $cache_ref //= {}; + + my $sensors_data; + eval { $sensors_data = decode_json($sensors_output); }; + if ($@) { + debug(__LINE__, "Failed to parse sensors JSON: $@"); + return $sensors_output; + } + + my @entries = + grep { /^coretemp-isa-/ || /^k10temp-pci-/ } keys %{$sensors_data}; + + debug(__LINE__, "Found " . scalar(@entries) . " CPU entries in sensors output"); + + foreach my $entry (@entries) { + my ($cpu_model, $pkg) = ("unknown", "unknown"); + + if (exists $cache_ref->{$entry}) { + my $cached = $cache_ref->{$entry}; + $cpu_model = $cached->{model}; + $pkg = $cached->{package}; + debug(__LINE__, "Using cached CPU info for $entry"); + } else { + # ----- Intel coretemp ----- + if ($entry =~ /^coretemp-isa-(\d+)/) { + for my $hwmon (glob "/sys/class/hwmon/hwmon*") { + my $name = read_sysfs("$hwmon/name"); + next unless $name eq 'coretemp'; + + my $dev = readlink("$hwmon/device"); + next unless $dev; + + if ($dev =~ /\.([0-9]+)$/) { + $pkg = $1; + $cpu_model = _cpu_model_by_package($pkg); + debug(__LINE__, + "Found Intel CPU: $entry -> Package $pkg, Model: $cpu_model"); + last; + } + } + } + + # ----- AMD k10temp ----- + elsif ($entry =~ /^k10temp-pci-(\w+)/) { + my $pci_addr = $1; + my $pci_pattern = $pci_addr; + + if ($pci_addr =~ /^([0-9a-f]{2})([0-9a-f]{2})$/i) { + my ($bus, $dev_func) = ($1, $2); + $pci_pattern = + sprintf("%04x:%02x:%02x", 0, hex($bus), hex($dev_func)); + debug(__LINE__, + "Converted PCI address $pci_addr to pattern $pci_pattern"); + } + + for my $hwmon (glob "/sys/class/hwmon/hwmon*") { + my $name = read_sysfs("$hwmon/name"); + next unless $name eq 'k10temp'; + + my $dev = readlink("$hwmon/device"); + next unless $dev; + + if ($dev =~ /$pci_pattern/ || $dev =~ /$pci_addr/) { + $pkg = 0; + + if (opendir(my $dh, "/sys/devices/system/cpu")) { + my @cpus = grep { /^cpu\d+$/ } readdir($dh); + closedir($dh); + + foreach my $cpu (@cpus) { + my $cpu_pkg = read_sysfs( + "/sys/devices/system/cpu/$cpu/topology/physical_package_id"); + if ($cpu_pkg ne "unknown" && $cpu_pkg =~ /^\d+$/) { + $pkg = $cpu_pkg; + last; + } + } + } + + $cpu_model = _cpu_model_by_package($pkg); + debug(__LINE__, + "Found AMD CPU: $entry -> Package $pkg, Model: $cpu_model"); + last; + } + } + } + + $cache_ref->{$entry} = { model => $cpu_model, package => $pkg }; + debug(__LINE__, "CPU: $entry -> Package $pkg (Model: $cpu_model)"); + } + + if (exists $sensors_data->{$entry}) { + $sensors_data->{$entry}->{cpu_model} = $cpu_model; + $sensors_data->{$entry}->{cpu_package} = $pkg; + debug(__LINE__, "Enhanced $entry with CPU info"); + } + } + + return JSON->new->pretty->canonical->encode($sensors_data); +} + +# ============================================================================ +# CPU model lookup helper +# ============================================================================ + +sub _cpu_model_by_package { + my ($pkg) = @_; + + if (open my $fh, '<', '/proc/cpuinfo') { + my $current_pkg = -1; + my $model_name = "unknown"; + + while (my $line = <$fh>) { + chomp $line; + + if ($line =~ /^physical id\s+:\s+(\d+)/) { + $current_pkg = $1; + } + + if ($line =~ /^model name\s+:\s+(.+)$/) { + $model_name = $1; + $model_name =~ s/^\s+|\s+$//g; + + if ($current_pkg == $pkg) { + close($fh); + return $model_name; + } + } + } + close($fh); + + return $model_name if $model_name ne "unknown"; + } + + return "unknown"; +} + +1; diff --git a/src/node_info/files/Collector/Nvidia.pm b/src/node_info/files/Collector/Nvidia.pm index 42508c7..9972c27 100644 --- a/src/node_info/files/Collector/Nvidia.pm +++ b/src/node_info/files/Collector/Nvidia.pm @@ -1,212 +1,212 @@ -package PVE::PVEMod::Collector::Nvidia; - -use strict; -use warnings; -use Exporter 'import'; - -use PVE::PVEMod::Config qw(%config $process_type $pve_mod_working_dir); -use PVE::PVEMod::Utils qw(debug check_executable setup_collector_signals safe_write_json parse_csv_line); -use PVE::PVEMod::Store qw(update_nvidia_gpu_rrd); - -our @EXPORT_OK = qw( - get_nvidia_gpu_devices - collector_for_nvidia_devices -); - -# ============================================================================ -# NVIDIA GPU — device discovery -# ============================================================================ - -sub get_nvidia_gpu_devices { - my @devices = (); - - if ($config{debug}{nvidia_mode} && -f $config{debug}{nvidia_devices_file}) { - debug(__LINE__, "Debug mode: reading NVIDIA GPU devices from $config{debug}{nvidia_devices_file}"); - if (open my $fh, '<', $config{debug}{nvidia_devices_file}) { - my $line_num = 0; - while (<$fh>) { - chomp; - $line_num++; - next if $line_num == 1 || /^\s*$/; - my @values = parse_csv_line($_, 2); - if (@values) { - push @devices, { index => $values[0], name => $values[1] }; - debug(__LINE__, "Found NVIDIA GPU device (debug): $values[1] (index: $values[0])"); - } - } - close $fh; - } else { - debug(__LINE__, "Failed to open debug file $config{debug}{nvidia_devices_file}: $!"); - } - } else { - if (open my $fh, '-|', 'nvidia-smi --query-gpu=index,name --format=csv') { - my $line_num = 0; - while (<$fh>) { - chomp; - $line_num++; - next if $line_num == 1 || /^\s*$/; - my @values = parse_csv_line($_, 2); - if (@values) { - push @devices, { index => $values[0], name => $values[1] }; - debug(__LINE__, "Found NVIDIA GPU device: $values[1] (index: $values[0])"); - } - } - close $fh; - } - } - - return @devices; -} - -# ============================================================================ -# NVIDIA GPU — data parsing -# ============================================================================ - -sub _parse_nvidia_gpu_line { - my ($line) = @_; - - # Expected CSV format: - # index, name, temperature.gpu, utilization.gpu, utilization.memory, - # memory.used, memory.total, power.draw, power.limit, fan.speed - - my @values = parse_csv_line($line, 10); - return unless @values; - - return { - index => $values[0] + 0, - name => $values[1], - temperature => { - gpu => $values[2] + 0.0, - unit => "°C", - }, - utilization => { - gpu => $values[3] + 0.0, - memory => $values[4] + 0.0, - unit => "%", - }, - memory => { - used => $values[5] + 0.0, - total => $values[6] + 0.0, - unit => "MiB", - }, - power => { - draw => $values[7] + 0.0, - limit => $values[8] + 0.0, - unit => "W", - }, - fan => { - speed => $values[9] + 0.0, - unit => "%", - }, - }; -} - -# ============================================================================ -# NVIDIA GPU — stat collection and write -# ============================================================================ - -sub _get_and_write_nvidia_stats { - my ($devices) = @_; - my @all_stats; - - if ($config{debug}{nvidia_mode} && -f $config{debug}{nvidia_output_file}) { - debug(__LINE__, "Debug mode: reading NVIDIA GPU stats from $config{debug}{nvidia_output_file}"); - if (open my $fh, '<', $config{debug}{nvidia_output_file}) { - my $line_num = 0; - while (<$fh>) { - chomp; - $line_num++; - next if $line_num == 1 || /^\s*$/; - my $stats = _parse_nvidia_gpu_line($_); - push @all_stats, $stats if $stats; - } - close $fh; - } else { - debug(__LINE__, "Failed to open debug file $config{debug}{nvidia_output_file}: $!"); - } - } else { - unless (check_executable('/usr/bin/nvidia-smi', 'NVIDIA')) { - debug(__LINE__, "nvidia-smi not available, cannot collect stats"); - return 0; - } - - my $query = 'index,name,temperature.gpu,utilization.gpu,utilization.memory,' - . 'memory.used,memory.total,power.draw,power.limit,fan.speed'; - my $cmd = "nvidia-smi --query-gpu=$query --format=csv,nounits"; - - if (open my $fh, '-|', $cmd) { - my $line_num = 0; - while (<$fh>) { - chomp; - $line_num++; - next if $line_num == 1 || /^\s*$/; - my $stats = _parse_nvidia_gpu_line($_); - push @all_stats, $stats if $stats; - } - close $fh; - } - } - - foreach my $stats (@all_stats) { - my $device_index = $stats->{index}; - - unless ($device_index =~ /^(\d+)$/) { - debug(__LINE__, "Invalid device index: $device_index, skipping"); - next; - } - $device_index = $1; # untainted - - my $node_name = "gpu$device_index"; - my $device_state_file = "$pve_mod_working_dir/stats-nvidia$device_index.json"; - - my $device_name = $stats->{name}; - foreach my $dev (@$devices) { - if ($dev->{index} == $device_index) { - $device_name = $dev->{name}; - last; - } - } - - my $device_data = { - $node_name => { - name => $device_name, - index => $device_index, - stats => $stats, - } - }; - - safe_write_json($device_state_file, $device_data); - update_nvidia_gpu_rrd($device_index, $stats); - } - - unless (@all_stats) { - debug(__LINE__, "No valid NVIDIA GPU stats collected"); - } - - return scalar(@all_stats); -} - -# ============================================================================ -# NVIDIA GPU — long-running collector (all devices in one process) -# ============================================================================ - -sub collector_for_nvidia_devices { - my ($devices) = @_; - $process_type = 'collector'; - $0 = "collector-gpu-nvidia-all"; - - debug(__LINE__, "NVIDIA collector started for " . scalar(@$devices) . " GPU(s)"); - - my $shutdown = 0; - setup_collector_signals('nvidia-all', \$shutdown); - - while (!$shutdown) { - _get_and_write_nvidia_stats($devices); - sleep $config{intervals}{data_pull} unless $shutdown; - } - - debug(__LINE__, "NVIDIA collector shutting down"); - exit 0; -} - -1; +package PVE::PVEMod::Collector::Nvidia; + +use strict; +use warnings; +use Exporter 'import'; + +use PVE::PVEMod::Config qw(%config $process_type $pve_mod_working_dir); +use PVE::PVEMod::Utils qw(debug check_executable setup_collector_signals safe_write_json parse_csv_line); +use PVE::PVEMod::Store qw(update_nvidia_gpu_rrd); + +our @EXPORT_OK = qw( + get_nvidia_gpu_devices + collector_for_nvidia_devices +); + +# ============================================================================ +# NVIDIA GPU — device discovery +# ============================================================================ + +sub get_nvidia_gpu_devices { + my @devices = (); + + if ($config{debug}{nvidia_mode} && -f $config{debug}{nvidia_devices_file}) { + debug(__LINE__, "Debug mode: reading NVIDIA GPU devices from $config{debug}{nvidia_devices_file}"); + if (open my $fh, '<', $config{debug}{nvidia_devices_file}) { + my $line_num = 0; + while (<$fh>) { + chomp; + $line_num++; + next if $line_num == 1 || /^\s*$/; + my @values = parse_csv_line($_, 2); + if (@values) { + push @devices, { index => $values[0], name => $values[1] }; + debug(__LINE__, "Found NVIDIA GPU device (debug): $values[1] (index: $values[0])"); + } + } + close $fh; + } else { + debug(__LINE__, "Failed to open debug file $config{debug}{nvidia_devices_file}: $!"); + } + } else { + if (open my $fh, '-|', 'nvidia-smi --query-gpu=index,name --format=csv') { + my $line_num = 0; + while (<$fh>) { + chomp; + $line_num++; + next if $line_num == 1 || /^\s*$/; + my @values = parse_csv_line($_, 2); + if (@values) { + push @devices, { index => $values[0], name => $values[1] }; + debug(__LINE__, "Found NVIDIA GPU device: $values[1] (index: $values[0])"); + } + } + close $fh; + } + } + + return @devices; +} + +# ============================================================================ +# NVIDIA GPU — data parsing +# ============================================================================ + +sub _parse_nvidia_gpu_line { + my ($line) = @_; + + # Expected CSV format: + # index, name, temperature.gpu, utilization.gpu, utilization.memory, + # memory.used, memory.total, power.draw, power.limit, fan.speed + + my @values = parse_csv_line($line, 10); + return unless @values; + + return { + index => $values[0] + 0, + name => $values[1], + temperature => { + gpu => $values[2] + 0.0, + unit => "°C", + }, + utilization => { + gpu => $values[3] + 0.0, + memory => $values[4] + 0.0, + unit => "%", + }, + memory => { + used => $values[5] + 0.0, + total => $values[6] + 0.0, + unit => "MiB", + }, + power => { + draw => $values[7] + 0.0, + limit => $values[8] + 0.0, + unit => "W", + }, + fan => { + speed => $values[9] + 0.0, + unit => "%", + }, + }; +} + +# ============================================================================ +# NVIDIA GPU — stat collection and write +# ============================================================================ + +sub _get_and_write_nvidia_stats { + my ($devices) = @_; + my @all_stats; + + if ($config{debug}{nvidia_mode} && -f $config{debug}{nvidia_output_file}) { + debug(__LINE__, "Debug mode: reading NVIDIA GPU stats from $config{debug}{nvidia_output_file}"); + if (open my $fh, '<', $config{debug}{nvidia_output_file}) { + my $line_num = 0; + while (<$fh>) { + chomp; + $line_num++; + next if $line_num == 1 || /^\s*$/; + my $stats = _parse_nvidia_gpu_line($_); + push @all_stats, $stats if $stats; + } + close $fh; + } else { + debug(__LINE__, "Failed to open debug file $config{debug}{nvidia_output_file}: $!"); + } + } else { + unless (check_executable('/usr/bin/nvidia-smi', 'NVIDIA')) { + debug(__LINE__, "nvidia-smi not available, cannot collect stats"); + return 0; + } + + my $query = 'index,name,temperature.gpu,utilization.gpu,utilization.memory,' + . 'memory.used,memory.total,power.draw,power.limit,fan.speed'; + my $cmd = "nvidia-smi --query-gpu=$query --format=csv,nounits"; + + if (open my $fh, '-|', $cmd) { + my $line_num = 0; + while (<$fh>) { + chomp; + $line_num++; + next if $line_num == 1 || /^\s*$/; + my $stats = _parse_nvidia_gpu_line($_); + push @all_stats, $stats if $stats; + } + close $fh; + } + } + + foreach my $stats (@all_stats) { + my $device_index = $stats->{index}; + + unless ($device_index =~ /^(\d+)$/) { + debug(__LINE__, "Invalid device index: $device_index, skipping"); + next; + } + $device_index = $1; # untainted + + my $node_name = "gpu$device_index"; + my $device_state_file = "$pve_mod_working_dir/stats-nvidia$device_index.json"; + + my $device_name = $stats->{name}; + foreach my $dev (@$devices) { + if ($dev->{index} == $device_index) { + $device_name = $dev->{name}; + last; + } + } + + my $device_data = { + $node_name => { + name => $device_name, + index => $device_index, + stats => $stats, + } + }; + + safe_write_json($device_state_file, $device_data); + update_nvidia_gpu_rrd($device_index, $stats); + } + + unless (@all_stats) { + debug(__LINE__, "No valid NVIDIA GPU stats collected"); + } + + return scalar(@all_stats); +} + +# ============================================================================ +# NVIDIA GPU — long-running collector (all devices in one process) +# ============================================================================ + +sub collector_for_nvidia_devices { + my ($devices) = @_; + $process_type = 'collector'; + $0 = "collector-gpu-nvidia-all"; + + debug(__LINE__, "NVIDIA collector started for " . scalar(@$devices) . " GPU(s)"); + + my $shutdown = 0; + setup_collector_signals('nvidia-all', \$shutdown); + + while (!$shutdown) { + _get_and_write_nvidia_stats($devices); + sleep $config{intervals}{data_pull} unless $shutdown; + } + + debug(__LINE__, "NVIDIA collector shutting down"); + exit 0; +} + +1; diff --git a/src/node_info/files/Collector/Ups.pm b/src/node_info/files/Collector/Ups.pm index 261713f..2f36ecc 100644 --- a/src/node_info/files/Collector/Ups.pm +++ b/src/node_info/files/Collector/Ups.pm @@ -1,115 +1,115 @@ -package PVE::PVEMod::Collector::Ups; - -use strict; -use warnings; -use Exporter 'import'; - -use JSON; - -use PVE::PVEMod::Config qw($process_type $ups_state_file); -use PVE::PVEMod::Utils qw(debug setup_collector_signals); - -our @EXPORT_OK = qw( - collector_for_ups -); - -# ============================================================================ -# UPS — long-running collector -# ============================================================================ - -sub collector_for_ups { - my ($device) = @_; - $process_type = 'collector'; - $0 = "collector-ups-$device->{ups_name}"; - debug(__LINE__, "UPS collector started"); - - my $shutdown = 0; - setup_collector_signals("ups-$device->{ups_name}", \$shutdown); - - while (!$shutdown) { - my $ups_data = _get_ups_status($device->{ups_name}); - - eval { - open my $ofh, '>', $ups_state_file - or die "Failed to open $ups_state_file: $!"; - print $ofh $ups_data; - close $ofh; - debug(__LINE__, "Wrote UPS data to $ups_state_file"); - }; - if ($@) { - debug(__LINE__, "Error writing UPS data: $@"); - } - - sleep 1 unless $shutdown; # $config{intervals}{data_pull} - } - - debug(__LINE__, "UPS collector shutting down"); - exit 0; -} - -# ============================================================================ -# UPS — status query -# ============================================================================ - -sub _get_ups_status { - my ($ups_name) = @_; - - debug(__LINE__, "Collecting UPS status for $ups_name"); - - my $output = `/usr/bin/upsc $ups_name 2>/dev/null`; - - unless (defined $output && length($output) > 0) { - debug(__LINE__, "No output from upsc for $ups_name"); - return encode_json({ error => "No data from UPS $ups_name" }); - } - - my $ups_data = _parse_upsc_output($output); - - unless (keys %$ups_data) { - debug(__LINE__, "No data received from upsc for $ups_name"); - return encode_json({ error => "No data from UPS $ups_name" }); - } - - return JSON->new->pretty->canonical->encode({ $ups_name => $ups_data }); -} - -# ============================================================================ -# UPS — output parser -# ============================================================================ - -sub _parse_upsc_output { - my ($output) = @_; - - my $ups_data = {}; - - debug(__LINE__, "Parsing upsc output"); - - eval { - foreach my $line (split /\n/, $output) { - next if $line =~ /^\s*$/; - next if $line =~ /^Init SSL/; - - if ($line =~ /^([^:]+):\s*(.*)$/) { - my ($key, $value) = ($1, $2); - $key =~ s/^\s+|\s+$//g; - $value =~ s/^\s+|\s+$//g; - - # Coerce numeric values - if ($value =~ /^-?\d+\.?\d*$/) { - $ups_data->{$key} = $value + 0; - } else { - $ups_data->{$key} = $value; - } - } - } - }; - if ($@) { - debug(__LINE__, "Error parsing upsc output: $@"); - } - - debug(__LINE__, "Completed parsing upsc output"); - - return $ups_data; -} - -1; +package PVE::PVEMod::Collector::Ups; + +use strict; +use warnings; +use Exporter 'import'; + +use JSON; + +use PVE::PVEMod::Config qw($process_type $ups_state_file); +use PVE::PVEMod::Utils qw(debug setup_collector_signals); + +our @EXPORT_OK = qw( + collector_for_ups +); + +# ============================================================================ +# UPS — long-running collector +# ============================================================================ + +sub collector_for_ups { + my ($device) = @_; + $process_type = 'collector'; + $0 = "collector-ups-$device->{ups_name}"; + debug(__LINE__, "UPS collector started"); + + my $shutdown = 0; + setup_collector_signals("ups-$device->{ups_name}", \$shutdown); + + while (!$shutdown) { + my $ups_data = _get_ups_status($device->{ups_name}); + + eval { + open my $ofh, '>', $ups_state_file + or die "Failed to open $ups_state_file: $!"; + print $ofh $ups_data; + close $ofh; + debug(__LINE__, "Wrote UPS data to $ups_state_file"); + }; + if ($@) { + debug(__LINE__, "Error writing UPS data: $@"); + } + + sleep 1 unless $shutdown; # $config{intervals}{data_pull} + } + + debug(__LINE__, "UPS collector shutting down"); + exit 0; +} + +# ============================================================================ +# UPS — status query +# ============================================================================ + +sub _get_ups_status { + my ($ups_name) = @_; + + debug(__LINE__, "Collecting UPS status for $ups_name"); + + my $output = `/usr/bin/upsc $ups_name 2>/dev/null`; + + unless (defined $output && length($output) > 0) { + debug(__LINE__, "No output from upsc for $ups_name"); + return encode_json({ error => "No data from UPS $ups_name" }); + } + + my $ups_data = _parse_upsc_output($output); + + unless (keys %$ups_data) { + debug(__LINE__, "No data received from upsc for $ups_name"); + return encode_json({ error => "No data from UPS $ups_name" }); + } + + return JSON->new->pretty->canonical->encode({ $ups_name => $ups_data }); +} + +# ============================================================================ +# UPS — output parser +# ============================================================================ + +sub _parse_upsc_output { + my ($output) = @_; + + my $ups_data = {}; + + debug(__LINE__, "Parsing upsc output"); + + eval { + foreach my $line (split /\n/, $output) { + next if $line =~ /^\s*$/; + next if $line =~ /^Init SSL/; + + if ($line =~ /^([^:]+):\s*(.*)$/) { + my ($key, $value) = ($1, $2); + $key =~ s/^\s+|\s+$//g; + $value =~ s/^\s+|\s+$//g; + + # Coerce numeric values + if ($value =~ /^-?\d+\.?\d*$/) { + $ups_data->{$key} = $value + 0; + } else { + $ups_data->{$key} = $value; + } + } + } + }; + if ($@) { + debug(__LINE__, "Error parsing upsc output: $@"); + } + + debug(__LINE__, "Completed parsing upsc output"); + + return $ups_data; +} + +1; diff --git a/src/node_info/files/Collector/systemInformation.pm b/src/node_info/files/Collector/systemInformation.pm index de43892..9e7af70 100644 --- a/src/node_info/files/Collector/systemInformation.pm +++ b/src/node_info/files/Collector/systemInformation.pm @@ -1,96 +1,96 @@ -package PVE::PVEMod::Collector::SystemInformation; - -use strict; -use warnings; -use Exporter 'import'; - -use PVE::PVEMod::Config qw(%config); -use PVE::PVEMod::Utils qw(debug); - -our @EXPORT_OK = qw( - get_system_information_data -); - -# ============================================================================ -# System Information — one-time dmidecode call -# ============================================================================ - -sub get_system_information_data { - unless ($config{system_info}{enabled}) { - debug(__LINE__, "System information collection is disabled"); - return {}; - } - - my $raw_type = $config{system_info}{type}; - - # Taint-safe: only allow type 1 (System) or 2 (Baseboard/Motherboard) - my $type; - if (defined $raw_type && $raw_type =~ /^([12])$/) { - $type = $1; - } else { - debug(__LINE__, "Invalid system_info type '${\($raw_type // 'undef')}', defaulting to 1"); - $type = 1; - } - - debug(__LINE__, "Collecting system information via dmidecode -t $type"); - - return _get_system_info($type); -} - -# ============================================================================ -# Internal — run dmidecode and parse output -# ============================================================================ - -sub _get_system_info { - my ($type) = @_; - - my $output = `/usr/sbin/dmidecode -t $type 2>/dev/null`; - - unless (defined $output && length($output) > 0) { - debug(__LINE__, "No output from dmidecode -t $type"); - return {}; - } - - my %fields; - my @field_order; - - for my $line (split /\n/, $output) { - if ($line =~ /^\s+(Manufacturer|Product Name|Serial Number):\s*(.+)$/) { - my ($key, $value) = ($1, $2); - $value =~ s/^\s+|\s+$//g; - - my $field_key = lc($key); - $field_key =~ s/ /_/g; - - unless (exists $fields{$field_key}) { - push @field_order, $field_key; - $fields{$field_key} = $value; - } - } - } - - unless (%fields) { - debug(__LINE__, "No recognised fields found in dmidecode output"); - return {}; - } - - # Build display string: "Manufacturer: X | Product Name: Y | Serial Number: Z" - my %pretty_key = ( - manufacturer => 'Manufacturer', - product_name => 'Product Name', - serial_number => 'Serial Number', - ); - - my @parts; - for my $key (@field_order) { - my $label = $pretty_key{$key} // $key; - push @parts, "$label: $fields{$key}"; - } - $fields{display_string} = join(' | ', @parts); - - debug(__LINE__, "System information: $fields{display_string}"); - - return \%fields; -} - -1; +package PVE::PVEMod::Collector::SystemInformation; + +use strict; +use warnings; +use Exporter 'import'; + +use PVE::PVEMod::Config qw(%config); +use PVE::PVEMod::Utils qw(debug); + +our @EXPORT_OK = qw( + get_system_information_data +); + +# ============================================================================ +# System Information — one-time dmidecode call +# ============================================================================ + +sub get_system_information_data { + unless ($config{system_info}{enabled}) { + debug(__LINE__, "System information collection is disabled"); + return {}; + } + + my $raw_type = $config{system_info}{type}; + + # Taint-safe: only allow type 1 (System) or 2 (Baseboard/Motherboard) + my $type; + if (defined $raw_type && $raw_type =~ /^([12])$/) { + $type = $1; + } else { + debug(__LINE__, "Invalid system_info type '${\($raw_type // 'undef')}', defaulting to 1"); + $type = 1; + } + + debug(__LINE__, "Collecting system information via dmidecode -t $type"); + + return _get_system_info($type); +} + +# ============================================================================ +# Internal — run dmidecode and parse output +# ============================================================================ + +sub _get_system_info { + my ($type) = @_; + + my $output = `/usr/sbin/dmidecode -t $type 2>/dev/null`; + + unless (defined $output && length($output) > 0) { + debug(__LINE__, "No output from dmidecode -t $type"); + return {}; + } + + my %fields; + my @field_order; + + for my $line (split /\n/, $output) { + if ($line =~ /^\s+(Manufacturer|Product Name|Serial Number):\s*(.+)$/) { + my ($key, $value) = ($1, $2); + $value =~ s/^\s+|\s+$//g; + + my $field_key = lc($key); + $field_key =~ s/ /_/g; + + unless (exists $fields{$field_key}) { + push @field_order, $field_key; + $fields{$field_key} = $value; + } + } + } + + unless (%fields) { + debug(__LINE__, "No recognised fields found in dmidecode output"); + return {}; + } + + # Build display string: "Manufacturer: X | Product Name: Y | Serial Number: Z" + my %pretty_key = ( + manufacturer => 'Manufacturer', + product_name => 'Product Name', + serial_number => 'Serial Number', + ); + + my @parts; + for my $key (@field_order) { + my $label = $pretty_key{$key} // $key; + push @parts, "$label: $fields{$key}"; + } + $fields{display_string} = join(' | ', @parts); + + debug(__LINE__, "System information: $fields{display_string}"); + + return \%fields; +} + +1; diff --git a/src/node_info/files/Config.pm b/src/node_info/files/Config.pm index 4678f72..f306f4f 100644 --- a/src/node_info/files/Config.pm +++ b/src/node_info/files/Config.pm @@ -1,151 +1,151 @@ -package PVE::PVEMod::Config; - -use strict; -use warnings; -use Exporter 'import'; - -our @EXPORT_OK = qw( - %config - $DEBUG_ENABLED $VERSION $process_type - $pve_mod_working_dir $stats_dir $state_file - $sensors_state_file $ups_state_file - $pve_mod_worker_lock $startup_lock - $RRD_SOCKET $RRD_BASE -); - -# ============================================================================ -# Debug / Version -# ============================================================================ - -our $DEBUG_ENABLED = 1; -our $VERSION = 'version-placeholder'; - -# Runtime process-type tag — set to 'worker' or 'collector' after fork. -# Each forked child gets its own copy of this variable. -our $process_type = 'main'; # 'main', 'worker', or 'collector' - -# ============================================================================ -# Configuration -# ============================================================================ - -our %config = ( - gpu => { - intel_enabled => 0, - amd_enabled => 0, - nvidia_enabled => 0, - gpu_history => 0, - }, - debug => { - log_enabled => 0, - log_file => '/tmp/pve-mod-debug.log', - lm_sensors_mode => 0, - lm_sensors_output_file => '/tmp/sensors-output.json', - intel_mode => 0, - intel_devices_file => '/tmp/intel-gpu-devices.json', - nvidia_mode => 0, - nvidia_devices_file => '/tmp/nvidia-smi-devices.csv', - nvidia_output_file => '/tmp/nvidia-smi-output.csv', - amd_mode => 0, - amd_devices_file => '/tmp/amd-gpu-devices.json', - ups_mode => 0, - ups_output_file => '/tmp/ups-output.json', - }, - intervals => { - data_pull => 1, # seconds between data pulls - collector_timeout => 10, # stop collectors after N seconds of inactivity - }, - lm_sensors => { - enabled => 0, - enable_cpu => 0, - cpu_temp_target => 'Core', - enable_ram_temp => 0, - enable_hdd_temp => 0, - enable_nvme_temp => 0, - enable_fan_speed => 0, - display_zero_speed_fans => 0, - temp_unit => 'C', - }, - ups => { - enabled => 0, - device_name => 'ups@localhost', - }, - system_info => { - enabled => 0, - type => 1, # 1 = System (dmidecode -t 1), 2 = Baseboard/Motherboard (dmidecode -t 2) - }, - paths => { - working_dir => '/run/pveproxy/pve-mod', - }, -); - -# ============================================================================ -# Derived paths -# ============================================================================ - -our $pve_mod_working_dir = $config{paths}{working_dir}; -our $stats_dir = $pve_mod_working_dir; -our $state_file = "$pve_mod_working_dir/stats.json"; -our $sensors_state_file = "$pve_mod_working_dir/sensors.json"; -our $ups_state_file = "$pve_mod_working_dir/ups.json"; -our $pve_mod_worker_lock = "$pve_mod_working_dir/pve_mod_worker.lock"; -our $startup_lock = "$pve_mod_working_dir/startup.lock"; - -# ============================================================================ -# RRD paths -# ============================================================================ - -our $RRD_SOCKET = '/var/run/rrdcached.sock'; -our $RRD_BASE = '/var/lib/rrdcached/db/pve-mod-gpu'; - -# ============================================================================ -# Load configuration from /etc/pve-mod/pve-mod.conf (INI format). -# Merges file values into %config, overriding compiled-in defaults. -# Safe to call multiple times; silently skips missing file or unknown keys. -# ============================================================================ - -sub _load_ini_file { - my $path = '/etc/pve-mod/pve-mod.conf'; - return unless -f $path; - - open my $fh, '<', $path or return; - my $section = ''; - - while (my $line = <$fh>) { - chomp $line; - $line =~ s/#.*//; # strip inline comments - $line =~ s/^\s+|\s+$//g; # trim whitespace - next unless length $line; - - if ($line =~ /^\[([^\]]+)\]$/) { - $section = $1; - next; - } - - if ($line =~ /^([^=]+)=(.*)$/) { - my ($key, $val) = ($1, $2); - $key =~ s/^\s+|\s+$//g; - $val =~ s/^\s+|\s+$//g; - - if ($section eq 'gpu' && exists $config{gpu}{$key}) { - $config{gpu}{$key} = $val; - } - elsif ($section eq 'lm_sensors' && exists $config{lm_sensors}{$key}) { - $config{lm_sensors}{$key} = $val; - } - elsif ($section eq 'ups' && exists $config{ups}{$key}) { - $config{ups}{$key} = $val; - } - elsif ($section eq 'system_info' && exists $config{system_info}{$key}) { - $config{system_info}{$key} = $val; - } - elsif ($section eq 'debug' && exists $config{debug}{$key}) { - $config{debug}{$key} = $val; - } - } - } - close $fh; -} - -_load_ini_file(); - -1; +package PVE::PVEMod::Config; + +use strict; +use warnings; +use Exporter 'import'; + +our @EXPORT_OK = qw( + %config + $DEBUG_ENABLED $VERSION $process_type + $pve_mod_working_dir $stats_dir $state_file + $sensors_state_file $ups_state_file + $pve_mod_worker_lock $startup_lock + $RRD_SOCKET $RRD_BASE +); + +# ============================================================================ +# Debug / Version +# ============================================================================ + +our $DEBUG_ENABLED = 1; +our $VERSION = 'version-placeholder'; + +# Runtime process-type tag — set to 'worker' or 'collector' after fork. +# Each forked child gets its own copy of this variable. +our $process_type = 'main'; # 'main', 'worker', or 'collector' + +# ============================================================================ +# Configuration +# ============================================================================ + +our %config = ( + gpu => { + intel_enabled => 0, + amd_enabled => 0, + nvidia_enabled => 0, + gpu_history => 0, + }, + debug => { + log_enabled => 0, + log_file => '/tmp/pve-mod-debug.log', + lm_sensors_mode => 0, + lm_sensors_output_file => '/tmp/sensors-output.json', + intel_mode => 0, + intel_devices_file => '/tmp/intel-gpu-devices.json', + nvidia_mode => 0, + nvidia_devices_file => '/tmp/nvidia-smi-devices.csv', + nvidia_output_file => '/tmp/nvidia-smi-output.csv', + amd_mode => 0, + amd_devices_file => '/tmp/amd-gpu-devices.json', + ups_mode => 0, + ups_output_file => '/tmp/ups-output.json', + }, + intervals => { + data_pull => 1, # seconds between data pulls + collector_timeout => 10, # stop collectors after N seconds of inactivity + }, + lm_sensors => { + enabled => 0, + enable_cpu => 0, + cpu_temp_target => 'Core', + enable_ram_temp => 0, + enable_hdd_temp => 0, + enable_nvme_temp => 0, + enable_fan_speed => 0, + display_zero_speed_fans => 0, + temp_unit => 'C', + }, + ups => { + enabled => 0, + device_name => 'ups@localhost', + }, + system_info => { + enabled => 0, + type => 1, # 1 = System (dmidecode -t 1), 2 = Baseboard/Motherboard (dmidecode -t 2) + }, + paths => { + working_dir => '/run/pveproxy/pve-mod', + }, +); + +# ============================================================================ +# Derived paths +# ============================================================================ + +our $pve_mod_working_dir = $config{paths}{working_dir}; +our $stats_dir = $pve_mod_working_dir; +our $state_file = "$pve_mod_working_dir/stats.json"; +our $sensors_state_file = "$pve_mod_working_dir/sensors.json"; +our $ups_state_file = "$pve_mod_working_dir/ups.json"; +our $pve_mod_worker_lock = "$pve_mod_working_dir/pve_mod_worker.lock"; +our $startup_lock = "$pve_mod_working_dir/startup.lock"; + +# ============================================================================ +# RRD paths +# ============================================================================ + +our $RRD_SOCKET = '/var/run/rrdcached.sock'; +our $RRD_BASE = '/var/lib/rrdcached/db/pve-mod-gpu'; + +# ============================================================================ +# Load configuration from /etc/pve-mod/pve-mod.conf (INI format). +# Merges file values into %config, overriding compiled-in defaults. +# Safe to call multiple times; silently skips missing file or unknown keys. +# ============================================================================ + +sub _load_ini_file { + my $path = '/etc/pve-mod/pve-mod.conf'; + return unless -f $path; + + open my $fh, '<', $path or return; + my $section = ''; + + while (my $line = <$fh>) { + chomp $line; + $line =~ s/#.*//; # strip inline comments + $line =~ s/^\s+|\s+$//g; # trim whitespace + next unless length $line; + + if ($line =~ /^\[([^\]]+)\]$/) { + $section = $1; + next; + } + + if ($line =~ /^([^=]+)=(.*)$/) { + my ($key, $val) = ($1, $2); + $key =~ s/^\s+|\s+$//g; + $val =~ s/^\s+|\s+$//g; + + if ($section eq 'gpu' && exists $config{gpu}{$key}) { + $config{gpu}{$key} = $val; + } + elsif ($section eq 'lm_sensors' && exists $config{lm_sensors}{$key}) { + $config{lm_sensors}{$key} = $val; + } + elsif ($section eq 'ups' && exists $config{ups}{$key}) { + $config{ups}{$key} = $val; + } + elsif ($section eq 'system_info' && exists $config{system_info}{$key}) { + $config{system_info}{$key} = $val; + } + elsif ($section eq 'debug' && exists $config{debug}{$key}) { + $config{debug}{$key} = $val; + } + } + } + close $fh; +} + +_load_ini_file(); + +1; diff --git a/src/node_info/files/ProcessManager.pm b/src/node_info/files/ProcessManager.pm index 741717f..054495b 100644 --- a/src/node_info/files/ProcessManager.pm +++ b/src/node_info/files/ProcessManager.pm @@ -1,469 +1,469 @@ -package PVE::PVEMod::ProcessManager; - -use strict; -use warnings; -use Exporter 'import'; - -use POSIX qw(WNOHANG); -use File::Path qw(remove_tree); - -use PVE::PVEMod::Config qw( - %config $process_type - $pve_mod_working_dir $state_file - $pve_mod_worker_lock $startup_lock -); -use PVE::PVEMod::Utils qw( - debug is_process_alive read_lock_pid - acquire_exclusive_lock ensure_pve_mod_directory_exists - check_executable startup_message -); - -use PVE::PVEMod::Collector::Intel qw(get_intel_gpu_devices collector_for_intel_device); -use PVE::PVEMod::Collector::Nvidia qw(get_nvidia_gpu_devices collector_for_nvidia_devices); -use PVE::PVEMod::Collector::Amd qw(get_amd_gpu_devices collector_for_amd_device); -use PVE::PVEMod::Collector::LmSensors qw(collector_for_temperature_sensors); -use PVE::PVEMod::Collector::Ups qw(collector_for_ups); - -our @EXPORT_OK = qw( - pve_mod_starter - notify_pve_mod_worker -); - -# Collector registry — only populated inside the worker process. -# Each forked child has its own copy; the parent never accesses this after forking. -my %collectors = (); - -# ============================================================================ -# Public API (called from SensorInfo) -# ============================================================================ - -# Ensures the worker is running. Starts it if necessary (double-checked locking). -sub pve_mod_starter { - debug(__LINE__, "Checking if pve_mod_worker is already running"); - if (_worker_lock_file_exists()) { - debug(__LINE__, "pve_mod_worker process already running, system is already started"); - return "pve_mod_worker process already running, system is already started"; - } - debug(__LINE__, "PVE mod worker is not running. PVE Mod will be started."); - - startup_message(); - ensure_pve_mod_directory_exists(); - - debug(__LINE__, "Trying to acquire startup lock: $startup_lock"); - my $startup_fh = acquire_exclusive_lock($startup_lock, 'startup lock'); - return unless $startup_fh; - - # Second check after acquiring lock - if (_worker_lock_file_exists()) { - debug(__LINE__, "Worker started by another process while we waited for lock"); - close($startup_fh); - unlink($startup_lock); - return "already running"; - } - - print $startup_fh "$$\n"; - $startup_fh->flush(); - debug(__LINE__, "Wrote PID $$ to startup lock"); - - _pve_mod_worker(); - - unlink($startup_lock); - debug(__LINE__, "Released startup lock"); - debug(__LINE__, "pve_mod_worker started successfully, returning"); -} - -# Sends SIGUSR1 to the worker to reset the inactivity timer. -sub notify_pve_mod_worker { - debug(__LINE__, "notify_pve_mod_worker called"); - unless (-f $pve_mod_worker_lock) { - debug(__LINE__, "pve_mod_worker lock file does not exist"); - return; - } - - debug(__LINE__, "pve_mod_worker lock file exists, reading PID"); - if (open my $fh, '<', $pve_mod_worker_lock) { - my $pid = <$fh>; - close $fh; - chomp $pid if defined $pid; - if (defined $pid && $pid =~ /^(\d+)$/) { - my $clean_pid = $1; - - if (is_process_alive($clean_pid)) { - debug(__LINE__, "Sending USR1 signal to pve_mod_worker PID $clean_pid"); - my $result = kill('USR1', $clean_pid); - debug(__LINE__, "Signal result: $result"); - } else { - debug(__LINE__, - "pve_mod_worker process $clean_pid is not alive, removing stale lock"); - unlink($pve_mod_worker_lock); - } - } else { - debug(__LINE__, - "pve_mod_worker lock is stale (PID: " . ($pid // 'undefined') . "), removing"); - unlink($pve_mod_worker_lock); - } - } else { - debug(__LINE__, "Failed to open pve_mod_worker lock file: $!"); - } -} - -# ============================================================================ -# Worker process management -# ============================================================================ - -sub _worker_lock_file_exists { - return -f $pve_mod_worker_lock; -} - -# Forks the worker process and records its PID in the lock file. -sub _pve_mod_worker { - debug(__LINE__, "_pve_mod_worker called"); - - my $pve_mod_worker_fh = - acquire_exclusive_lock($pve_mod_worker_lock, 'pve_mod_worker lock'); - return unless $pve_mod_worker_fh; - print $pve_mod_worker_fh "$$\n"; - close($pve_mod_worker_fh); - - debug(__LINE__, "Forking new pve_mod_worker process"); - my $pve_mod_worker_pid = fork(); - - unless (defined $pve_mod_worker_pid) { - debug(__LINE__, "Failed to fork pve_mod_worker process: $!"); - return; - } - - if ($pve_mod_worker_pid == 0) { - # Child - $0 = "pve_mod_worker_controller"; - debug(__LINE__, "Child process forked, calling _pve_mod_keep_alive"); - _pve_mod_keep_alive(); - exit(0); - } - - # Parent — update lock file with real child PID - debug(__LINE__, "Forked pve_mod_worker process with PID $pve_mod_worker_pid"); - if (open my $fh, '>', $pve_mod_worker_lock) { - print $fh "$pve_mod_worker_pid\n"; - close $fh; - debug(__LINE__, "Wrote pve_mod_worker PID to lock file: $pve_mod_worker_lock"); - } else { - debug(__LINE__, "Failed to write pve_mod_worker lock file: $!"); - kill('TERM', $pve_mod_worker_pid); - } - - debug(__LINE__, "pve_mod_worker process started successfully"); -} - -# ============================================================================ -# Worker keep-alive loop -# ============================================================================ - -sub _pve_mod_keep_alive { - $process_type = 'worker'; - debug(__LINE__, "pve_mod_worker process started with PID $$"); - - my $last_activity = time(); - - $SIG{USR1} = sub { - $last_activity = time(); - debug(__LINE__, "Activity ping received"); - }; - - $SIG{CHLD} = sub { - while ((my $pid = waitpid(-1, WNOHANG)) > 0) { - my $exit_status = $? >> 8; - debug(__LINE__, "Child process $pid exited with status $exit_status"); - - foreach my $name (keys %collectors) { - if ($collectors{$name} == $pid) { - debug(__LINE__, - "Collector '$name' (PID $pid) exited, removing from registry"); - delete $collectors{$name}; - last; - } - } - } - }; - - $SIG{TERM} = sub { - debug(__LINE__, "pve_mod_worker received SIGTERM, shutting down"); - _stop_child_collectors(); - unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock; - exit(0); - }; - $SIG{INT} = sub { - debug(__LINE__, "pve_mod_worker received SIGINT, shutting down"); - _stop_child_collectors(); - unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock; - exit(0); - }; - - debug(__LINE__, "Worker starting all collectors"); - _initialise_sensors_collector(); - _initialise_graphics_collectors(); - _initialise_ups_collector(); - debug(__LINE__, "All collectors started by worker"); - - debug(__LINE__, - "Entering pve_mod_worker loop, timeout=$config{intervals}{collector_timeout}s"); - - while (1) { - debug(__LINE__, "pve_mod_worker loop start: checking activity"); - - my $idle_time = time() - $last_activity; - debug(__LINE__, - "pve_mod_worker loop: idle_time=${idle_time}s, " - . "timeout=$config{intervals}{collector_timeout}s"); - - if ($idle_time > $config{intervals}{collector_timeout}) { - debug(__LINE__, "Timeout reached, stopping collectors"); - _stop_child_collectors(); - debug(__LINE__, "Collectors stopped, exiting pve_mod_worker"); - unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock; - exit(0); - } - sleep(1); - } - - debug(__LINE__, "pve_mod_worker loop exited unexpectedly!"); -} - -# ============================================================================ -# Collector startup helpers (called from worker loop) -# ============================================================================ - -sub _initialise_sensors_collector { - return unless $config{lm_sensors}{enabled}; - return unless check_executable('/usr/bin/sensors', 'lm-sensors', - $config{debug}{lm_sensors_mode}, - $config{debug}{lm_sensors_output_file}); - - debug(__LINE__, "Starting lm-sensors collector"); - _start_collector('sensors', 'sensors', - \&collector_for_temperature_sensors, - { name => 'sensors' }); -} - -sub _initialise_ups_collector { - unless ($config{ups}{enabled} && $config{ups}{device_name}) { - debug(__LINE__, "UPS collection disabled/invalid in config, skipping"); - return; - } - return unless check_executable('/usr/bin/upsc', 'UPS', - $config{debug}{ups_mode}, - $config{debug}{ups_output_file}); - - debug(__LINE__, "Starting UPS collector: $config{ups}{device_name}"); - _start_collector('ups', 'ups', \&collector_for_ups, - { ups_name => $config{ups}{device_name} }); -} - -sub _initialise_graphics_collectors { - unless ($config{gpu}{intel_enabled} - || $config{gpu}{amd_enabled} - || $config{gpu}{nvidia_enabled}) { - debug(__LINE__, "No GPU types enabled, skipping collector startup"); - return; - } - - debug(__LINE__, "Starting graphics collectors"); - - my (@all_devices, @all_types, @all_collector_subs); - my @nvidia_devices; - - # Intel (each GPU has its own collector) - if ($config{gpu}{intel_enabled} && check_executable('/usr/bin/intel_gpu_top', 'Intel', - $config{debug}{intel_mode}, - $config{debug}{intel_devices_file})) { - my @intel_devices = get_intel_gpu_devices(); - for my $device (@intel_devices) { - push @all_devices, $device; - push @all_types, 'intel'; - push @all_collector_subs, \&collector_for_intel_device; - } - } - - # AMD (each GPU has its own collector) - if ($config{gpu}{amd_enabled} && check_executable('/usr/bin/rocm-smi', 'AMD', - $config{debug}{amd_mode}, - $config{debug}{amd_devices_file})) { - my @amd_devices = get_amd_gpu_devices(); - for my $device (@amd_devices) { - push @all_devices, $device; - push @all_types, 'amd'; - push @all_collector_subs, \&collector_for_amd_device; - } - } - - # NVIDIA (all GPUs collected together in one collector due to nvidia-smi design) - if ($config{gpu}{nvidia_enabled} && check_executable('/usr/bin/nvidia-smi', 'NVIDIA', - $config{debug}{nvidia_mode}, - $config{debug}{nvidia_devices_file})) { - @nvidia_devices = get_nvidia_gpu_devices(); - } - - debug(__LINE__, - "Detected: " - . scalar(grep { $_ eq 'intel' } @all_types) . " Intel, " - . scalar(grep { $_ eq 'amd' } @all_types) . " AMD, " - . scalar(@nvidia_devices) . " NVIDIA"); - - my $started_count = 0; - - # Start individual collectors for Intel and AMD devices - for (my $i = 0; $i < @all_devices; $i++) { - my $device = $all_devices[$i]; - my $type = $all_types[$i]; - my $collector_sub = $all_collector_subs[$i]; - my $device_name = $device->{card} // $device->{name} // "device$i"; - - my $pid = _start_collector($device_name, $type, $collector_sub, $device); - $started_count++ if $pid; - } - - # NVIDIA — single collector for all GPUs - if (@nvidia_devices) { - my $pid = _start_collector('nvidia-all', 'nvidia', - \&collector_for_nvidia_devices, - \@nvidia_devices); - $started_count++ if $pid; - } - - debug(__LINE__, - "Started/verified $started_count graphics collector(s)"); -} - -# ============================================================================ -# Generic collector start/stop -# ============================================================================ - -sub _start_collector { - my ($collector_name, $collector_type, $collector_sub, $device) = @_; - - debug(__LINE__, "Starting $collector_type collector: $collector_name"); - - if (exists $collectors{$collector_name}) { - my $pid = $collectors{$collector_name}; - if (kill(0, $pid)) { - debug(__LINE__, - "$collector_type collector '$collector_name' already running with PID $pid"); - return $pid; - } else { - debug(__LINE__, - "Collector '$collector_name' PID $pid is stale, removing from registry"); - delete $collectors{$collector_name}; - } - } - - my $pid = _start_child_collector($collector_name, $collector_sub, $device); - - unless ($pid) { - debug(__LINE__, "Failed to start $collector_type collector '$collector_name'"); - return undef; - } - - $collectors{$collector_name} = $pid; - debug(__LINE__, - "Registered $collector_type collector '$collector_name' with PID $pid"); - - sleep 0.1; - if (kill(0, $pid)) { - debug(__LINE__, - "Verified $collector_type collector '$collector_name' (PID $pid) is alive"); - return $pid; - } else { - debug(__LINE__, - "WARNING - $collector_type collector '$collector_name' (PID $pid) died immediately!"); - delete $collectors{$collector_name}; - return undef; - } -} - -sub _start_child_collector { - my ($collector_name, $collector_sub, $device) = @_; - - debug(__LINE__, "Starting child collector: $collector_name"); - - my $pid = fork(); - unless (defined $pid) { - debug(__LINE__, "fork failed for $collector_name: $!"); - return undef; - } - - if ($pid == 0) { - $process_type = 'collector'; - debug(__LINE__, "In child process for $collector_name"); - $0 = "collector-$collector_name"; - $collector_sub->($device); - exit(0); - } - - debug(__LINE__, "Forked child PID $pid for $collector_name"); - return $pid; -} - -sub _stop_child_collectors { - debug(__LINE__, "Stopping all collectors"); - - my @pids = values %collectors; - - if (@pids) { - debug(__LINE__, "Sending SIGTERM to " . scalar(@pids) . " collector process(es)"); - foreach my $pid (@pids) { - if (kill(0, $pid)) { - kill('TERM', $pid); - debug(__LINE__, "Sent SIGTERM to collector PID $pid"); - } - } - - my $timeout = 2; - my $start = time(); - while (time() - $start < $timeout) { - my $any_alive = 0; - foreach my $pid (@pids) { - if (kill(0, $pid)) { $any_alive = 1; last; } - } - last unless $any_alive; - select(undef, undef, undef, 0.1); - } - - foreach my $pid (@pids) { - if (kill(0, $pid)) { - debug(__LINE__, "Force killing collector process $pid"); - kill('KILL', $pid); - } - } - } - - %collectors = (); - debug(__LINE__, "Cleared collector registry"); - - if (-f $state_file) { - unlink $state_file or debug(__LINE__, "Failed to remove $state_file: $!"); - } - - if (-d $pve_mod_working_dir) { - remove_tree($pve_mod_working_dir, { error => \my $err }); - debug(__LINE__, "Cleanup errors: @$err") if @$err; - } - - debug(__LINE__, "Cleanup complete"); -} - -# ============================================================================ -# END block — only the worker process performs cleanup -# ============================================================================ - -END { - if ($process_type eq 'worker') { - debug(__LINE__, "PVE Mod Worker END block: cleaning up"); - _stop_child_collectors(); - } elsif ($process_type eq 'collector') { - debug(__LINE__, "Collector ($0) END block: no cleanup needed"); - } else { - debug(__LINE__, "Main process END block: no cleanup needed"); - } -} - -1; +package PVE::PVEMod::ProcessManager; + +use strict; +use warnings; +use Exporter 'import'; + +use POSIX qw(WNOHANG); +use File::Path qw(remove_tree); + +use PVE::PVEMod::Config qw( + %config $process_type + $pve_mod_working_dir $state_file + $pve_mod_worker_lock $startup_lock +); +use PVE::PVEMod::Utils qw( + debug is_process_alive read_lock_pid + acquire_exclusive_lock ensure_pve_mod_directory_exists + check_executable startup_message +); + +use PVE::PVEMod::Collector::Intel qw(get_intel_gpu_devices collector_for_intel_device); +use PVE::PVEMod::Collector::Nvidia qw(get_nvidia_gpu_devices collector_for_nvidia_devices); +use PVE::PVEMod::Collector::Amd qw(get_amd_gpu_devices collector_for_amd_device); +use PVE::PVEMod::Collector::LmSensors qw(collector_for_temperature_sensors); +use PVE::PVEMod::Collector::Ups qw(collector_for_ups); + +our @EXPORT_OK = qw( + pve_mod_starter + notify_pve_mod_worker +); + +# Collector registry — only populated inside the worker process. +# Each forked child has its own copy; the parent never accesses this after forking. +my %collectors = (); + +# ============================================================================ +# Public API (called from SensorInfo) +# ============================================================================ + +# Ensures the worker is running. Starts it if necessary (double-checked locking). +sub pve_mod_starter { + debug(__LINE__, "Checking if pve_mod_worker is already running"); + if (_worker_lock_file_exists()) { + debug(__LINE__, "pve_mod_worker process already running, system is already started"); + return "pve_mod_worker process already running, system is already started"; + } + debug(__LINE__, "PVE mod worker is not running. PVE Mod will be started."); + + startup_message(); + ensure_pve_mod_directory_exists(); + + debug(__LINE__, "Trying to acquire startup lock: $startup_lock"); + my $startup_fh = acquire_exclusive_lock($startup_lock, 'startup lock'); + return unless $startup_fh; + + # Second check after acquiring lock + if (_worker_lock_file_exists()) { + debug(__LINE__, "Worker started by another process while we waited for lock"); + close($startup_fh); + unlink($startup_lock); + return "already running"; + } + + print $startup_fh "$$\n"; + $startup_fh->flush(); + debug(__LINE__, "Wrote PID $$ to startup lock"); + + _pve_mod_worker(); + + unlink($startup_lock); + debug(__LINE__, "Released startup lock"); + debug(__LINE__, "pve_mod_worker started successfully, returning"); +} + +# Sends SIGUSR1 to the worker to reset the inactivity timer. +sub notify_pve_mod_worker { + debug(__LINE__, "notify_pve_mod_worker called"); + unless (-f $pve_mod_worker_lock) { + debug(__LINE__, "pve_mod_worker lock file does not exist"); + return; + } + + debug(__LINE__, "pve_mod_worker lock file exists, reading PID"); + if (open my $fh, '<', $pve_mod_worker_lock) { + my $pid = <$fh>; + close $fh; + chomp $pid if defined $pid; + if (defined $pid && $pid =~ /^(\d+)$/) { + my $clean_pid = $1; + + if (is_process_alive($clean_pid)) { + debug(__LINE__, "Sending USR1 signal to pve_mod_worker PID $clean_pid"); + my $result = kill('USR1', $clean_pid); + debug(__LINE__, "Signal result: $result"); + } else { + debug(__LINE__, + "pve_mod_worker process $clean_pid is not alive, removing stale lock"); + unlink($pve_mod_worker_lock); + } + } else { + debug(__LINE__, + "pve_mod_worker lock is stale (PID: " . ($pid // 'undefined') . "), removing"); + unlink($pve_mod_worker_lock); + } + } else { + debug(__LINE__, "Failed to open pve_mod_worker lock file: $!"); + } +} + +# ============================================================================ +# Worker process management +# ============================================================================ + +sub _worker_lock_file_exists { + return -f $pve_mod_worker_lock; +} + +# Forks the worker process and records its PID in the lock file. +sub _pve_mod_worker { + debug(__LINE__, "_pve_mod_worker called"); + + my $pve_mod_worker_fh = + acquire_exclusive_lock($pve_mod_worker_lock, 'pve_mod_worker lock'); + return unless $pve_mod_worker_fh; + print $pve_mod_worker_fh "$$\n"; + close($pve_mod_worker_fh); + + debug(__LINE__, "Forking new pve_mod_worker process"); + my $pve_mod_worker_pid = fork(); + + unless (defined $pve_mod_worker_pid) { + debug(__LINE__, "Failed to fork pve_mod_worker process: $!"); + return; + } + + if ($pve_mod_worker_pid == 0) { + # Child + $0 = "pve_mod_worker_controller"; + debug(__LINE__, "Child process forked, calling _pve_mod_keep_alive"); + _pve_mod_keep_alive(); + exit(0); + } + + # Parent — update lock file with real child PID + debug(__LINE__, "Forked pve_mod_worker process with PID $pve_mod_worker_pid"); + if (open my $fh, '>', $pve_mod_worker_lock) { + print $fh "$pve_mod_worker_pid\n"; + close $fh; + debug(__LINE__, "Wrote pve_mod_worker PID to lock file: $pve_mod_worker_lock"); + } else { + debug(__LINE__, "Failed to write pve_mod_worker lock file: $!"); + kill('TERM', $pve_mod_worker_pid); + } + + debug(__LINE__, "pve_mod_worker process started successfully"); +} + +# ============================================================================ +# Worker keep-alive loop +# ============================================================================ + +sub _pve_mod_keep_alive { + $process_type = 'worker'; + debug(__LINE__, "pve_mod_worker process started with PID $$"); + + my $last_activity = time(); + + $SIG{USR1} = sub { + $last_activity = time(); + debug(__LINE__, "Activity ping received"); + }; + + $SIG{CHLD} = sub { + while ((my $pid = waitpid(-1, WNOHANG)) > 0) { + my $exit_status = $? >> 8; + debug(__LINE__, "Child process $pid exited with status $exit_status"); + + foreach my $name (keys %collectors) { + if ($collectors{$name} == $pid) { + debug(__LINE__, + "Collector '$name' (PID $pid) exited, removing from registry"); + delete $collectors{$name}; + last; + } + } + } + }; + + $SIG{TERM} = sub { + debug(__LINE__, "pve_mod_worker received SIGTERM, shutting down"); + _stop_child_collectors(); + unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock; + exit(0); + }; + $SIG{INT} = sub { + debug(__LINE__, "pve_mod_worker received SIGINT, shutting down"); + _stop_child_collectors(); + unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock; + exit(0); + }; + + debug(__LINE__, "Worker starting all collectors"); + _initialise_sensors_collector(); + _initialise_graphics_collectors(); + _initialise_ups_collector(); + debug(__LINE__, "All collectors started by worker"); + + debug(__LINE__, + "Entering pve_mod_worker loop, timeout=$config{intervals}{collector_timeout}s"); + + while (1) { + debug(__LINE__, "pve_mod_worker loop start: checking activity"); + + my $idle_time = time() - $last_activity; + debug(__LINE__, + "pve_mod_worker loop: idle_time=${idle_time}s, " + . "timeout=$config{intervals}{collector_timeout}s"); + + if ($idle_time > $config{intervals}{collector_timeout}) { + debug(__LINE__, "Timeout reached, stopping collectors"); + _stop_child_collectors(); + debug(__LINE__, "Collectors stopped, exiting pve_mod_worker"); + unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock; + exit(0); + } + sleep(1); + } + + debug(__LINE__, "pve_mod_worker loop exited unexpectedly!"); +} + +# ============================================================================ +# Collector startup helpers (called from worker loop) +# ============================================================================ + +sub _initialise_sensors_collector { + return unless $config{lm_sensors}{enabled}; + return unless check_executable('/usr/bin/sensors', 'lm-sensors', + $config{debug}{lm_sensors_mode}, + $config{debug}{lm_sensors_output_file}); + + debug(__LINE__, "Starting lm-sensors collector"); + _start_collector('sensors', 'sensors', + \&collector_for_temperature_sensors, + { name => 'sensors' }); +} + +sub _initialise_ups_collector { + unless ($config{ups}{enabled} && $config{ups}{device_name}) { + debug(__LINE__, "UPS collection disabled/invalid in config, skipping"); + return; + } + return unless check_executable('/usr/bin/upsc', 'UPS', + $config{debug}{ups_mode}, + $config{debug}{ups_output_file}); + + debug(__LINE__, "Starting UPS collector: $config{ups}{device_name}"); + _start_collector('ups', 'ups', \&collector_for_ups, + { ups_name => $config{ups}{device_name} }); +} + +sub _initialise_graphics_collectors { + unless ($config{gpu}{intel_enabled} + || $config{gpu}{amd_enabled} + || $config{gpu}{nvidia_enabled}) { + debug(__LINE__, "No GPU types enabled, skipping collector startup"); + return; + } + + debug(__LINE__, "Starting graphics collectors"); + + my (@all_devices, @all_types, @all_collector_subs); + my @nvidia_devices; + + # Intel (each GPU has its own collector) + if ($config{gpu}{intel_enabled} && check_executable('/usr/bin/intel_gpu_top', 'Intel', + $config{debug}{intel_mode}, + $config{debug}{intel_devices_file})) { + my @intel_devices = get_intel_gpu_devices(); + for my $device (@intel_devices) { + push @all_devices, $device; + push @all_types, 'intel'; + push @all_collector_subs, \&collector_for_intel_device; + } + } + + # AMD (each GPU has its own collector) + if ($config{gpu}{amd_enabled} && check_executable('/usr/bin/rocm-smi', 'AMD', + $config{debug}{amd_mode}, + $config{debug}{amd_devices_file})) { + my @amd_devices = get_amd_gpu_devices(); + for my $device (@amd_devices) { + push @all_devices, $device; + push @all_types, 'amd'; + push @all_collector_subs, \&collector_for_amd_device; + } + } + + # NVIDIA (all GPUs collected together in one collector due to nvidia-smi design) + if ($config{gpu}{nvidia_enabled} && check_executable('/usr/bin/nvidia-smi', 'NVIDIA', + $config{debug}{nvidia_mode}, + $config{debug}{nvidia_devices_file})) { + @nvidia_devices = get_nvidia_gpu_devices(); + } + + debug(__LINE__, + "Detected: " + . scalar(grep { $_ eq 'intel' } @all_types) . " Intel, " + . scalar(grep { $_ eq 'amd' } @all_types) . " AMD, " + . scalar(@nvidia_devices) . " NVIDIA"); + + my $started_count = 0; + + # Start individual collectors for Intel and AMD devices + for (my $i = 0; $i < @all_devices; $i++) { + my $device = $all_devices[$i]; + my $type = $all_types[$i]; + my $collector_sub = $all_collector_subs[$i]; + my $device_name = $device->{card} // $device->{name} // "device$i"; + + my $pid = _start_collector($device_name, $type, $collector_sub, $device); + $started_count++ if $pid; + } + + # NVIDIA — single collector for all GPUs + if (@nvidia_devices) { + my $pid = _start_collector('nvidia-all', 'nvidia', + \&collector_for_nvidia_devices, + \@nvidia_devices); + $started_count++ if $pid; + } + + debug(__LINE__, + "Started/verified $started_count graphics collector(s)"); +} + +# ============================================================================ +# Generic collector start/stop +# ============================================================================ + +sub _start_collector { + my ($collector_name, $collector_type, $collector_sub, $device) = @_; + + debug(__LINE__, "Starting $collector_type collector: $collector_name"); + + if (exists $collectors{$collector_name}) { + my $pid = $collectors{$collector_name}; + if (kill(0, $pid)) { + debug(__LINE__, + "$collector_type collector '$collector_name' already running with PID $pid"); + return $pid; + } else { + debug(__LINE__, + "Collector '$collector_name' PID $pid is stale, removing from registry"); + delete $collectors{$collector_name}; + } + } + + my $pid = _start_child_collector($collector_name, $collector_sub, $device); + + unless ($pid) { + debug(__LINE__, "Failed to start $collector_type collector '$collector_name'"); + return undef; + } + + $collectors{$collector_name} = $pid; + debug(__LINE__, + "Registered $collector_type collector '$collector_name' with PID $pid"); + + sleep 0.1; + if (kill(0, $pid)) { + debug(__LINE__, + "Verified $collector_type collector '$collector_name' (PID $pid) is alive"); + return $pid; + } else { + debug(__LINE__, + "WARNING - $collector_type collector '$collector_name' (PID $pid) died immediately!"); + delete $collectors{$collector_name}; + return undef; + } +} + +sub _start_child_collector { + my ($collector_name, $collector_sub, $device) = @_; + + debug(__LINE__, "Starting child collector: $collector_name"); + + my $pid = fork(); + unless (defined $pid) { + debug(__LINE__, "fork failed for $collector_name: $!"); + return undef; + } + + if ($pid == 0) { + $process_type = 'collector'; + debug(__LINE__, "In child process for $collector_name"); + $0 = "collector-$collector_name"; + $collector_sub->($device); + exit(0); + } + + debug(__LINE__, "Forked child PID $pid for $collector_name"); + return $pid; +} + +sub _stop_child_collectors { + debug(__LINE__, "Stopping all collectors"); + + my @pids = values %collectors; + + if (@pids) { + debug(__LINE__, "Sending SIGTERM to " . scalar(@pids) . " collector process(es)"); + foreach my $pid (@pids) { + if (kill(0, $pid)) { + kill('TERM', $pid); + debug(__LINE__, "Sent SIGTERM to collector PID $pid"); + } + } + + my $timeout = 2; + my $start = time(); + while (time() - $start < $timeout) { + my $any_alive = 0; + foreach my $pid (@pids) { + if (kill(0, $pid)) { $any_alive = 1; last; } + } + last unless $any_alive; + select(undef, undef, undef, 0.1); + } + + foreach my $pid (@pids) { + if (kill(0, $pid)) { + debug(__LINE__, "Force killing collector process $pid"); + kill('KILL', $pid); + } + } + } + + %collectors = (); + debug(__LINE__, "Cleared collector registry"); + + if (-f $state_file) { + unlink $state_file or debug(__LINE__, "Failed to remove $state_file: $!"); + } + + if (-d $pve_mod_working_dir) { + remove_tree($pve_mod_working_dir, { error => \my $err }); + debug(__LINE__, "Cleanup errors: @$err") if @$err; + } + + debug(__LINE__, "Cleanup complete"); +} + +# ============================================================================ +# END block — only the worker process performs cleanup +# ============================================================================ + +END { + if ($process_type eq 'worker') { + debug(__LINE__, "PVE Mod Worker END block: cleaning up"); + _stop_child_collectors(); + } elsif ($process_type eq 'collector') { + debug(__LINE__, "Collector ($0) END block: no cleanup needed"); + } else { + debug(__LINE__, "Main process END block: no cleanup needed"); + } +} + +1; diff --git a/src/node_info/files/PveMod_SensorInfo.pm b/src/node_info/files/PveMod_SensorInfo.pm index 6e777c9..edd5659 100644 --- a/src/node_info/files/PveMod_SensorInfo.pm +++ b/src/node_info/files/PveMod_SensorInfo.pm @@ -1,218 +1,218 @@ -package PVE::API2::PVEMod_SensorInfo; - -use strict; -use warnings; - -use PVE::PVEMod::Config qw(%config $VERSION $stats_dir $sensors_state_file $ups_state_file); -use PVE::PVEMod::Utils qw(debug safe_read_json); -use PVE::PVEMod::ProcessManager qw(pve_mod_starter notify_pve_mod_worker); -use PVE::PVEMod::Collector::SystemInformation qw(get_system_information_data); - -# Per-endpoint state caches (module-level, reset on worker restart) -my $graphics_cache = { data => {}, mtime => 0 }; -my $sensors_cache = { data => '{}', mtime => 0 }; -my $ups_cache = { data => '{}', mtime => 0 }; -my $system_info_cache = undef; - - -# ============================================================================ -# Internal helpers -# ============================================================================ - -sub _read_state_file_cached { - my ($files, $cache_ref, $reader, $empty_fallback) = @_; - - # Normalize scalar path to single-element arrayref - my @filepaths = ref($files) eq 'ARRAY' ? @$files : ($files); - - # Find newest mtime across all files - my $newest_mtime = 0; - my $any_exist = 0; - foreach my $fp (@filepaths) { - my @st = stat($fp); - if (@st) { - $any_exist = 1; - $newest_mtime = $st[9] if $st[9] > $newest_mtime; - } - } - - unless ($any_exist) { - debug(__LINE__, "No state files exist: " . join(', ', @filepaths)); - return $cache_ref->{data} // $empty_fallback; - } - - if ($newest_mtime == $cache_ref->{mtime} && defined $cache_ref->{data}) { - debug(__LINE__, "State files unchanged, returning cached data"); - return $cache_ref->{data}; - } - - my $data; - if (ref($reader) eq 'CODE') { - $data = $reader->(\@filepaths); - } else { - $data = safe_read_json($filepaths[0], $reader); - } - - if (!defined $data) { - debug(__LINE__, "Failed to read state file(s): " . join(', ', @filepaths)); - return $cache_ref->{data} // $empty_fallback; - } - - $cache_ref->{data} = $data; - $cache_ref->{mtime} = $newest_mtime; - return $cache_ref->{data}; -} - -sub _merge_graphics_files { - my ($filepaths) = @_; - - my $merged = { - Graphics => { - Intel => {}, - NVIDIA => {}, - AMD => {}, - } - }; - - foreach my $filepath (@$filepaths) { - my ($file) = $filepath =~ m{([^/]+)$}; - debug(__LINE__, "Reading device file: $filepath"); - - my $device_data = safe_read_json($filepath, 0); - if (!$device_data) { - debug(__LINE__, "Failed to read/parse $filepath"); - next; - } - - my $device_type = ($file =~ /^stats-card/) ? 'Intel' - : ($file =~ /^stats-nvidia/) ? 'NVIDIA' - : 'AMD'; - - foreach my $node_name (keys %$device_data) { - $merged->{Graphics}->{$device_type}->{$node_name} = $device_data->{$node_name}; - debug(__LINE__, "Merged $device_type node '$node_name' from $file"); - } - } - - return $merged; -} - -sub _load_graphics_data { - # Build filename patterns for enabled GPU types - my @patterns; - push @patterns, 'card\d+' if $config{gpu}{intel_enabled}; - push @patterns, 'nvidia\d+' if $config{gpu}{nvidia_enabled}; - push @patterns, 'amd\d+' if $config{gpu}{amd_enabled}; - - unless (@patterns) { - debug(__LINE__, "No GPU types enabled in config"); - return $graphics_cache->{data}; - } - - my $pattern = join('|', @patterns); - - # Find device stat files for enabled GPU types - my $dh; - unless (opendir($dh, $stats_dir)) { - debug(__LINE__, "Failed to open stats directory: $stats_dir: $!"); - return $graphics_cache->{data}; - } - - my @stat_files = grep { /^stats-(?:$pattern)\.json$/ } readdir($dh); - closedir($dh); - - unless (@stat_files) { - debug(__LINE__, "No device stat files found in $stats_dir"); - return $graphics_cache->{data}; - } - - debug(__LINE__, "Found " . scalar(@stat_files) . " device stat file(s): " . join(', ', @stat_files)); - - my @filepaths = map { "$stats_dir/$_" } @stat_files; - - my $data = _read_state_file_cached( - \@filepaths, - $graphics_cache, - \&_merge_graphics_files, - { Graphics => { Intel => {}, NVIDIA => {}, AMD => {} } } - ); - - my $intel_count = scalar(keys %{$data->{Graphics}{Intel} // {}}); - my $nvidia_count = scalar(keys %{$data->{Graphics}{NVIDIA} // {}}); - my $amd_count = scalar(keys %{$data->{Graphics}{AMD} // {}}); - debug(__LINE__, "Returning $intel_count Intel + $nvidia_count NVIDIA + $amd_count AMD device node(s)"); - - return $data; -} - -# ============================================================================ -# API calls -# ============================================================================ - -sub get_graphic_info { - debug(__LINE__, "get_graphic_info called"); - - # Start PVE Mod - pve_mod_starter(); - - my $data = _load_graphics_data(); - - # Notify pve_mod_worker of activity - notify_pve_mod_worker(); - - return $data; -} - -sub get_sensors_info { - debug(__LINE__, "get_sensors_info called"); - - # Start PVE Mod - pve_mod_starter(); - - my $data = _read_state_file_cached($sensors_state_file, $sensors_cache, 1, '{}'); - - # Notify pve_mod_worker of activity - notify_pve_mod_worker(); - - return $data; -} - -sub get_ups_info { - debug(__LINE__, "get_ups_info called"); - - # Start PVE Mod - pve_mod_starter(); - - my $data = _read_state_file_cached($ups_state_file, $ups_cache, 1, '{}'); - - # Notify pve_mod_worker of activity - notify_pve_mod_worker(); - - return $data; -} - -sub get_pve_mod_version { - debug(__LINE__, "get_pve_mod_version called"); - - # Notify pve_mod_worker of activity - notify_pve_mod_worker(); - - debug(__LINE__, "Returning version: $VERSION"); - - return $VERSION; -} - -sub get_system_information { - debug(__LINE__, "get_system_information called"); - - if (defined $system_info_cache) { - debug(__LINE__, "Returning cached system information"); - return $system_info_cache; - } - - $system_info_cache = get_system_information_data(); - - return $system_info_cache; -} - -1; +package PVE::API2::PVEMod_SensorInfo; + +use strict; +use warnings; + +use PVE::PVEMod::Config qw(%config $VERSION $stats_dir $sensors_state_file $ups_state_file); +use PVE::PVEMod::Utils qw(debug safe_read_json); +use PVE::PVEMod::ProcessManager qw(pve_mod_starter notify_pve_mod_worker); +use PVE::PVEMod::Collector::SystemInformation qw(get_system_information_data); + +# Per-endpoint state caches (module-level, reset on worker restart) +my $graphics_cache = { data => {}, mtime => 0 }; +my $sensors_cache = { data => '{}', mtime => 0 }; +my $ups_cache = { data => '{}', mtime => 0 }; +my $system_info_cache = undef; + + +# ============================================================================ +# Internal helpers +# ============================================================================ + +sub _read_state_file_cached { + my ($files, $cache_ref, $reader, $empty_fallback) = @_; + + # Normalize scalar path to single-element arrayref + my @filepaths = ref($files) eq 'ARRAY' ? @$files : ($files); + + # Find newest mtime across all files + my $newest_mtime = 0; + my $any_exist = 0; + foreach my $fp (@filepaths) { + my @st = stat($fp); + if (@st) { + $any_exist = 1; + $newest_mtime = $st[9] if $st[9] > $newest_mtime; + } + } + + unless ($any_exist) { + debug(__LINE__, "No state files exist: " . join(', ', @filepaths)); + return $cache_ref->{data} // $empty_fallback; + } + + if ($newest_mtime == $cache_ref->{mtime} && defined $cache_ref->{data}) { + debug(__LINE__, "State files unchanged, returning cached data"); + return $cache_ref->{data}; + } + + my $data; + if (ref($reader) eq 'CODE') { + $data = $reader->(\@filepaths); + } else { + $data = safe_read_json($filepaths[0], $reader); + } + + if (!defined $data) { + debug(__LINE__, "Failed to read state file(s): " . join(', ', @filepaths)); + return $cache_ref->{data} // $empty_fallback; + } + + $cache_ref->{data} = $data; + $cache_ref->{mtime} = $newest_mtime; + return $cache_ref->{data}; +} + +sub _merge_graphics_files { + my ($filepaths) = @_; + + my $merged = { + Graphics => { + Intel => {}, + NVIDIA => {}, + AMD => {}, + } + }; + + foreach my $filepath (@$filepaths) { + my ($file) = $filepath =~ m{([^/]+)$}; + debug(__LINE__, "Reading device file: $filepath"); + + my $device_data = safe_read_json($filepath, 0); + if (!$device_data) { + debug(__LINE__, "Failed to read/parse $filepath"); + next; + } + + my $device_type = ($file =~ /^stats-card/) ? 'Intel' + : ($file =~ /^stats-nvidia/) ? 'NVIDIA' + : 'AMD'; + + foreach my $node_name (keys %$device_data) { + $merged->{Graphics}->{$device_type}->{$node_name} = $device_data->{$node_name}; + debug(__LINE__, "Merged $device_type node '$node_name' from $file"); + } + } + + return $merged; +} + +sub _load_graphics_data { + # Build filename patterns for enabled GPU types + my @patterns; + push @patterns, 'card\d+' if $config{gpu}{intel_enabled}; + push @patterns, 'nvidia\d+' if $config{gpu}{nvidia_enabled}; + push @patterns, 'amd\d+' if $config{gpu}{amd_enabled}; + + unless (@patterns) { + debug(__LINE__, "No GPU types enabled in config"); + return $graphics_cache->{data}; + } + + my $pattern = join('|', @patterns); + + # Find device stat files for enabled GPU types + my $dh; + unless (opendir($dh, $stats_dir)) { + debug(__LINE__, "Failed to open stats directory: $stats_dir: $!"); + return $graphics_cache->{data}; + } + + my @stat_files = grep { /^stats-(?:$pattern)\.json$/ } readdir($dh); + closedir($dh); + + unless (@stat_files) { + debug(__LINE__, "No device stat files found in $stats_dir"); + return $graphics_cache->{data}; + } + + debug(__LINE__, "Found " . scalar(@stat_files) . " device stat file(s): " . join(', ', @stat_files)); + + my @filepaths = map { "$stats_dir/$_" } @stat_files; + + my $data = _read_state_file_cached( + \@filepaths, + $graphics_cache, + \&_merge_graphics_files, + { Graphics => { Intel => {}, NVIDIA => {}, AMD => {} } } + ); + + my $intel_count = scalar(keys %{$data->{Graphics}{Intel} // {}}); + my $nvidia_count = scalar(keys %{$data->{Graphics}{NVIDIA} // {}}); + my $amd_count = scalar(keys %{$data->{Graphics}{AMD} // {}}); + debug(__LINE__, "Returning $intel_count Intel + $nvidia_count NVIDIA + $amd_count AMD device node(s)"); + + return $data; +} + +# ============================================================================ +# API calls +# ============================================================================ + +sub get_graphic_info { + debug(__LINE__, "get_graphic_info called"); + + # Start PVE Mod + pve_mod_starter(); + + my $data = _load_graphics_data(); + + # Notify pve_mod_worker of activity + notify_pve_mod_worker(); + + return $data; +} + +sub get_sensors_info { + debug(__LINE__, "get_sensors_info called"); + + # Start PVE Mod + pve_mod_starter(); + + my $data = _read_state_file_cached($sensors_state_file, $sensors_cache, 1, '{}'); + + # Notify pve_mod_worker of activity + notify_pve_mod_worker(); + + return $data; +} + +sub get_ups_info { + debug(__LINE__, "get_ups_info called"); + + # Start PVE Mod + pve_mod_starter(); + + my $data = _read_state_file_cached($ups_state_file, $ups_cache, 1, '{}'); + + # Notify pve_mod_worker of activity + notify_pve_mod_worker(); + + return $data; +} + +sub get_pve_mod_version { + debug(__LINE__, "get_pve_mod_version called"); + + # Notify pve_mod_worker of activity + notify_pve_mod_worker(); + + debug(__LINE__, "Returning version: $VERSION"); + + return $VERSION; +} + +sub get_system_information { + debug(__LINE__, "get_system_information called"); + + if (defined $system_info_cache) { + debug(__LINE__, "Returning cached system information"); + return $system_info_cache; + } + + $system_info_cache = get_system_information_data(); + + return $system_info_cache; +} + +1; diff --git a/src/node_info/files/PveMod_pvemanagerlib.js b/src/node_info/files/PveMod_pvemanagerlib.js index 39504d5..af1b445 100644 --- a/src/node_info/files/PveMod_pvemanagerlib.js +++ b/src/node_info/files/PveMod_pvemanagerlib.js @@ -1,1534 +1,1534 @@ -Ext.define('PVE.mod.TempHelper', { - //singleton: true, - - requires: ['Ext.util.Format'], - - statics: { - CELSIUS: 0, - FAHRENHEIT: 1 - }, - - srcUnit: null, - dstUnit: null, - - isValidUnit: function (unit) { - return ( - Ext.isNumber(unit) && (unit === this.self.CELSIUS || unit === this.self.FAHRENHEIT) - ); - }, - - constructor: function (config) { - this.srcUnit = config && this.isValidUnit(config.srcUnit) ? config.srcUnit : this.self.CELSIUS; - this.dstUnit = config && this.isValidUnit(config.dstUnit) ? config.dstUnit : this.self.CELSIUS; - }, - - toFahrenheit: function (tempCelsius) { - return Ext.isNumber(tempCelsius) - ? tempCelsius * 9 / 5 + 32 - : NaN; - }, - - toCelsius: function (tempFahrenheit) { - return Ext.isNumber(tempFahrenheit) - ? (tempFahrenheit - 32) * 5 / 9 - : NaN; - }, - - getTemp: function (value) { - if (this.srcUnit !== this.dstUnit) { - switch (this.srcUnit) { - case this.self.CELSIUS: - switch (this.dstUnit) { - case this.self.FAHRENHEIT: - return this.toFahrenheit(value); - - default: - Ext.raise({ - msg: - 'Unsupported destination temperature unit: ' + this.dstUnit, - }); - } - case this.self.FAHRENHEIT: - switch (this.dstUnit) { - case this.self.CELSIUS: - return this.toCelsius(value); - - default: - Ext.raise({ - msg: - 'Unsupported destination temperature unit: ' + this.dstUnit, - }); - } - default: - Ext.raise({ - msg: 'Unsupported source temperature unit: ' + this.srcUnit, - }); - } - } else { - return value; - } - }, - - getUnit: function(plainText) { - switch (this.dstUnit) { - case this.self.CELSIUS: - return plainText !== true ? '°C' : '\'C'; - - case this.self.FAHRENHEIT: - return plainText !== true ? '°F' : '\'F'; - - default: - Ext.raise({ - msg: 'Unsupported destination temperature unit: ' + this.srcUnit, - }); - } - }, -}); -Ext.define('PVE.node.StatusView', { - extend: 'Proxmox.panel.StatusView', - alias: 'widget.pveNodeStatus', - - minHeight: 360, - flex: 1, - collapsible: true, - titleCollapse: true, - bodyPadding: '20 15 20 15', - - layout: { - type: 'table', - columns: 2, - trAttrs: { valign: 'top' }, - tableAttrs: { - style: { - width: '100%', - }, - }, - }, - - defaults: { - xtype: 'pmxInfoWidget', - padding: '0 10 2 10', - }, - - items: [ - // ========== Primary Metrics ========== - { - xtype: 'box', - colspan: 2, - padding: '0', - html: '
Primary Metrics
', - }, - { - itemId: 'cpu', - iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon', - title: gettext('CPU Usage'), - valueField: 'cpu', - maxField: 'cpuinfo', - renderer: function(value, record) { - let result = Proxmox.Utils.render_node_cpu_usage(value, record); - // Append CPU model if available - if (record && record.cpuinfo && record.cpuinfo.model) { - result += ` (${record.cpuinfo.model})`; - } - return result; - }, - }, - { - iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', - itemId: 'memory', - title: gettext('Memory Usage'), - valueField: 'memory', - maxField: 'memory', - warningThreshold: 0.9, - criticalThreshold: 0.975, - renderer: Proxmox.Utils.render_node_size_usage, - }, - { - itemId: 'ksm', - iconCls: 'fa fa-fw fa-clone', - printBar: false, - title: gettext('KSM sharing'), - textField: 'ksm', - renderer: function (record) { - return Proxmox.Utils.render_size(record.shared); - }, - }, - { - itemId: 'gpu', - iconCls: 'fa fa-fw fa-desktop', - title: gettext('GPU Usage'), - printBar: false, - textField: 'PveMod_graphicsInfo', - renderer: function(gpuStats) { - if (!gpuStats || !gpuStats.Graphics) { - return ''; - } - - let hasActiveGPU = false; - let gpuName = ''; - - // Check Intel GPUs - if (gpuStats.Graphics.Intel) { - const keys = Object.keys(gpuStats.Graphics.Intel).sort(); - if (keys.length > 0) { - const gpuData = gpuStats.Graphics.Intel[keys[0]]; - hasActiveGPU = true; - gpuName = gpuData.name; - } - } - - // Check NVIDIA GPUs - if (gpuStats.Graphics.NVIDIA) { - const keys = Object.keys(gpuStats.Graphics.NVIDIA).sort(); - if (keys.length > 0) { - const stats = gpuStats.Graphics.NVIDIA[keys[0]].stats; - hasActiveGPU = true; - gpuName = stats.name; - } - } - - return hasActiveGPU ? gpuName : ''; - }, - }, - { - itemId: 'gpu_usage', - iconCls: 'fa fa-fw fa-desktop', - title: gettext('GPU 0'), - valueField: 'gpuStats', - printBar: false, - textField: 'gpuStats', - renderer: function(gpuStats) { - if (!gpuStats || !gpuStats.Graphics) { - return ''; - } - - // Check Intel GPUs - if (gpuStats.Graphics.Intel) { - const keys = Object.keys(gpuStats.Graphics.Intel).sort(); - if (keys.length > 0) { - const gpuData = gpuStats.Graphics.Intel[keys[0]]; - if (gpuData.stats.engines && gpuData.stats.engines['Render/3D']) { - const usage = gpuData.stats.engines['Render/3D'].busy; - return `${usage}%`; - } - } - } - - // Check NVIDIA GPUs - if (gpuStats.Graphics.NVIDIA) { - const keys = Object.keys(gpuStats.Graphics.NVIDIA).sort(); - if (keys.length > 0) { - const stats = gpuStats.Graphics.NVIDIA[keys[0]].stats; - if (stats.utilization) { - return `${stats.utilization.gpu}%`; - } - } - } - - return ''; - }, - }, - { - iconCls: 'fa fa-fw fa-hdd-o', - itemId: 'rootfs', - title: gettext('Disk (/) Usage'), - valueField: 'rootfs', - maxField: 'rootfs', - renderer: Proxmox.Utils.render_node_size_usage, - }, - { - iconCls: 'fa fa-fw fa-refresh', - itemId: 'swap', - title: gettext('SWAP Usage'), - valueField: 'swap', - maxField: 'swap', - warningThreshold: 0.4, - criticalThreshold: 0.8, - renderer: Proxmox.Utils.render_node_size_usage, - }, - // Fill the remaining cell so the next colspan:2 section header starts on a new row. - { - xtype: 'box', - html: '', - padding: 0, - }, - - // ========== Secondary Metrics ========== - { - xtype: 'box', - colspan: 2, - padding: '15 0 5 0', - html: '
Secondary Metrics
', - }, - { - itemId: 'load', - iconCls: 'fa fa-fw fa-tasks', - title: gettext('CPU Load Average'), - printBar: false, - textField: 'loadavg', - }, - { - itemId: 'wait', - iconCls: 'fa fa-fw fa-clock-o', - title: gettext('CPU I/O Delay'), - valueField: 'wait', - }, - { - itemId: 'thermalCpu', - colspan: 2, - printBar: false, - title: gettext('CPU Thermal State'), - iconCls: 'fa fa-fw fa-thermometer-half', - textField: 'PveMod_JsonSensorInfo', - renderer: function(value){ - // sensors configuration - const cpuTempHelper = Ext.create('PVE.mod.TempHelper', {srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.CELSIUS}); - // display configuration - const itemsPerRow = 0; - // --- - let objValue; - try { - objValue = JSON.parse(value) || {}; - objValue = objValue[Object.keys(objValue)[0]] || {}; - } catch(e) { - objValue = {}; - } - - 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 bINTEL = cpuKeysI.length > 0 ? true : false; - const INTELPackagePrefix = 'Core' == 'Core' ? 'Core ' : 'Package id'; - const INTELPackageCaption = 'Core' == 'Core' ? 'Core' : 'Package'; - let AMDPackagePrefix = 'Tccd'; - let AMDPackageCaption = 'CCD'; - - if (cpuKeysA.length > 0) { - let bTccd = false; - let bTctl = false; - let bTdie = false; - let bCpuCoreTemp = false; - cpuKeysA.forEach((cpuKey, cpuIndex) => { - let items = objValue[cpuKey]; - bTccd = Object.keys(items).findIndex(item => { return String(item).startsWith('Tccd'); }) >= 0; - bTctl = Object.keys(items).findIndex(item => { return String(item).startsWith('Tctl'); }) >= 0; - bTdie = Object.keys(items).findIndex(item => { return String(item).startsWith('Tdie'); }) >= 0; - bCpuCoreTemp = Object.keys(items).findIndex(item => { return String(item) === 'CPU Core Temp'; }) >= 0; - }); - if (bTccd && 'Core' == 'Core') { - AMDPackagePrefix = 'Tccd'; - AMDPackageCaption = 'ccd'; - } else if (bCpuCoreTemp && 'Core' == 'Package') { - AMDPackagePrefix = 'CPU Core Temp'; - AMDPackageCaption = 'CPU Core Temp'; - } else if (bTdie) { - AMDPackagePrefix = 'Tdie'; - AMDPackageCaption = 'die'; - } else if (bTctl) { - AMDPackagePrefix = 'Tctl'; - AMDPackageCaption = 'ctl'; - } else { - AMDPackagePrefix = 'temp'; - AMDPackageCaption = 'Temp'; - } - } - - const cpuKeys = bINTEL ? cpuKeysI : cpuKeysA; - const cpuItemPrefix = bINTEL ? INTELPackagePrefix : AMDPackagePrefix; - const cpuTempCaption = bINTEL ? INTELPackageCaption : AMDPackageCaption; - const formatTemp = bINTEL ? '0' : '0.0'; - const cpuCount = cpuKeys.length; - let temps = []; - - cpuKeys.forEach((cpuKey, cpuIndex) => { - let cpuTemps = []; - const items = objValue[cpuKey]; - const cpuModel = items.cpu_model || ''; - - const itemKeys = Object.keys(items).filter(item => { - if ('Core' == 'Core') { - // In Core mode: only show individual cores/CCDs, exclude overall CPU temp - return String(item).includes(cpuItemPrefix) || String(item).startsWith('Tccd'); - } else { - // In Package mode: show overall CPU temp and package-level readings - return String(item).includes(cpuItemPrefix) || String(item) === 'CPU Core Temp'; - } - }).sort((a, b) => { - // Sort cores numerically - let numA = parseInt(a.match(/\d+/)?.[0] || '0', 10); - let numB = parseInt(b.match(/\d+/)?.[0] || '0', 10); - return numA - numB; - }); - - itemKeys.forEach((coreKey) => { - try { - let tempVal = NaN, tempMax = NaN, tempCrit = NaN; - Object.keys(items[coreKey]).forEach((secondLevelKey) => { - if (secondLevelKey.endsWith('_input')) { - tempVal = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); - } else if (secondLevelKey.endsWith('_max')) { - tempMax = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); - } else if (secondLevelKey.endsWith('_crit')) { - tempCrit = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); - } - }); - - if (!isNaN(tempVal)) { - let tempStyle = ''; - if (!isNaN(tempMax) && tempVal >= tempMax) { - tempStyle = 'color: #FFC300; font-weight: bold;'; - } - if (!isNaN(tempCrit) && tempVal >= tempCrit) { - tempStyle = 'color: red; font-weight: bold;'; - } - - let tempStr = ''; - - // Enhanced parsing for AMD temperatures - if (coreKey.startsWith('Tccd')) { - let tempIndex = coreKey.match(/Tccd(\d+)/); - if (tempIndex !== null && tempIndex.length > 1) { - tempIndex = tempIndex[1]; - tempStr = `${cpuTempCaption} ${tempIndex}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } else { - tempStr = `${cpuTempCaption}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } - } - // Handle CPU Core Temp (single overall temperature) - else if (coreKey === 'CPU Core Temp') { - tempStr = `${cpuTempCaption}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } - // Enhanced parsing for Intel cores (P-Core, E-Core, regular Core) - else { - let tempIndex = coreKey.match(/(?:P\s+Core|E\s+Core|Core)\s*(\d+)/); - if (tempIndex !== null && tempIndex.length > 1) { - tempIndex = tempIndex[1]; - let coreType = coreKey.startsWith('P Core') ? 'P Core' : - coreKey.startsWith('E Core') ? 'E Core' : - cpuTempCaption; - tempStr = `${coreType} ${tempIndex}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } else { - // fallback for CPUs which do not have a core index - let coreType = coreKey.startsWith('P Core') ? 'P Core' : - coreKey.startsWith('E Core') ? 'E Core' : - cpuTempCaption; - tempStr = `${coreType}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } - } - - cpuTemps.push(tempStr); - } - } catch (e) { /*_*/ } - }); - - if(cpuTemps.length > 0) { - temps.push({ model: cpuModel, temps: cpuTemps }); - } - }); - - let html = ''; - temps.forEach((cpuData, cpuIndex) => { - const strCoreTemps = cpuData.temps.map((strTemp, index, arr) => { - return strTemp + (index + 1 < arr.length ? (itemsPerRow > 0 && (index + 1) % itemsPerRow === 0 ? '
' : ' | ') : ''); - }); - if(strCoreTemps.length > 0) { - let cpuLabel = cpuCount > 1 ? `Socket ${cpuIndex + 1}` : 'Socket 1'; - let cpuModelStr = cpuData.model || 'Unknown CPU'; - - html += ''; - html += ``; - html += ``; - html += ''; - } - }); - html += '
${cpuModelStr}${strCoreTemps.join('')}
'; - - return html.indexOf('') > 0 - ? '
' + html + '
' - : 'N/A'; - } - }, - { - itemId: 'gpu_details', - colspan: 2, - iconCls: 'fa fa-fw fa-desktop', - title: gettext('GPU Details'), - printBar: false, - textField: 'PveMod_graphicsInfo', - renderer: function(gpuStats) { - if (!gpuStats || !gpuStats.Graphics) { - return ''; - } - - let html = ''; - - // Intel GPUs - Secondary details - if (gpuStats.Graphics.Intel) { - Object.keys(gpuStats.Graphics.Intel).sort().forEach(key => { - const gpuData = gpuStats.Graphics.Intel[key]; - - let details = []; - - // All engine details - if (gpuData.stats.engines) { - if (gpuData.stats.engines['Render/3D']) { - details.push(`Render/3D: ${gpuData.stats.engines['Render/3D'].busy}%`); - } - if (gpuData.stats.engines['Video']) { - details.push(`Video: ${gpuData.stats.engines['Video'].busy}%`); - } - if (gpuData.stats.engines['Blitter']) { - details.push(`Blitter: ${gpuData.stats.engines['Blitter'].busy}%`); - } - if (gpuData.stats.engines['VideoEnhance']) { - details.push(`VideoEnhance: ${gpuData.stats.engines['VideoEnhance'].busy}%`); - } - } - - // Power - if (gpuData.stats.power) { - details.push(`Power: ${gpuData.stats.power?.GPU ?? 'N/A'} / ${gpuData.stats.power?.Package ?? 'N/A'} ${gpuData.stats.power?.unit || 'W'}`); - } - - // Frequency - if (gpuData.stats.frequency) { - details.push(`Freq: ${gpuData.stats.frequency?.actual ?? 'N/A'}/${gpuData.stats.frequency?.requested ?? 'N/A'} ${gpuData.stats.frequency?.unit || 'MHz'}`); - } - - html += ''; - html += ``; - html += ``; - html += ''; - }); - } - - // NVIDIA GPUs - Secondary details - if (gpuStats.Graphics.NVIDIA) { - Object.keys(gpuStats.Graphics.NVIDIA).sort().forEach(key => { - const gpuData = gpuStats.Graphics.NVIDIA[key]; - const stats = gpuData.stats; - - let details = []; - - // Memory Utilization - if (stats.utilization && stats.utilization.memory) { - const memUsage = parseInt(stats.utilization.memory); - let memStyle = ''; - if (memUsage >= 90) memStyle = 'color: #d9534f; font-weight: bold;'; - else if (memUsage >= 70) memStyle = 'color: #f0ad4e; font-weight: bold;'; - details.push(`MEM: ${stats.utilization.memory}%`); - } - - // VRAM Usage - if (stats.memory) { - const vramUsedGB = parseInt(stats.memory.used); - const vramTotalGB = parseInt(stats.memory.total); - const vramPercent = (vramUsedGB / vramTotalGB) * 100; - let vramStyle = ''; - if (vramPercent >= 90) vramStyle = 'color: #d9534f; font-weight: bold;'; - else if (vramPercent >= 70) vramStyle = 'color: #f0ad4e; font-weight: bold;'; - details.push(`VRAM: ${stats.memory.used}/${stats.memory.total} ${stats.memory.unit}`); - } - - // Temperature - if (stats.temperature) { - let tempStyle = ''; - if (stats.temperature.gpu >= 80) { - tempStyle = 'color: red; font-weight: bold;'; - } else if (stats.temperature.gpu >= 70) { - tempStyle = 'color: #FFC300; font-weight: bold;'; - } - details.push(`Temp: ${stats.temperature.gpu}${stats.temperature.unit}`); - } - - // Power - if (stats.power) { - details.push(`Power: ${stats.power.draw}/${stats.power.limit} ${stats.power.unit}`); - } - - html += ''; - html += ``; - html += ``; - html += ''; - }); - } - - html += '
${gpuData.name}${details.join(' | ')}
${stats.name}${details.join(' | ')}
'; - return html.indexOf('') > 0 - ? '
' + html + '
' - : ''; - }, - }, - { - itemId: 'thermalNvme', - colspan: 2, - printBar: false, - title: gettext('NVMe Temperatures'), - iconCls: 'fa fa-fw fa-thermometer-half', - textField: 'PveMod_JsonSensorInfo', - renderer: function(value) { - // sensors configuration - const addressPrefix = "nvme-pci-"; - const sensorName = "Composite"; - const tempHelper = Ext.create('PVE.mod.TempHelper', {srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.CELSIUS}); - // display configuration - const itemsPerRow = 0; - // --- - let objValue; - try { - objValue = JSON.parse(value) || {}; - objValue = objValue[Object.keys(objValue)[0]] || {}; - } catch(e) { - objValue = {}; - } - const nvmeKeys = Object.keys(objValue).filter(item => String(item).startsWith(addressPrefix)).sort(); - let nvmeData = []; - nvmeKeys.forEach((nvmeKey, index) => { - try { - let tempVal = NaN, tempMax = NaN, tempCrit = NaN, model = '', serial = ''; - Object.keys(objValue[nvmeKey][sensorName]).forEach((secondLevelKey) => { - if (secondLevelKey.endsWith('_input')) { - tempVal = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); - } else if (secondLevelKey.endsWith('_max')) { - tempMax = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); - } else if (secondLevelKey.endsWith('_crit')) { - tempCrit = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); - } - }); - model = objValue[nvmeKey]['model'] || 'Unknown'; - serial = objValue[nvmeKey]['serial'] || ''; - - if (!isNaN(tempVal)) { - let tempStyle = ''; - if (!isNaN(tempMax) && tempVal >= tempMax) { - tempStyle = 'color: #FFC300; font-weight: bold;'; - } - if (!isNaN(tempCrit) && tempVal >= tempCrit) { - tempStyle = 'color: red; font-weight: bold;'; - } - nvmeData.push({ - model: model, - serial: serial, - temp: tempVal, - tempStyle: tempStyle, - unit: tempHelper.getUnit() - }); - } - } catch(e) { /*_*/ } - }); - - if (nvmeData.length === 0) { - return 'N/A'; - } - - let html = ''; - nvmeData.forEach((data) => { - let deviceName = data.model; - if (data.serial) { - deviceName += ` (${data.serial})`; - } - html += ''; - html += ``; - html += ``; - html += ''; - }); - html += '
${deviceName}${Ext.util.Format.number(data.temp, '0.0')}${data.unit}
'; - return '
' + html + '
'; - } - }, - - // ========== TERTIARY DIAGNOSTICS (Tier 3) ========== - { - xtype: 'box', - colspan: 2, - padding: '15 0 5 0', - html: '
Diagnostics
', - }, - { - itemId: 'speedFan', - colspan: 2, - printBar: false, - title: gettext('System Fans'), - iconCls: 'fa fa-fw fa-snowflake-o', - textField: 'PveMod_JsonSensorInfo', - renderer: function(value) { - // --- - let objValue; - try { - objValue = JSON.parse(value) || {}; - objValue = objValue[Object.keys(objValue)[0]] || {}; - } catch(e) { - objValue = {}; - } - - // Recursive function to find fan keys and values - function findFanKeys(obj, fanKeys, parentKey = null) { - Object.keys(obj).forEach(key => { - const value = obj[key]; - if (typeof value === 'object' && value !== null) { - // If the value is an object, recursively call the function - findFanKeys(value, fanKeys, key); - } else if (/^fan[0-9]+(_input)?$/.test(key)) { - if (true != true && value === 0) { - // Skip this fan if DISPLAY_ZERO_SPEED_FANS is false and value is 0 - return; - } - // If the key matches the pattern, add the parent key and value to the fanKeys array - fanKeys.push({ key: parentKey, value: value }); - } - }); - } - - let speeds = []; - // Loop through the parent keys - Object.keys(objValue).forEach(parentKey => { - const parentObj = objValue[parentKey]; - // Array to store fan keys and values - const fanKeys = []; - // Call the recursive function to find fan keys and values - findFanKeys(parentObj, fanKeys); - // Sort the fan keys - fanKeys.sort((a, b) => { - if (a.key < b.key) return -1; - if (a.key > b.key) return 1; - return 0; - }); - // Process each fan key and value - fanKeys.forEach(({ key: fanKey, value: fanSpeed }) => { - try { - const fan = fanKey.charAt(0).toUpperCase() + fanKey.slice(1); // Capitalize the first letter of fanKey - speeds.push(`${fan}: ${fanSpeed} RPM`); - } catch(e) { - console.error(`Error retrieving fan speed for ${fanKey} in ${parentKey}:`, e); // Debug: Log specific error - } - }); - }); - return '
' + (speeds.length > 0 ? speeds.join(' | ') : 'N/A') + '
'; - } - }, - { - itemId: 'gpuFans', - colspan: 2, - printBar: false, - title: gettext('GPU Fans'), - iconCls: 'fa fa-fw fa-snowflake-o', - textField: 'PveMod_graphicsInfo', - renderer: function(gpuStats) { - if (!gpuStats || !gpuStats.Graphics || !gpuStats.Graphics.NVIDIA) { - return ''; - } - - let rows = []; - - // todo: handle intel, amd - - Object.keys(gpuStats.Graphics.NVIDIA).sort().forEach(key => { - const gpuData = gpuStats.Graphics.NVIDIA[key]; - const stats = gpuData?.stats; - const fan = stats?.fan; - - if (!fan || fan.speed === undefined || fan.speed === null) { - return; - } - - const gpuName = stats?.name || key; - const unit = fan.unit || '%'; - rows.push( - '' + - `${gpuName}` + - `Fan: ${fan.speed}${unit}` + - '', - ); - }); - - if (rows.length === 0) { - return 'N/A'; - } - - return '
' + rows.join('') + '
'; - }, - }, - { - itemId: 'upsc', - colspan: 2, - printBar: false, - title: gettext('UPS Status'), - iconCls: 'fa fa-fw fa-battery-three-quarters', - textField: 'PveMod_upsInfo', - renderer: function(value) { - let objValue = {}; - try { - // Parse the UPS data - if (typeof value === 'string') { - objValue = JSON.parse(value) || {}; - } else if (typeof value === 'object') { - objValue = value || {}; - } - } catch(e) { - objValue = {}; - } - - // If objValue is null or empty, return N/A - if (!objValue || Object.keys(objValue).length === 0) { - return 'N/A'; - } - - // 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'; - } - - // 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`; - } - - // Process each UPS in the data - let allDisplayItems = []; - - Object.keys(objValue).forEach(upsKey => { - const upsData = objValue[upsKey]; - - // 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']; - 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']; - - // Main status line with all metrics - let statusLine = ''; - - // 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: ' + statusText + ''; - } else { - statusLine += 'Status: N/A'; - } - - // Battery charge - if (statusLine) statusLine += ' | '; - if (batteryCharge) { - const chargeColor = getPercentageColor(batteryCharge, false); - let chargeStyle = chargeColor ? ('color: ' + chargeColor + ';') : ''; - statusLine += 'Battery: ' + batteryCharge + '%'; - } else { - statusLine += 'Battery: N/A'; - } - - // Load percentage - if (statusLine) statusLine += ' | '; - if (upsLoad) { - const loadColor = getPercentageColor(upsLoad, true); - let loadStyle = loadColor ? ('color: ' + loadColor + ';') : ''; - statusLine += 'Load: ' + upsLoad + '%'; - } else { - statusLine += 'Load: N/A'; - } - - // 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: ' + formatRuntime(runtime) + ''; - } else { - statusLine += 'Runtime: N/A'; - } - - // Input voltage - if (statusLine) statusLine += ' | '; - if (inputVoltage) { - statusLine += 'Input: ' + parseFloat(inputVoltage).toFixed(0) + 'V'; - } else { - statusLine += 'Input: N/A'; - } - - // 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: ' + actualWattage + 'W'; - } else { - statusLine += 'Output: N/A'; - } - - // 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 += ' | Test: ' + testResult + ''; - } else { - statusLine += ' | Test: N/A'; - } - - // Build UPS display with model on left, details on right - let upsHtml = ''; - upsHtml += '' + (upsModel || upsKey) + ''; - upsHtml += '' + statusLine + ''; - upsHtml += ''; - - allDisplayItems.push(upsHtml); - }); - - // Format the final output for all UPS devices - return '
' + allDisplayItems.join('') + '
'; - } - }, - { - xtype: 'box', - colspan: 2, - padding: '15 0 5 0', - html: '
System
', - }, - { - 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: '', - }, - { - itemId: 'pve_mod_version', - colspan: 2, - printBar: false, - title: gettext('Sensor Mod Version'), - textField: 'PveMod_Version', - value: '', - }, - { - itemId: 'sysinfo', - colspan: 2, - printBar: false, - title: gettext('Information'), - textField: 'PveMod_systemInfo', - renderer: function(value) { - if (value === null || value === undefined) { - return ''; - } - return 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-rrd-gpu', { - extend: 'Ext.data.Model', - fields: [ - 'freq_req', 'freq_act', 'rc6', - 'power_gpu', 'power_pkg', - 'render_busy', 'blitter_busy', 'video_busy', 'videnh_busy', - 'gpu_util', 'mem_util', 'mem_used', 'mem_total', - 'power_draw', 'power_limit', 'temp_gpu', 'fan_speed', - { type: 'date', dateFormat: 'timestamp', name: 'time' }, - ], -}); - -Ext.define('PVE.data.GpuRRDStore', { - extend: 'Proxmox.data.RRDStore', - alias: 'store.pveGpuRRDStore', - - model: 'pve-rrd-gpu', - card: undefined, - - setRRDUrl: function(timeframe, cf) { - var me = this; - if (!me.rrdurl) { return; } - if (!timeframe) { timeframe = me.timeframe; } - if (!cf) { cf = me.cf; } - me.proxy.url = me.rrdurl + - '?card=' + encodeURIComponent(me.card) + - '&timeframe=' + timeframe + - '&cf=' + cf; - }, -}); - -Ext.define('PVE.node.GpuRRD', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveNodeGpuRRD', - - layout: 'fit', - title: 'GPU', - - initComponent: function() { - var me = this; - - var nodename = me.nodename; - var card = me.card || 'card0'; - var baseurl = '/api2/json/nodes/' + nodename + '/gpurrddata'; - var isNvidia = card.indexOf('nvidia') === 0; - - var store = Ext.create('PVE.data.GpuRRDStore', { - rrdurl: baseurl, - card: card, - }); - - var items; - if (isNvidia) { - items = [ - { - xtype: 'proxmoxRRDChart', - title: 'GPU & Memory Utilization', - fields: ['gpu_util', 'mem_util'], - fieldTitles: ['GPU %', 'Memory %'], - unit: 'percent', - store: store, - }, - { - xtype: 'proxmoxRRDChart', - title: 'Memory Usage (MiB)', - fields: ['mem_used', 'mem_total'], - fieldTitles: ['Used', 'Total'], - store: store, - }, - { - xtype: 'proxmoxRRDChart', - title: 'Power Draw (W)', - fields: ['power_draw', 'power_limit'], - fieldTitles: ['Draw', 'Limit'], - store: store, - }, - { - xtype: 'proxmoxRRDChart', - title: 'Temperature & Fan', - fields: ['temp_gpu', 'fan_speed'], - fieldTitles: ['Temp (°C)', 'Fan %'], - store: store, - }, - ]; - } else { - items = [ - { - xtype: 'proxmoxRRDChart', - title: 'GPU Frequency (MHz)', - fields: ['freq_req', 'freq_act'], - fieldTitles: ['Requested', 'Actual'], - store: store, - }, - { - xtype: 'proxmoxRRDChart', - title: 'Engine Busy', - fields: ['render_busy', 'blitter_busy', 'video_busy', 'videnh_busy'], - fieldTitles: ['Render/3D %', 'Blitter %', 'Video %', 'VideoEnh %'], - unit: 'percent', - store: store, - }, - { - xtype: 'proxmoxRRDChart', - title: 'Power (W)', - fields: ['power_gpu', 'power_pkg'], - fieldTitles: ['GPU', 'Package'], - store: store, - }, - { - xtype: 'proxmoxRRDChart', - title: 'RC6 Residency', - fields: ['rc6'], - fieldTitles: ['RC6 %'], - unit: 'percent', - store: store, - }, - ]; - } - - Ext.apply(me, { - items: [{ - xtype: 'container', - layout: { - type: 'vbox', - align: 'stretch', - }, - items: items, - }], - }); - - me.callParent(); - - me.on('activate', function() { store.startUpdate(); }); - me.on('deactivate', function() { store.stopUpdate(); }); - me.on('destroy', function() { store.stopUpdate(); }); - }, -}); - -Ext.define('PVE.node.Summary', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveNodeSummary', - - scrollable: true, - bodyPadding: 5, - - showVersions: function () { - var me = this; - - 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', - }); - - var gpurrdstore = Ext.create('PVE.data.GpuRRDStore', { - rrdurl: '/api2/json/nodes/' + nodename + '/gpurrddata', - card: 'card0', - }); - - 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', - }, - { - xtype: 'proxmoxRRDChart', - title: gettext('GPU Frequency (MHz)'), - fields: ['freq_req', 'freq_act'], - fieldTitles: [gettext('Requested'), gettext('Actual')], - store: gpurrdstore, - }, - { - xtype: 'proxmoxRRDChart', - title: gettext('GPU Engine Busy'), - fields: ['render_busy', 'blitter_busy', 'video_busy', 'videnh_busy'], - fieldTitles: [gettext('Render/3D'), gettext('Blitter'), gettext('Video'), gettext('VideoEnh')], - unit: 'percent', - store: gpurrdstore, - }, - { - xtype: 'proxmoxRRDChart', - title: gettext('GPU Power (W)'), - fields: ['power_gpu', 'power_pkg'], - fieldTitles: [gettext('GPU'), gettext('Package')], - store: gpurrdstore, - }, - { - xtype: 'proxmoxRRDChart', - title: gettext('GPU RC6 Residency'), - fields: ['rc6'], - fieldTitles: [gettext('RC6 %')], - unit: 'percent', - store: gpurrdstore, - }, - ], - listeners: { - resize: function (panel) { - Proxmox.Utils.updateColumns(panel); - }, - }, - }, - ], - listeners: { - activate: function () { - rstore.setInterval(1000); - rstore.startUpdate(); - rrdstore.startUpdate(); - gpurrdstore.startUpdate(); - }, - destroy: function () { - rstore.setInterval(5000); - rrdstore.stopUpdate(); - gpurrdstore.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.mod.TempHelper', { + //singleton: true, + + requires: ['Ext.util.Format'], + + statics: { + CELSIUS: 0, + FAHRENHEIT: 1 + }, + + srcUnit: null, + dstUnit: null, + + isValidUnit: function (unit) { + return ( + Ext.isNumber(unit) && (unit === this.self.CELSIUS || unit === this.self.FAHRENHEIT) + ); + }, + + constructor: function (config) { + this.srcUnit = config && this.isValidUnit(config.srcUnit) ? config.srcUnit : this.self.CELSIUS; + this.dstUnit = config && this.isValidUnit(config.dstUnit) ? config.dstUnit : this.self.CELSIUS; + }, + + toFahrenheit: function (tempCelsius) { + return Ext.isNumber(tempCelsius) + ? tempCelsius * 9 / 5 + 32 + : NaN; + }, + + toCelsius: function (tempFahrenheit) { + return Ext.isNumber(tempFahrenheit) + ? (tempFahrenheit - 32) * 5 / 9 + : NaN; + }, + + getTemp: function (value) { + if (this.srcUnit !== this.dstUnit) { + switch (this.srcUnit) { + case this.self.CELSIUS: + switch (this.dstUnit) { + case this.self.FAHRENHEIT: + return this.toFahrenheit(value); + + default: + Ext.raise({ + msg: + 'Unsupported destination temperature unit: ' + this.dstUnit, + }); + } + case this.self.FAHRENHEIT: + switch (this.dstUnit) { + case this.self.CELSIUS: + return this.toCelsius(value); + + default: + Ext.raise({ + msg: + 'Unsupported destination temperature unit: ' + this.dstUnit, + }); + } + default: + Ext.raise({ + msg: 'Unsupported source temperature unit: ' + this.srcUnit, + }); + } + } else { + return value; + } + }, + + getUnit: function(plainText) { + switch (this.dstUnit) { + case this.self.CELSIUS: + return plainText !== true ? '°C' : '\'C'; + + case this.self.FAHRENHEIT: + return plainText !== true ? '°F' : '\'F'; + + default: + Ext.raise({ + msg: 'Unsupported destination temperature unit: ' + this.srcUnit, + }); + } + }, +}); +Ext.define('PVE.node.StatusView', { + extend: 'Proxmox.panel.StatusView', + alias: 'widget.pveNodeStatus', + + minHeight: 360, + flex: 1, + collapsible: true, + titleCollapse: true, + bodyPadding: '20 15 20 15', + + layout: { + type: 'table', + columns: 2, + trAttrs: { valign: 'top' }, + tableAttrs: { + style: { + width: '100%', + }, + }, + }, + + defaults: { + xtype: 'pmxInfoWidget', + padding: '0 10 2 10', + }, + + items: [ + // ========== Primary Metrics ========== + { + xtype: 'box', + colspan: 2, + padding: '0', + html: '
Primary Metrics
', + }, + { + itemId: 'cpu', + iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon', + title: gettext('CPU Usage'), + valueField: 'cpu', + maxField: 'cpuinfo', + renderer: function(value, record) { + let result = Proxmox.Utils.render_node_cpu_usage(value, record); + // Append CPU model if available + if (record && record.cpuinfo && record.cpuinfo.model) { + result += ` (${record.cpuinfo.model})`; + } + return result; + }, + }, + { + iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', + itemId: 'memory', + title: gettext('Memory Usage'), + valueField: 'memory', + maxField: 'memory', + warningThreshold: 0.9, + criticalThreshold: 0.975, + renderer: Proxmox.Utils.render_node_size_usage, + }, + { + itemId: 'ksm', + iconCls: 'fa fa-fw fa-clone', + printBar: false, + title: gettext('KSM sharing'), + textField: 'ksm', + renderer: function (record) { + return Proxmox.Utils.render_size(record.shared); + }, + }, + { + itemId: 'gpu', + iconCls: 'fa fa-fw fa-desktop', + title: gettext('GPU Usage'), + printBar: false, + textField: 'PveMod_graphicsInfo', + renderer: function(gpuStats) { + if (!gpuStats || !gpuStats.Graphics) { + return ''; + } + + let hasActiveGPU = false; + let gpuName = ''; + + // Check Intel GPUs + if (gpuStats.Graphics.Intel) { + const keys = Object.keys(gpuStats.Graphics.Intel).sort(); + if (keys.length > 0) { + const gpuData = gpuStats.Graphics.Intel[keys[0]]; + hasActiveGPU = true; + gpuName = gpuData.name; + } + } + + // Check NVIDIA GPUs + if (gpuStats.Graphics.NVIDIA) { + const keys = Object.keys(gpuStats.Graphics.NVIDIA).sort(); + if (keys.length > 0) { + const stats = gpuStats.Graphics.NVIDIA[keys[0]].stats; + hasActiveGPU = true; + gpuName = stats.name; + } + } + + return hasActiveGPU ? gpuName : ''; + }, + }, + { + itemId: 'gpu_usage', + iconCls: 'fa fa-fw fa-desktop', + title: gettext('GPU 0'), + valueField: 'gpuStats', + printBar: false, + textField: 'gpuStats', + renderer: function(gpuStats) { + if (!gpuStats || !gpuStats.Graphics) { + return ''; + } + + // Check Intel GPUs + if (gpuStats.Graphics.Intel) { + const keys = Object.keys(gpuStats.Graphics.Intel).sort(); + if (keys.length > 0) { + const gpuData = gpuStats.Graphics.Intel[keys[0]]; + if (gpuData.stats.engines && gpuData.stats.engines['Render/3D']) { + const usage = gpuData.stats.engines['Render/3D'].busy; + return `${usage}%`; + } + } + } + + // Check NVIDIA GPUs + if (gpuStats.Graphics.NVIDIA) { + const keys = Object.keys(gpuStats.Graphics.NVIDIA).sort(); + if (keys.length > 0) { + const stats = gpuStats.Graphics.NVIDIA[keys[0]].stats; + if (stats.utilization) { + return `${stats.utilization.gpu}%`; + } + } + } + + return ''; + }, + }, + { + iconCls: 'fa fa-fw fa-hdd-o', + itemId: 'rootfs', + title: gettext('Disk (/) Usage'), + valueField: 'rootfs', + maxField: 'rootfs', + renderer: Proxmox.Utils.render_node_size_usage, + }, + { + iconCls: 'fa fa-fw fa-refresh', + itemId: 'swap', + title: gettext('SWAP Usage'), + valueField: 'swap', + maxField: 'swap', + warningThreshold: 0.4, + criticalThreshold: 0.8, + renderer: Proxmox.Utils.render_node_size_usage, + }, + // Fill the remaining cell so the next colspan:2 section header starts on a new row. + { + xtype: 'box', + html: '', + padding: 0, + }, + + // ========== Secondary Metrics ========== + { + xtype: 'box', + colspan: 2, + padding: '15 0 5 0', + html: '
Secondary Metrics
', + }, + { + itemId: 'load', + iconCls: 'fa fa-fw fa-tasks', + title: gettext('CPU Load Average'), + printBar: false, + textField: 'loadavg', + }, + { + itemId: 'wait', + iconCls: 'fa fa-fw fa-clock-o', + title: gettext('CPU I/O Delay'), + valueField: 'wait', + }, + { + itemId: 'thermalCpu', + colspan: 2, + printBar: false, + title: gettext('CPU Thermal State'), + iconCls: 'fa fa-fw fa-thermometer-half', + textField: 'PveMod_JsonSensorInfo', + renderer: function(value){ + // sensors configuration + const cpuTempHelper = Ext.create('PVE.mod.TempHelper', {srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.CELSIUS}); + // display configuration + const itemsPerRow = 0; + // --- + let objValue; + try { + objValue = JSON.parse(value) || {}; + objValue = objValue[Object.keys(objValue)[0]] || {}; + } catch(e) { + objValue = {}; + } + + 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 bINTEL = cpuKeysI.length > 0 ? true : false; + const INTELPackagePrefix = 'Core' == 'Core' ? 'Core ' : 'Package id'; + const INTELPackageCaption = 'Core' == 'Core' ? 'Core' : 'Package'; + let AMDPackagePrefix = 'Tccd'; + let AMDPackageCaption = 'CCD'; + + if (cpuKeysA.length > 0) { + let bTccd = false; + let bTctl = false; + let bTdie = false; + let bCpuCoreTemp = false; + cpuKeysA.forEach((cpuKey, cpuIndex) => { + let items = objValue[cpuKey]; + bTccd = Object.keys(items).findIndex(item => { return String(item).startsWith('Tccd'); }) >= 0; + bTctl = Object.keys(items).findIndex(item => { return String(item).startsWith('Tctl'); }) >= 0; + bTdie = Object.keys(items).findIndex(item => { return String(item).startsWith('Tdie'); }) >= 0; + bCpuCoreTemp = Object.keys(items).findIndex(item => { return String(item) === 'CPU Core Temp'; }) >= 0; + }); + if (bTccd && 'Core' == 'Core') { + AMDPackagePrefix = 'Tccd'; + AMDPackageCaption = 'ccd'; + } else if (bCpuCoreTemp && 'Core' == 'Package') { + AMDPackagePrefix = 'CPU Core Temp'; + AMDPackageCaption = 'CPU Core Temp'; + } else if (bTdie) { + AMDPackagePrefix = 'Tdie'; + AMDPackageCaption = 'die'; + } else if (bTctl) { + AMDPackagePrefix = 'Tctl'; + AMDPackageCaption = 'ctl'; + } else { + AMDPackagePrefix = 'temp'; + AMDPackageCaption = 'Temp'; + } + } + + const cpuKeys = bINTEL ? cpuKeysI : cpuKeysA; + const cpuItemPrefix = bINTEL ? INTELPackagePrefix : AMDPackagePrefix; + const cpuTempCaption = bINTEL ? INTELPackageCaption : AMDPackageCaption; + const formatTemp = bINTEL ? '0' : '0.0'; + const cpuCount = cpuKeys.length; + let temps = []; + + cpuKeys.forEach((cpuKey, cpuIndex) => { + let cpuTemps = []; + const items = objValue[cpuKey]; + const cpuModel = items.cpu_model || ''; + + const itemKeys = Object.keys(items).filter(item => { + if ('Core' == 'Core') { + // In Core mode: only show individual cores/CCDs, exclude overall CPU temp + return String(item).includes(cpuItemPrefix) || String(item).startsWith('Tccd'); + } else { + // In Package mode: show overall CPU temp and package-level readings + return String(item).includes(cpuItemPrefix) || String(item) === 'CPU Core Temp'; + } + }).sort((a, b) => { + // Sort cores numerically + let numA = parseInt(a.match(/\d+/)?.[0] || '0', 10); + let numB = parseInt(b.match(/\d+/)?.[0] || '0', 10); + return numA - numB; + }); + + itemKeys.forEach((coreKey) => { + try { + let tempVal = NaN, tempMax = NaN, tempCrit = NaN; + Object.keys(items[coreKey]).forEach((secondLevelKey) => { + if (secondLevelKey.endsWith('_input')) { + tempVal = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); + } else if (secondLevelKey.endsWith('_max')) { + tempMax = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); + } else if (secondLevelKey.endsWith('_crit')) { + tempCrit = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); + } + }); + + if (!isNaN(tempVal)) { + let tempStyle = ''; + if (!isNaN(tempMax) && tempVal >= tempMax) { + tempStyle = 'color: #FFC300; font-weight: bold;'; + } + if (!isNaN(tempCrit) && tempVal >= tempCrit) { + tempStyle = 'color: red; font-weight: bold;'; + } + + let tempStr = ''; + + // Enhanced parsing for AMD temperatures + if (coreKey.startsWith('Tccd')) { + let tempIndex = coreKey.match(/Tccd(\d+)/); + if (tempIndex !== null && tempIndex.length > 1) { + tempIndex = tempIndex[1]; + tempStr = `${cpuTempCaption} ${tempIndex}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } else { + tempStr = `${cpuTempCaption}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } + } + // Handle CPU Core Temp (single overall temperature) + else if (coreKey === 'CPU Core Temp') { + tempStr = `${cpuTempCaption}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } + // Enhanced parsing for Intel cores (P-Core, E-Core, regular Core) + else { + let tempIndex = coreKey.match(/(?:P\s+Core|E\s+Core|Core)\s*(\d+)/); + if (tempIndex !== null && tempIndex.length > 1) { + tempIndex = tempIndex[1]; + let coreType = coreKey.startsWith('P Core') ? 'P Core' : + coreKey.startsWith('E Core') ? 'E Core' : + cpuTempCaption; + tempStr = `${coreType} ${tempIndex}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } else { + // fallback for CPUs which do not have a core index + let coreType = coreKey.startsWith('P Core') ? 'P Core' : + coreKey.startsWith('E Core') ? 'E Core' : + cpuTempCaption; + tempStr = `${coreType}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } + } + + cpuTemps.push(tempStr); + } + } catch (e) { /*_*/ } + }); + + if(cpuTemps.length > 0) { + temps.push({ model: cpuModel, temps: cpuTemps }); + } + }); + + let html = ''; + temps.forEach((cpuData, cpuIndex) => { + const strCoreTemps = cpuData.temps.map((strTemp, index, arr) => { + return strTemp + (index + 1 < arr.length ? (itemsPerRow > 0 && (index + 1) % itemsPerRow === 0 ? '
' : ' | ') : ''); + }); + if(strCoreTemps.length > 0) { + let cpuLabel = cpuCount > 1 ? `Socket ${cpuIndex + 1}` : 'Socket 1'; + let cpuModelStr = cpuData.model || 'Unknown CPU'; + + html += ''; + html += ``; + html += ``; + html += ''; + } + }); + html += '
${cpuModelStr}${strCoreTemps.join('')}
'; + + return html.indexOf('') > 0 + ? '
' + html + '
' + : 'N/A'; + } + }, + { + itemId: 'gpu_details', + colspan: 2, + iconCls: 'fa fa-fw fa-desktop', + title: gettext('GPU Details'), + printBar: false, + textField: 'PveMod_graphicsInfo', + renderer: function(gpuStats) { + if (!gpuStats || !gpuStats.Graphics) { + return ''; + } + + let html = ''; + + // Intel GPUs - Secondary details + if (gpuStats.Graphics.Intel) { + Object.keys(gpuStats.Graphics.Intel).sort().forEach(key => { + const gpuData = gpuStats.Graphics.Intel[key]; + + let details = []; + + // All engine details + if (gpuData.stats.engines) { + if (gpuData.stats.engines['Render/3D']) { + details.push(`Render/3D: ${gpuData.stats.engines['Render/3D'].busy}%`); + } + if (gpuData.stats.engines['Video']) { + details.push(`Video: ${gpuData.stats.engines['Video'].busy}%`); + } + if (gpuData.stats.engines['Blitter']) { + details.push(`Blitter: ${gpuData.stats.engines['Blitter'].busy}%`); + } + if (gpuData.stats.engines['VideoEnhance']) { + details.push(`VideoEnhance: ${gpuData.stats.engines['VideoEnhance'].busy}%`); + } + } + + // Power + if (gpuData.stats.power) { + details.push(`Power: ${gpuData.stats.power?.GPU ?? 'N/A'} / ${gpuData.stats.power?.Package ?? 'N/A'} ${gpuData.stats.power?.unit || 'W'}`); + } + + // Frequency + if (gpuData.stats.frequency) { + details.push(`Freq: ${gpuData.stats.frequency?.actual ?? 'N/A'}/${gpuData.stats.frequency?.requested ?? 'N/A'} ${gpuData.stats.frequency?.unit || 'MHz'}`); + } + + html += ''; + html += ``; + html += ``; + html += ''; + }); + } + + // NVIDIA GPUs - Secondary details + if (gpuStats.Graphics.NVIDIA) { + Object.keys(gpuStats.Graphics.NVIDIA).sort().forEach(key => { + const gpuData = gpuStats.Graphics.NVIDIA[key]; + const stats = gpuData.stats; + + let details = []; + + // Memory Utilization + if (stats.utilization && stats.utilization.memory) { + const memUsage = parseInt(stats.utilization.memory); + let memStyle = ''; + if (memUsage >= 90) memStyle = 'color: #d9534f; font-weight: bold;'; + else if (memUsage >= 70) memStyle = 'color: #f0ad4e; font-weight: bold;'; + details.push(`MEM: ${stats.utilization.memory}%`); + } + + // VRAM Usage + if (stats.memory) { + const vramUsedGB = parseInt(stats.memory.used); + const vramTotalGB = parseInt(stats.memory.total); + const vramPercent = (vramUsedGB / vramTotalGB) * 100; + let vramStyle = ''; + if (vramPercent >= 90) vramStyle = 'color: #d9534f; font-weight: bold;'; + else if (vramPercent >= 70) vramStyle = 'color: #f0ad4e; font-weight: bold;'; + details.push(`VRAM: ${stats.memory.used}/${stats.memory.total} ${stats.memory.unit}`); + } + + // Temperature + if (stats.temperature) { + let tempStyle = ''; + if (stats.temperature.gpu >= 80) { + tempStyle = 'color: red; font-weight: bold;'; + } else if (stats.temperature.gpu >= 70) { + tempStyle = 'color: #FFC300; font-weight: bold;'; + } + details.push(`Temp: ${stats.temperature.gpu}${stats.temperature.unit}`); + } + + // Power + if (stats.power) { + details.push(`Power: ${stats.power.draw}/${stats.power.limit} ${stats.power.unit}`); + } + + html += ''; + html += ``; + html += ``; + html += ''; + }); + } + + html += '
${gpuData.name}${details.join(' | ')}
${stats.name}${details.join(' | ')}
'; + return html.indexOf('') > 0 + ? '
' + html + '
' + : ''; + }, + }, + { + itemId: 'thermalNvme', + colspan: 2, + printBar: false, + title: gettext('NVMe Temperatures'), + iconCls: 'fa fa-fw fa-thermometer-half', + textField: 'PveMod_JsonSensorInfo', + renderer: function(value) { + // sensors configuration + const addressPrefix = "nvme-pci-"; + const sensorName = "Composite"; + const tempHelper = Ext.create('PVE.mod.TempHelper', {srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.CELSIUS}); + // display configuration + const itemsPerRow = 0; + // --- + let objValue; + try { + objValue = JSON.parse(value) || {}; + objValue = objValue[Object.keys(objValue)[0]] || {}; + } catch(e) { + objValue = {}; + } + const nvmeKeys = Object.keys(objValue).filter(item => String(item).startsWith(addressPrefix)).sort(); + let nvmeData = []; + nvmeKeys.forEach((nvmeKey, index) => { + try { + let tempVal = NaN, tempMax = NaN, tempCrit = NaN, model = '', serial = ''; + Object.keys(objValue[nvmeKey][sensorName]).forEach((secondLevelKey) => { + if (secondLevelKey.endsWith('_input')) { + tempVal = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); + } else if (secondLevelKey.endsWith('_max')) { + tempMax = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); + } else if (secondLevelKey.endsWith('_crit')) { + tempCrit = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); + } + }); + model = objValue[nvmeKey]['model'] || 'Unknown'; + serial = objValue[nvmeKey]['serial'] || ''; + + if (!isNaN(tempVal)) { + let tempStyle = ''; + if (!isNaN(tempMax) && tempVal >= tempMax) { + tempStyle = 'color: #FFC300; font-weight: bold;'; + } + if (!isNaN(tempCrit) && tempVal >= tempCrit) { + tempStyle = 'color: red; font-weight: bold;'; + } + nvmeData.push({ + model: model, + serial: serial, + temp: tempVal, + tempStyle: tempStyle, + unit: tempHelper.getUnit() + }); + } + } catch(e) { /*_*/ } + }); + + if (nvmeData.length === 0) { + return 'N/A'; + } + + let html = ''; + nvmeData.forEach((data) => { + let deviceName = data.model; + if (data.serial) { + deviceName += ` (${data.serial})`; + } + html += ''; + html += ``; + html += ``; + html += ''; + }); + html += '
${deviceName}${Ext.util.Format.number(data.temp, '0.0')}${data.unit}
'; + return '
' + html + '
'; + } + }, + + // ========== TERTIARY DIAGNOSTICS (Tier 3) ========== + { + xtype: 'box', + colspan: 2, + padding: '15 0 5 0', + html: '
Diagnostics
', + }, + { + itemId: 'speedFan', + colspan: 2, + printBar: false, + title: gettext('System Fans'), + iconCls: 'fa fa-fw fa-snowflake-o', + textField: 'PveMod_JsonSensorInfo', + renderer: function(value) { + // --- + let objValue; + try { + objValue = JSON.parse(value) || {}; + objValue = objValue[Object.keys(objValue)[0]] || {}; + } catch(e) { + objValue = {}; + } + + // Recursive function to find fan keys and values + function findFanKeys(obj, fanKeys, parentKey = null) { + Object.keys(obj).forEach(key => { + const value = obj[key]; + if (typeof value === 'object' && value !== null) { + // If the value is an object, recursively call the function + findFanKeys(value, fanKeys, key); + } else if (/^fan[0-9]+(_input)?$/.test(key)) { + if (true != true && value === 0) { + // Skip this fan if DISPLAY_ZERO_SPEED_FANS is false and value is 0 + return; + } + // If the key matches the pattern, add the parent key and value to the fanKeys array + fanKeys.push({ key: parentKey, value: value }); + } + }); + } + + let speeds = []; + // Loop through the parent keys + Object.keys(objValue).forEach(parentKey => { + const parentObj = objValue[parentKey]; + // Array to store fan keys and values + const fanKeys = []; + // Call the recursive function to find fan keys and values + findFanKeys(parentObj, fanKeys); + // Sort the fan keys + fanKeys.sort((a, b) => { + if (a.key < b.key) return -1; + if (a.key > b.key) return 1; + return 0; + }); + // Process each fan key and value + fanKeys.forEach(({ key: fanKey, value: fanSpeed }) => { + try { + const fan = fanKey.charAt(0).toUpperCase() + fanKey.slice(1); // Capitalize the first letter of fanKey + speeds.push(`${fan}: ${fanSpeed} RPM`); + } catch(e) { + console.error(`Error retrieving fan speed for ${fanKey} in ${parentKey}:`, e); // Debug: Log specific error + } + }); + }); + return '
' + (speeds.length > 0 ? speeds.join(' | ') : 'N/A') + '
'; + } + }, + { + itemId: 'gpuFans', + colspan: 2, + printBar: false, + title: gettext('GPU Fans'), + iconCls: 'fa fa-fw fa-snowflake-o', + textField: 'PveMod_graphicsInfo', + renderer: function(gpuStats) { + if (!gpuStats || !gpuStats.Graphics || !gpuStats.Graphics.NVIDIA) { + return ''; + } + + let rows = []; + + // todo: handle intel, amd + + Object.keys(gpuStats.Graphics.NVIDIA).sort().forEach(key => { + const gpuData = gpuStats.Graphics.NVIDIA[key]; + const stats = gpuData?.stats; + const fan = stats?.fan; + + if (!fan || fan.speed === undefined || fan.speed === null) { + return; + } + + const gpuName = stats?.name || key; + const unit = fan.unit || '%'; + rows.push( + '' + + `${gpuName}` + + `Fan: ${fan.speed}${unit}` + + '', + ); + }); + + if (rows.length === 0) { + return 'N/A'; + } + + return '
' + rows.join('') + '
'; + }, + }, + { + itemId: 'upsc', + colspan: 2, + printBar: false, + title: gettext('UPS Status'), + iconCls: 'fa fa-fw fa-battery-three-quarters', + textField: 'PveMod_upsInfo', + renderer: function(value) { + let objValue = {}; + try { + // Parse the UPS data + if (typeof value === 'string') { + objValue = JSON.parse(value) || {}; + } else if (typeof value === 'object') { + objValue = value || {}; + } + } catch(e) { + objValue = {}; + } + + // If objValue is null or empty, return N/A + if (!objValue || Object.keys(objValue).length === 0) { + return 'N/A'; + } + + // 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'; + } + + // 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`; + } + + // Process each UPS in the data + let allDisplayItems = []; + + Object.keys(objValue).forEach(upsKey => { + const upsData = objValue[upsKey]; + + // 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']; + 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']; + + // Main status line with all metrics + let statusLine = ''; + + // 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: ' + statusText + ''; + } else { + statusLine += 'Status: N/A'; + } + + // Battery charge + if (statusLine) statusLine += ' | '; + if (batteryCharge) { + const chargeColor = getPercentageColor(batteryCharge, false); + let chargeStyle = chargeColor ? ('color: ' + chargeColor + ';') : ''; + statusLine += 'Battery: ' + batteryCharge + '%'; + } else { + statusLine += 'Battery: N/A'; + } + + // Load percentage + if (statusLine) statusLine += ' | '; + if (upsLoad) { + const loadColor = getPercentageColor(upsLoad, true); + let loadStyle = loadColor ? ('color: ' + loadColor + ';') : ''; + statusLine += 'Load: ' + upsLoad + '%'; + } else { + statusLine += 'Load: N/A'; + } + + // 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: ' + formatRuntime(runtime) + ''; + } else { + statusLine += 'Runtime: N/A'; + } + + // Input voltage + if (statusLine) statusLine += ' | '; + if (inputVoltage) { + statusLine += 'Input: ' + parseFloat(inputVoltage).toFixed(0) + 'V'; + } else { + statusLine += 'Input: N/A'; + } + + // 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: ' + actualWattage + 'W'; + } else { + statusLine += 'Output: N/A'; + } + + // 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 += ' | Test: ' + testResult + ''; + } else { + statusLine += ' | Test: N/A'; + } + + // Build UPS display with model on left, details on right + let upsHtml = ''; + upsHtml += '' + (upsModel || upsKey) + ''; + upsHtml += '' + statusLine + ''; + upsHtml += ''; + + allDisplayItems.push(upsHtml); + }); + + // Format the final output for all UPS devices + return '
' + allDisplayItems.join('') + '
'; + } + }, + { + xtype: 'box', + colspan: 2, + padding: '15 0 5 0', + html: '
System
', + }, + { + 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: '', + }, + { + itemId: 'pve_mod_version', + colspan: 2, + printBar: false, + title: gettext('Sensor Mod Version'), + textField: 'PveMod_Version', + value: '', + }, + { + itemId: 'sysinfo', + colspan: 2, + printBar: false, + title: gettext('Information'), + textField: 'PveMod_systemInfo', + renderer: function(value) { + if (value === null || value === undefined) { + return ''; + } + return 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-rrd-gpu', { + extend: 'Ext.data.Model', + fields: [ + 'freq_req', 'freq_act', 'rc6', + 'power_gpu', 'power_pkg', + 'render_busy', 'blitter_busy', 'video_busy', 'videnh_busy', + 'gpu_util', 'mem_util', 'mem_used', 'mem_total', + 'power_draw', 'power_limit', 'temp_gpu', 'fan_speed', + { type: 'date', dateFormat: 'timestamp', name: 'time' }, + ], +}); + +Ext.define('PVE.data.GpuRRDStore', { + extend: 'Proxmox.data.RRDStore', + alias: 'store.pveGpuRRDStore', + + model: 'pve-rrd-gpu', + card: undefined, + + setRRDUrl: function(timeframe, cf) { + var me = this; + if (!me.rrdurl) { return; } + if (!timeframe) { timeframe = me.timeframe; } + if (!cf) { cf = me.cf; } + me.proxy.url = me.rrdurl + + '?card=' + encodeURIComponent(me.card) + + '&timeframe=' + timeframe + + '&cf=' + cf; + }, +}); + +Ext.define('PVE.node.GpuRRD', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveNodeGpuRRD', + + layout: 'fit', + title: 'GPU', + + initComponent: function() { + var me = this; + + var nodename = me.nodename; + var card = me.card || 'card0'; + var baseurl = '/api2/json/nodes/' + nodename + '/gpurrddata'; + var isNvidia = card.indexOf('nvidia') === 0; + + var store = Ext.create('PVE.data.GpuRRDStore', { + rrdurl: baseurl, + card: card, + }); + + var items; + if (isNvidia) { + items = [ + { + xtype: 'proxmoxRRDChart', + title: 'GPU & Memory Utilization', + fields: ['gpu_util', 'mem_util'], + fieldTitles: ['GPU %', 'Memory %'], + unit: 'percent', + store: store, + }, + { + xtype: 'proxmoxRRDChart', + title: 'Memory Usage (MiB)', + fields: ['mem_used', 'mem_total'], + fieldTitles: ['Used', 'Total'], + store: store, + }, + { + xtype: 'proxmoxRRDChart', + title: 'Power Draw (W)', + fields: ['power_draw', 'power_limit'], + fieldTitles: ['Draw', 'Limit'], + store: store, + }, + { + xtype: 'proxmoxRRDChart', + title: 'Temperature & Fan', + fields: ['temp_gpu', 'fan_speed'], + fieldTitles: ['Temp (°C)', 'Fan %'], + store: store, + }, + ]; + } else { + items = [ + { + xtype: 'proxmoxRRDChart', + title: 'GPU Frequency (MHz)', + fields: ['freq_req', 'freq_act'], + fieldTitles: ['Requested', 'Actual'], + store: store, + }, + { + xtype: 'proxmoxRRDChart', + title: 'Engine Busy', + fields: ['render_busy', 'blitter_busy', 'video_busy', 'videnh_busy'], + fieldTitles: ['Render/3D %', 'Blitter %', 'Video %', 'VideoEnh %'], + unit: 'percent', + store: store, + }, + { + xtype: 'proxmoxRRDChart', + title: 'Power (W)', + fields: ['power_gpu', 'power_pkg'], + fieldTitles: ['GPU', 'Package'], + store: store, + }, + { + xtype: 'proxmoxRRDChart', + title: 'RC6 Residency', + fields: ['rc6'], + fieldTitles: ['RC6 %'], + unit: 'percent', + store: store, + }, + ]; + } + + Ext.apply(me, { + items: [{ + xtype: 'container', + layout: { + type: 'vbox', + align: 'stretch', + }, + items: items, + }], + }); + + me.callParent(); + + me.on('activate', function() { store.startUpdate(); }); + me.on('deactivate', function() { store.stopUpdate(); }); + me.on('destroy', function() { store.stopUpdate(); }); + }, +}); + +Ext.define('PVE.node.Summary', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveNodeSummary', + + scrollable: true, + bodyPadding: 5, + + showVersions: function () { + var me = this; + + 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', + }); + + var gpurrdstore = Ext.create('PVE.data.GpuRRDStore', { + rrdurl: '/api2/json/nodes/' + nodename + '/gpurrddata', + card: 'card0', + }); + + 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', + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('GPU Frequency (MHz)'), + fields: ['freq_req', 'freq_act'], + fieldTitles: [gettext('Requested'), gettext('Actual')], + store: gpurrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('GPU Engine Busy'), + fields: ['render_busy', 'blitter_busy', 'video_busy', 'videnh_busy'], + fieldTitles: [gettext('Render/3D'), gettext('Blitter'), gettext('Video'), gettext('VideoEnh')], + unit: 'percent', + store: gpurrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('GPU Power (W)'), + fields: ['power_gpu', 'power_pkg'], + fieldTitles: [gettext('GPU'), gettext('Package')], + store: gpurrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('GPU RC6 Residency'), + fields: ['rc6'], + fieldTitles: [gettext('RC6 %')], + unit: 'percent', + store: gpurrdstore, + }, + ], + listeners: { + resize: function (panel) { + Proxmox.Utils.updateColumns(panel); + }, + }, + }, + ], + listeners: { + activate: function () { + rstore.setInterval(1000); + rstore.startUpdate(); + rrdstore.startUpdate(); + gpurrdstore.startUpdate(); + }, + destroy: function () { + rstore.setInterval(5000); + rrdstore.stopUpdate(); + gpurrdstore.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')); + }); + }, }); \ No newline at end of file diff --git a/src/node_info/files/Store.pm b/src/node_info/files/Store.pm index 436aa59..7a427e1 100644 --- a/src/node_info/files/Store.pm +++ b/src/node_info/files/Store.pm @@ -1,166 +1,166 @@ -package PVE::PVEMod::Store; - -use strict; -use warnings; -use Exporter 'import'; - -use File::Path qw(make_path); -use PVE::INotify; -use RRDs; - -use PVE::PVEMod::Config qw($RRD_SOCKET $RRD_BASE); -use PVE::PVEMod::Utils qw(debug); - -our @EXPORT_OK = qw( - get_nodename - gpu_rrd_path - update_intel_gpu_rrd - update_nvidia_gpu_rrd -); - -# ============================================================================ -# Node name -# ============================================================================ - -sub get_nodename { - return PVE::INotify::nodename(); -} - -# ============================================================================ -# RRD path helper -# ============================================================================ - -sub gpu_rrd_path { - my ($card) = @_; - return "$RRD_BASE/" . get_nodename() . "/$card"; -} - -# ============================================================================ -# Intel GPU RRD -# ============================================================================ - -sub _ensure_intel_gpu_rrd { - my ($card) = @_; - my $path = gpu_rrd_path($card); - return if -f $path; - - my $dir = "$RRD_BASE/" . get_nodename(); - make_path($dir, { mode => 0755 }) unless -d $dir; - - RRDs::create( - $path, - '--step', '1', - 'DS:freq_req:GAUGE:120:0:U', - 'DS:freq_act:GAUGE:120:0:U', - 'DS:rc6:GAUGE:120:0:100', - 'DS:power_gpu:GAUGE:120:0:U', - 'DS:power_pkg:GAUGE:120:0:U', - 'DS:render_busy:GAUGE:120:0:100', - 'DS:blitter_busy:GAUGE:120:0:100', - 'DS:video_busy:GAUGE:120:0:100', - 'DS:videnh_busy:GAUGE:120:0:100', - 'RRA:AVERAGE:0.5:1:1440', - 'RRA:AVERAGE:0.5:60:1440', - 'RRA:AVERAGE:0.5:1800:1344', - 'RRA:AVERAGE:0.5:21600:1464', - 'RRA:AVERAGE:0.5:604800:520', - 'RRA:MAX:0.5:1:1440', - 'RRA:MAX:0.5:60:1440', - 'RRA:MAX:0.5:1800:1344', - 'RRA:MAX:0.5:21600:1464', - 'RRA:MAX:0.5:604800:520', - ); - my $err = RRDs::error(); - debug(__LINE__, "Created Intel GPU RRD $path: " . ($err // 'OK')); -} - -sub update_intel_gpu_rrd { - my ($card, $stats) = @_; - _ensure_intel_gpu_rrd($card); - my $path = gpu_rrd_path($card); - - my $freq_req = $stats->{frequency}{requested} // 'U'; - my $freq_act = $stats->{frequency}{actual} // 'U'; - my $rc6 = $stats->{rc6}{value} // 'U'; - my $power_gpu = $stats->{power}{GPU} // 'U'; - my $power_pkg = $stats->{power}{Package} // 'U'; - my $render_busy = $stats->{engines}{'Render/3D'}{busy} // 'U'; - my $blitter = $stats->{engines}{Blitter}{busy} // 'U'; - my $video = $stats->{engines}{Video}{busy} // 'U'; - my $videnh = $stats->{engines}{VideoEnhance}{busy} // 'U'; - - my @daemon_args = (-S $RRD_SOCKET) ? ('--daemon', "unix:$RRD_SOCKET") : (); - RRDs::update( - $path, - @daemon_args, - "N:$freq_req:$freq_act:$rc6:$power_gpu:$power_pkg:$render_busy:$blitter:$video:$videnh", - ); - my $err = RRDs::error(); - debug(__LINE__, "RRD update intel $card: $err") if $err; -} - -# ============================================================================ -# NVIDIA GPU RRD -# ============================================================================ - -sub _ensure_nvidia_gpu_rrd { - my ($index) = @_; - my $card = "nvidia$index"; - my $path = gpu_rrd_path($card); - return if -f $path; - - my $dir = "$RRD_BASE/" . get_nodename(); - make_path($dir, { mode => 0755 }) unless -d $dir; - - RRDs::create( - $path, - '--step', '1', - 'DS:gpu_util:GAUGE:120:0:100', - 'DS:mem_util:GAUGE:120:0:100', - 'DS:mem_used:GAUGE:120:0:U', - 'DS:mem_total:GAUGE:120:0:U', - 'DS:power_draw:GAUGE:120:0:U', - 'DS:power_limit:GAUGE:120:0:U', - 'DS:temp_gpu:GAUGE:120:0:U', - 'DS:fan_speed:GAUGE:120:0:100', - 'RRA:AVERAGE:0.5:1:1440', - 'RRA:AVERAGE:0.5:60:1440', - 'RRA:AVERAGE:0.5:1800:1344', - 'RRA:AVERAGE:0.5:21600:1464', - 'RRA:AVERAGE:0.5:604800:520', - 'RRA:MAX:0.5:1:1440', - 'RRA:MAX:0.5:60:1440', - 'RRA:MAX:0.5:1800:1344', - 'RRA:MAX:0.5:21600:1464', - 'RRA:MAX:0.5:604800:520', - ); - my $err = RRDs::error(); - debug(__LINE__, "Created NVIDIA GPU RRD $path: " . ($err // 'OK')); -} - -sub update_nvidia_gpu_rrd { - my ($index, $stats) = @_; - _ensure_nvidia_gpu_rrd($index); - my $card = "nvidia$index"; - my $path = gpu_rrd_path($card); - - my $gpu_util = $stats->{utilization}{gpu} // 'U'; - my $mem_util = $stats->{utilization}{memory} // 'U'; - my $mem_used = $stats->{memory}{used} // 'U'; - my $mem_total = $stats->{memory}{total} // 'U'; - my $power_draw = $stats->{power}{draw} // 'U'; - my $power_limit = $stats->{power}{limit} // 'U'; - my $temp_gpu = $stats->{temperature}{gpu} // 'U'; - my $fan_speed = $stats->{fan}{speed} // 'U'; - - my @daemon_args = (-S $RRD_SOCKET) ? ('--daemon', "unix:$RRD_SOCKET") : (); - RRDs::update( - $path, - @daemon_args, - "N:$gpu_util:$mem_util:$mem_used:$mem_total:$power_draw:$power_limit:$temp_gpu:$fan_speed", - ); - my $err = RRDs::error(); - debug(__LINE__, "RRD update nvidia$index: $err") if $err; -} - -1; +package PVE::PVEMod::Store; + +use strict; +use warnings; +use Exporter 'import'; + +use File::Path qw(make_path); +use PVE::INotify; +use RRDs; + +use PVE::PVEMod::Config qw($RRD_SOCKET $RRD_BASE); +use PVE::PVEMod::Utils qw(debug); + +our @EXPORT_OK = qw( + get_nodename + gpu_rrd_path + update_intel_gpu_rrd + update_nvidia_gpu_rrd +); + +# ============================================================================ +# Node name +# ============================================================================ + +sub get_nodename { + return PVE::INotify::nodename(); +} + +# ============================================================================ +# RRD path helper +# ============================================================================ + +sub gpu_rrd_path { + my ($card) = @_; + return "$RRD_BASE/" . get_nodename() . "/$card"; +} + +# ============================================================================ +# Intel GPU RRD +# ============================================================================ + +sub _ensure_intel_gpu_rrd { + my ($card) = @_; + my $path = gpu_rrd_path($card); + return if -f $path; + + my $dir = "$RRD_BASE/" . get_nodename(); + make_path($dir, { mode => 0755 }) unless -d $dir; + + RRDs::create( + $path, + '--step', '1', + 'DS:freq_req:GAUGE:120:0:U', + 'DS:freq_act:GAUGE:120:0:U', + 'DS:rc6:GAUGE:120:0:100', + 'DS:power_gpu:GAUGE:120:0:U', + 'DS:power_pkg:GAUGE:120:0:U', + 'DS:render_busy:GAUGE:120:0:100', + 'DS:blitter_busy:GAUGE:120:0:100', + 'DS:video_busy:GAUGE:120:0:100', + 'DS:videnh_busy:GAUGE:120:0:100', + 'RRA:AVERAGE:0.5:1:1440', + 'RRA:AVERAGE:0.5:60:1440', + 'RRA:AVERAGE:0.5:1800:1344', + 'RRA:AVERAGE:0.5:21600:1464', + 'RRA:AVERAGE:0.5:604800:520', + 'RRA:MAX:0.5:1:1440', + 'RRA:MAX:0.5:60:1440', + 'RRA:MAX:0.5:1800:1344', + 'RRA:MAX:0.5:21600:1464', + 'RRA:MAX:0.5:604800:520', + ); + my $err = RRDs::error(); + debug(__LINE__, "Created Intel GPU RRD $path: " . ($err // 'OK')); +} + +sub update_intel_gpu_rrd { + my ($card, $stats) = @_; + _ensure_intel_gpu_rrd($card); + my $path = gpu_rrd_path($card); + + my $freq_req = $stats->{frequency}{requested} // 'U'; + my $freq_act = $stats->{frequency}{actual} // 'U'; + my $rc6 = $stats->{rc6}{value} // 'U'; + my $power_gpu = $stats->{power}{GPU} // 'U'; + my $power_pkg = $stats->{power}{Package} // 'U'; + my $render_busy = $stats->{engines}{'Render/3D'}{busy} // 'U'; + my $blitter = $stats->{engines}{Blitter}{busy} // 'U'; + my $video = $stats->{engines}{Video}{busy} // 'U'; + my $videnh = $stats->{engines}{VideoEnhance}{busy} // 'U'; + + my @daemon_args = (-S $RRD_SOCKET) ? ('--daemon', "unix:$RRD_SOCKET") : (); + RRDs::update( + $path, + @daemon_args, + "N:$freq_req:$freq_act:$rc6:$power_gpu:$power_pkg:$render_busy:$blitter:$video:$videnh", + ); + my $err = RRDs::error(); + debug(__LINE__, "RRD update intel $card: $err") if $err; +} + +# ============================================================================ +# NVIDIA GPU RRD +# ============================================================================ + +sub _ensure_nvidia_gpu_rrd { + my ($index) = @_; + my $card = "nvidia$index"; + my $path = gpu_rrd_path($card); + return if -f $path; + + my $dir = "$RRD_BASE/" . get_nodename(); + make_path($dir, { mode => 0755 }) unless -d $dir; + + RRDs::create( + $path, + '--step', '1', + 'DS:gpu_util:GAUGE:120:0:100', + 'DS:mem_util:GAUGE:120:0:100', + 'DS:mem_used:GAUGE:120:0:U', + 'DS:mem_total:GAUGE:120:0:U', + 'DS:power_draw:GAUGE:120:0:U', + 'DS:power_limit:GAUGE:120:0:U', + 'DS:temp_gpu:GAUGE:120:0:U', + 'DS:fan_speed:GAUGE:120:0:100', + 'RRA:AVERAGE:0.5:1:1440', + 'RRA:AVERAGE:0.5:60:1440', + 'RRA:AVERAGE:0.5:1800:1344', + 'RRA:AVERAGE:0.5:21600:1464', + 'RRA:AVERAGE:0.5:604800:520', + 'RRA:MAX:0.5:1:1440', + 'RRA:MAX:0.5:60:1440', + 'RRA:MAX:0.5:1800:1344', + 'RRA:MAX:0.5:21600:1464', + 'RRA:MAX:0.5:604800:520', + ); + my $err = RRDs::error(); + debug(__LINE__, "Created NVIDIA GPU RRD $path: " . ($err // 'OK')); +} + +sub update_nvidia_gpu_rrd { + my ($index, $stats) = @_; + _ensure_nvidia_gpu_rrd($index); + my $card = "nvidia$index"; + my $path = gpu_rrd_path($card); + + my $gpu_util = $stats->{utilization}{gpu} // 'U'; + my $mem_util = $stats->{utilization}{memory} // 'U'; + my $mem_used = $stats->{memory}{used} // 'U'; + my $mem_total = $stats->{memory}{total} // 'U'; + my $power_draw = $stats->{power}{draw} // 'U'; + my $power_limit = $stats->{power}{limit} // 'U'; + my $temp_gpu = $stats->{temperature}{gpu} // 'U'; + my $fan_speed = $stats->{fan}{speed} // 'U'; + + my @daemon_args = (-S $RRD_SOCKET) ? ('--daemon', "unix:$RRD_SOCKET") : (); + RRDs::update( + $path, + @daemon_args, + "N:$gpu_util:$mem_util:$mem_used:$mem_total:$power_draw:$power_limit:$temp_gpu:$fan_speed", + ); + my $err = RRDs::error(); + debug(__LINE__, "RRD update nvidia$index: $err") if $err; +} + +1; diff --git a/src/node_info/files/Utils.pm b/src/node_info/files/Utils.pm index 8dfdfdb..91f65ee 100644 --- a/src/node_info/files/Utils.pm +++ b/src/node_info/files/Utils.pm @@ -1,274 +1,274 @@ -package PVE::PVEMod::Utils; - -use strict; -use warnings; -use Exporter 'import'; - -use JSON; -use Fcntl qw(O_CREAT O_EXCL O_WRONLY); - -use PVE::PVEMod::Config qw($DEBUG_ENABLED $VERSION $pve_mod_working_dir %config); - -my $debug_log_fh; - -our @EXPORT_OK = qw( - debug - read_sysfs - is_process_alive - read_lock_pid - acquire_exclusive_lock - ensure_pve_mod_directory_exists - check_executable - startup_message - setup_collector_signals - safe_write_json - safe_read_json - parse_csv_line -); - -# ============================================================================ -# Debug -# ============================================================================ - -# debug function showing line number and call chain -# Usage: debug(__LINE__, "message") -sub debug { - return unless $DEBUG_ENABLED; - - my ($line, $message) = @_; - - my @caller1 = caller(1); # who called debug() - my @caller2 = caller(2); # parent of caller - - my $sub1 = $caller1[3] || 'main'; - my $sub2 = $caller2[3]; - - $sub1 =~ s/.*:://; - - my $output; - if (defined $sub2) { - $sub2 =~ s/.*:://; - $output = "[$sub2 -> $sub1:$line] $message\n"; - } else { - $output = "[$sub1:$line] $message\n"; - } - - warn $output; - - if ($config{debug}{log_enabled} && !defined $debug_log_fh) { - if (open(my $fh, '>>', $config{debug}{log_file})) { - $fh->autoflush(1); - $debug_log_fh = $fh; - } else { - warn "[debug] Failed to open log file $config{debug}{log_file}: $!\n"; - } - } - print $debug_log_fh $output if defined $debug_log_fh; -} - -# ============================================================================ -# File / Process helpers -# ============================================================================ - -sub read_sysfs { - my ($path) = @_; - - return "unknown" unless defined $path && -f $path; - - if (open my $fh, '<', $path) { - my $value = <$fh>; - close $fh; - - if (defined $value) { - chomp $value; - $value =~ s/^\s+|\s+$//g; - return $value ne '' ? $value : "unknown"; - } - } - - return "unknown"; -} - -sub is_process_alive { - my ($pid) = @_; - return -d "/proc/$pid"; -} - -sub read_lock_pid { - my ($lock_path) = @_; - - return undef unless open(my $fh, '<', $lock_path); - - my $pid = <$fh>; - close($fh); - chomp $pid if defined $pid; - - return $pid; -} - -sub acquire_exclusive_lock { - my ($lock_path, $purpose) = @_; - $purpose //= 'lock'; - - my $fh; - - if (sysopen($fh, $lock_path, O_CREAT|O_EXCL|O_WRONLY, 0644)) { - debug(__LINE__, "Acquired $purpose on first try"); - return $fh; - } - - debug(__LINE__, ucfirst($purpose) . " exists, checking if stale"); - - my $lock_pid = read_lock_pid($lock_path); - - if (!defined $lock_pid) { - debug(__LINE__, "Could not read $purpose file: $!"); - return undef; - } - - if ($lock_pid eq '' || $lock_pid !~ /^\d+$/) { - debug(__LINE__, "Invalid PID in $purpose: '" . ($lock_pid // 'undefined') . "', removing"); - unlink($lock_path); - } elsif (is_process_alive($lock_pid)) { - debug(__LINE__, ucfirst($purpose) . " holder PID $lock_pid is still alive"); - return undef; - } else { - debug(__LINE__, ucfirst($purpose) . " holder PID $lock_pid is dead, removing stale lock"); - unlink($lock_path); - } - - unless (sysopen($fh, $lock_path, O_CREAT|O_EXCL|O_WRONLY, 0644)) { - debug(__LINE__, "Failed to acquire $purpose on retry: $!"); - return undef; - } - - debug(__LINE__, "Acquired $purpose after removing stale lock"); - return $fh; -} - -sub ensure_pve_mod_directory_exists { - unless (-d $pve_mod_working_dir) { - debug(__LINE__, "Creating directory $pve_mod_working_dir"); - unless (mkdir($pve_mod_working_dir, 0755)) { - debug(__LINE__, "Failed to create $pve_mod_working_dir: $!. PVE Mod cannot start."); - die "Failed to create $pve_mod_working_dir: $!"; - } - debug(__LINE__, "Directory $pve_mod_working_dir created"); - } else { - debug(__LINE__, "Directory $pve_mod_working_dir already exists"); - } -} - -# Returns 1 if executable exists, or debug mode is active with a debug file present. -# Returns 0 otherwise. -sub check_executable { - my ($exec_path, $type, $debug_mode_enabled, $debug_file) = @_; - - if (defined $debug_mode_enabled && $debug_mode_enabled) { - if (defined $debug_file && -f $debug_file) { - debug(__LINE__, "Debug mode enabled for $type, using debug file: $debug_file"); - return 1; - } elsif (defined $debug_file) { - debug(__LINE__, "Debug mode enabled for $type but debug file missing: $debug_file"); - return 0; - } else { - debug(__LINE__, "Debug mode enabled for $type, skipping executable check for $exec_path"); - return 1; - } - } - - unless (-x $exec_path) { - debug(__LINE__, "$type executable not found or not executable: $exec_path"); - return 0; - } - - debug(__LINE__, "$type executable found: $exec_path"); - return 1; -} - -sub startup_message { - debug(__LINE__, "PVE Mod is being started. Version $VERSION"); -} - -# Setup common TERM/INT signal handlers for collector processes. -# $shutdown_ref is a scalar ref that will be set to 1 on signal. -sub setup_collector_signals { - my ($name, $shutdown_ref, $extra_cleanup) = @_; - - $SIG{TERM} = sub { - debug(__LINE__, "Collector $name received SIGTERM"); - $$shutdown_ref = 1; - $extra_cleanup->() if $extra_cleanup; - }; - $SIG{INT} = sub { - debug(__LINE__, "Collector $name received SIGINT"); - $$shutdown_ref = 1; - $extra_cleanup->() if $extra_cleanup; - }; -} - -# ============================================================================ -# JSON helpers -# ============================================================================ - -sub safe_write_json { - my ($filepath, $data, $pretty) = @_; - $pretty //= 1; - - eval { - open my $fh, '>', $filepath or die "Failed to open $filepath: $!"; - my $json = $pretty ? JSON->new->pretty->encode($data) : encode_json($data); - print $fh $json; - close $fh; - debug(__LINE__, "Wrote JSON to $filepath"); - }; - if ($@) { - debug(__LINE__, "Error writing to $filepath: $@"); - return 0; - } - return 1; -} - -sub safe_read_json { - my ($filepath, $as_string) = @_; - - return unless -f $filepath; - - my $result; - eval { - open my $fh, '<', $filepath or die "Failed to open $filepath: $!"; - local $/; - my $json = <$fh>; - close $fh; - - if ($as_string) { - $result = $json; - } else { - $result = decode_json($json); - } - debug(__LINE__, "Read JSON from $filepath"); - }; - if ($@) { - debug(__LINE__, "Error reading $filepath: $@"); - return; - } - return $result; -} - -# ============================================================================ -# CSV helper -# ============================================================================ - -sub parse_csv_line { - my ($line, $expected_fields) = @_; - - return unless $line; - $line =~ s/^\s+|\s+$//g; - - my @values = map { s/^\s+|\s+$//gr } split(/,/, $line); - - return unless !$expected_fields || @values >= $expected_fields; - return @values; -} - -1; +package PVE::PVEMod::Utils; + +use strict; +use warnings; +use Exporter 'import'; + +use JSON; +use Fcntl qw(O_CREAT O_EXCL O_WRONLY); + +use PVE::PVEMod::Config qw($DEBUG_ENABLED $VERSION $pve_mod_working_dir %config); + +my $debug_log_fh; + +our @EXPORT_OK = qw( + debug + read_sysfs + is_process_alive + read_lock_pid + acquire_exclusive_lock + ensure_pve_mod_directory_exists + check_executable + startup_message + setup_collector_signals + safe_write_json + safe_read_json + parse_csv_line +); + +# ============================================================================ +# Debug +# ============================================================================ + +# debug function showing line number and call chain +# Usage: debug(__LINE__, "message") +sub debug { + return unless $DEBUG_ENABLED; + + my ($line, $message) = @_; + + my @caller1 = caller(1); # who called debug() + my @caller2 = caller(2); # parent of caller + + my $sub1 = $caller1[3] || 'main'; + my $sub2 = $caller2[3]; + + $sub1 =~ s/.*:://; + + my $output; + if (defined $sub2) { + $sub2 =~ s/.*:://; + $output = "[$sub2 -> $sub1:$line] $message\n"; + } else { + $output = "[$sub1:$line] $message\n"; + } + + warn $output; + + if ($config{debug}{log_enabled} && !defined $debug_log_fh) { + if (open(my $fh, '>>', $config{debug}{log_file})) { + $fh->autoflush(1); + $debug_log_fh = $fh; + } else { + warn "[debug] Failed to open log file $config{debug}{log_file}: $!\n"; + } + } + print $debug_log_fh $output if defined $debug_log_fh; +} + +# ============================================================================ +# File / Process helpers +# ============================================================================ + +sub read_sysfs { + my ($path) = @_; + + return "unknown" unless defined $path && -f $path; + + if (open my $fh, '<', $path) { + my $value = <$fh>; + close $fh; + + if (defined $value) { + chomp $value; + $value =~ s/^\s+|\s+$//g; + return $value ne '' ? $value : "unknown"; + } + } + + return "unknown"; +} + +sub is_process_alive { + my ($pid) = @_; + return -d "/proc/$pid"; +} + +sub read_lock_pid { + my ($lock_path) = @_; + + return undef unless open(my $fh, '<', $lock_path); + + my $pid = <$fh>; + close($fh); + chomp $pid if defined $pid; + + return $pid; +} + +sub acquire_exclusive_lock { + my ($lock_path, $purpose) = @_; + $purpose //= 'lock'; + + my $fh; + + if (sysopen($fh, $lock_path, O_CREAT|O_EXCL|O_WRONLY, 0644)) { + debug(__LINE__, "Acquired $purpose on first try"); + return $fh; + } + + debug(__LINE__, ucfirst($purpose) . " exists, checking if stale"); + + my $lock_pid = read_lock_pid($lock_path); + + if (!defined $lock_pid) { + debug(__LINE__, "Could not read $purpose file: $!"); + return undef; + } + + if ($lock_pid eq '' || $lock_pid !~ /^\d+$/) { + debug(__LINE__, "Invalid PID in $purpose: '" . ($lock_pid // 'undefined') . "', removing"); + unlink($lock_path); + } elsif (is_process_alive($lock_pid)) { + debug(__LINE__, ucfirst($purpose) . " holder PID $lock_pid is still alive"); + return undef; + } else { + debug(__LINE__, ucfirst($purpose) . " holder PID $lock_pid is dead, removing stale lock"); + unlink($lock_path); + } + + unless (sysopen($fh, $lock_path, O_CREAT|O_EXCL|O_WRONLY, 0644)) { + debug(__LINE__, "Failed to acquire $purpose on retry: $!"); + return undef; + } + + debug(__LINE__, "Acquired $purpose after removing stale lock"); + return $fh; +} + +sub ensure_pve_mod_directory_exists { + unless (-d $pve_mod_working_dir) { + debug(__LINE__, "Creating directory $pve_mod_working_dir"); + unless (mkdir($pve_mod_working_dir, 0755)) { + debug(__LINE__, "Failed to create $pve_mod_working_dir: $!. PVE Mod cannot start."); + die "Failed to create $pve_mod_working_dir: $!"; + } + debug(__LINE__, "Directory $pve_mod_working_dir created"); + } else { + debug(__LINE__, "Directory $pve_mod_working_dir already exists"); + } +} + +# Returns 1 if executable exists, or debug mode is active with a debug file present. +# Returns 0 otherwise. +sub check_executable { + my ($exec_path, $type, $debug_mode_enabled, $debug_file) = @_; + + if (defined $debug_mode_enabled && $debug_mode_enabled) { + if (defined $debug_file && -f $debug_file) { + debug(__LINE__, "Debug mode enabled for $type, using debug file: $debug_file"); + return 1; + } elsif (defined $debug_file) { + debug(__LINE__, "Debug mode enabled for $type but debug file missing: $debug_file"); + return 0; + } else { + debug(__LINE__, "Debug mode enabled for $type, skipping executable check for $exec_path"); + return 1; + } + } + + unless (-x $exec_path) { + debug(__LINE__, "$type executable not found or not executable: $exec_path"); + return 0; + } + + debug(__LINE__, "$type executable found: $exec_path"); + return 1; +} + +sub startup_message { + debug(__LINE__, "PVE Mod is being started. Version $VERSION"); +} + +# Setup common TERM/INT signal handlers for collector processes. +# $shutdown_ref is a scalar ref that will be set to 1 on signal. +sub setup_collector_signals { + my ($name, $shutdown_ref, $extra_cleanup) = @_; + + $SIG{TERM} = sub { + debug(__LINE__, "Collector $name received SIGTERM"); + $$shutdown_ref = 1; + $extra_cleanup->() if $extra_cleanup; + }; + $SIG{INT} = sub { + debug(__LINE__, "Collector $name received SIGINT"); + $$shutdown_ref = 1; + $extra_cleanup->() if $extra_cleanup; + }; +} + +# ============================================================================ +# JSON helpers +# ============================================================================ + +sub safe_write_json { + my ($filepath, $data, $pretty) = @_; + $pretty //= 1; + + eval { + open my $fh, '>', $filepath or die "Failed to open $filepath: $!"; + my $json = $pretty ? JSON->new->pretty->encode($data) : encode_json($data); + print $fh $json; + close $fh; + debug(__LINE__, "Wrote JSON to $filepath"); + }; + if ($@) { + debug(__LINE__, "Error writing to $filepath: $@"); + return 0; + } + return 1; +} + +sub safe_read_json { + my ($filepath, $as_string) = @_; + + return unless -f $filepath; + + my $result; + eval { + open my $fh, '<', $filepath or die "Failed to open $filepath: $!"; + local $/; + my $json = <$fh>; + close $fh; + + if ($as_string) { + $result = $json; + } else { + $result = decode_json($json); + } + debug(__LINE__, "Read JSON from $filepath"); + }; + if ($@) { + debug(__LINE__, "Error reading $filepath: $@"); + return; + } + return $result; +} + +# ============================================================================ +# CSV helper +# ============================================================================ + +sub parse_csv_line { + my ($line, $expected_fields) = @_; + + return unless $line; + $line =~ s/^\s+|\s+$//g; + + my @values = map { s/^\s+|\s+$//gr } split(/,/, $line); + + return unless !$expected_fields || @values >= $expected_fields; + return @values; +} + +1; diff --git a/src/node_info/files/files.list b/src/node_info/files/files.list index 9639ae7..487e836 100644 --- a/src/node_info/files/files.list +++ b/src/node_info/files/files.list @@ -1,27 +1,27 @@ -# pve-mod :: node_info file manifest -# Maps files in this directory to their installation destinations. -# Format: [permission] -# source - path relative to this files/ directory -# destination - path relative to the package root (no leading slash) -# permission - octal mode, optional (defaults to 644) -# Read by src/gen-rules.sh to generate the per-module debian install rules. - -# PVE API2 facade -PveMod_SensorInfo.pm usr/share/perl5/PVE/API2/PVEMod_SensorInfo.pm - -# PVEMod core modules -Config.pm usr/share/perl5/PVE/PVEMod/Config.pm -Utils.pm usr/share/perl5/PVE/PVEMod/Utils.pm -Store.pm usr/share/perl5/PVE/PVEMod/Store.pm -ProcessManager.pm usr/share/perl5/PVE/PVEMod/ProcessManager.pm - -# Collector plugins -Collector/Intel.pm usr/share/perl5/PVE/PVEMod/Collector/Intel.pm -Collector/Nvidia.pm usr/share/perl5/PVE/PVEMod/Collector/Nvidia.pm -Collector/Amd.pm usr/share/perl5/PVE/PVEMod/Collector/Amd.pm -Collector/LmSensors.pm usr/share/perl5/PVE/PVEMod/Collector/LmSensors.pm -Collector/Ups.pm usr/share/perl5/PVE/PVEMod/Collector/Ups.pm -Collector/systemInformation.pm usr/share/perl5/PVE/PVEMod/Collector/SystemInformation.pm - -# JS module (rename to match loader reference) -PveMod_pvemanagerlib.js usr/share/pve-manager/js/PveMod_PveNodeStatusView.js +# pve-mod :: node_info file manifest +# Maps files in this directory to their installation destinations. +# Format: [permission] +# source - path relative to this files/ directory +# destination - path relative to the package root (no leading slash) +# permission - octal mode, optional (defaults to 644) +# Read by src/gen-rules.sh to generate the per-module debian install rules. + +# PVE API2 facade +PveMod_SensorInfo.pm usr/share/perl5/PVE/API2/PVEMod_SensorInfo.pm + +# PVEMod core modules +Config.pm usr/share/perl5/PVE/PVEMod/Config.pm +Utils.pm usr/share/perl5/PVE/PVEMod/Utils.pm +Store.pm usr/share/perl5/PVE/PVEMod/Store.pm +ProcessManager.pm usr/share/perl5/PVE/PVEMod/ProcessManager.pm + +# Collector plugins +Collector/Intel.pm usr/share/perl5/PVE/PVEMod/Collector/Intel.pm +Collector/Nvidia.pm usr/share/perl5/PVE/PVEMod/Collector/Nvidia.pm +Collector/Amd.pm usr/share/perl5/PVE/PVEMod/Collector/Amd.pm +Collector/LmSensors.pm usr/share/perl5/PVE/PVEMod/Collector/LmSensors.pm +Collector/Ups.pm usr/share/perl5/PVE/PVEMod/Collector/Ups.pm +Collector/systemInformation.pm usr/share/perl5/PVE/PVEMod/Collector/SystemInformation.pm + +# JS module (rename to match loader reference) +PveMod_pvemanagerlib.js usr/share/pve-manager/js/PveMod_PveNodeStatusView.js diff --git a/src/node_info/node_info.conf b/src/node_info/node_info.conf index 13c4ed4..6f9a087 100644 --- a/src/node_info/node_info.conf +++ b/src/node_info/node_info.conf @@ -1,45 +1,45 @@ -# pve-mod :: node_info mod configuration -# Settings for the node-info / sensor-monitoring mod. -# Managed by pve-mod-configure. Re-run to update. - -[gpu] -intel_enabled=0 -nvidia_enabled=0 -amd_enabled=0 -gpu_history=0 - -[lm_sensors] -enabled=0 -enable_cpu=0 -cpu_temp_target=Core -enable_ram_temp=0 -enable_hdd_temp=0 -enable_nvme_temp=0 -enable_fan_speed=0 -display_zero_speed_fans=0 -temp_unit=C - -[ups] -enabled=0 -device_name=ups@localhost - -[system_info] -enabled=0 -type=1 - -# Debug mode: when a collector's mode is 1, the real tool is not required. -# Data is read from the file path instead. Useful for development/testing. -[debug] -lm_sensors_mode=0 -lm_sensors_output_file=/tmp/sensors-output.json -intel_mode=0 -intel_devices_file=/tmp/intel-gpu-devices.json -nvidia_mode=0 -nvidia_devices_file=/tmp/nvidia-smi-devices.csv -nvidia_output_file=/tmp/nvidia-smi-output.csv -amd_mode=0 -amd_devices_file=/tmp/amd-gpu-devices.json -ups_mode=0 -ups_output_file=/tmp/ups-output.json -log_enabled=0 -log_file=/tmp/pve-mod-debug.log +# pve-mod :: node_info mod configuration +# Settings for the node-info / sensor-monitoring mod. +# Managed by pve-mod-configure. Re-run to update. + +[gpu] +intel_enabled=0 +nvidia_enabled=0 +amd_enabled=0 +gpu_history=0 + +[lm_sensors] +enabled=0 +enable_cpu=0 +cpu_temp_target=Core +enable_ram_temp=0 +enable_hdd_temp=0 +enable_nvme_temp=0 +enable_fan_speed=0 +display_zero_speed_fans=0 +temp_unit=C + +[ups] +enabled=0 +device_name=ups@localhost + +[system_info] +enabled=0 +type=1 + +# Debug mode: when a collector's mode is 1, the real tool is not required. +# Data is read from the file path instead. Useful for development/testing. +[debug] +lm_sensors_mode=0 +lm_sensors_output_file=/tmp/sensors-output.json +intel_mode=0 +intel_devices_file=/tmp/intel-gpu-devices.json +nvidia_mode=0 +nvidia_devices_file=/tmp/nvidia-smi-devices.csv +nvidia_output_file=/tmp/nvidia-smi-output.csv +amd_mode=0 +amd_devices_file=/tmp/amd-gpu-devices.json +ups_mode=0 +ups_output_file=/tmp/ups-output.json +log_enabled=0 +log_file=/tmp/pve-mod-debug.log diff --git a/src/node_info/patches/patches.list b/src/node_info/patches/patches.list index 9049189..6aed699 100644 --- a/src/node_info/patches/patches.list +++ b/src/node_info/patches/patches.list @@ -1,9 +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 +# 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/node_info/readme.md b/src/node_info/readme.md index 1e8ee1f..b25d2eb 100644 --- a/src/node_info/readme.md +++ b/src/node_info/readme.md @@ -1,3 +1,3 @@ -## Draft code -This version of PVEMod is as draft version and may or may not be fully functional. +## Draft code +This version of PVEMod is as draft version and may or may not be fully functional. The installer is currently not working and is work in progress. \ No newline at end of file diff --git a/src/pve-mod.conf b/src/pve-mod.conf index 2757128..b05b51d 100644 --- a/src/pve-mod.conf +++ b/src/pve-mod.conf @@ -1,19 +1,19 @@ -# pve-mod main configuration file -# Run 'pve-mod-configure' to set values interactively. -# -# This file only declares which mods are enabled. Each mod keeps its own -# settings in /etc/pve-mod/conf.d/.conf -# -# When a mod flag below is 1, its patches are (re)applied on install and, -# if [pve_trigger] enabled=1, after every pve-manager upgrade. - -[modules] -node_info=0 -nag_screen=0 - -# Re-apply patches automatically after a pve-manager upgrade (dpkg trigger). -[pve_trigger] -enabled=0 - -[service] -mode=embedded +# pve-mod main configuration file +# Run 'pve-mod-configure' to set values interactively. +# +# This file only declares which mods are enabled. Each mod keeps its own +# settings in /etc/pve-mod/conf.d/.conf +# +# When a mod flag below is 1, its patches are (re)applied on install and, +# if [pve_trigger] enabled=1, after every pve-manager upgrade. + +[modules] +node_info=0 +nag_screen=0 + +# Re-apply patches automatically after a pve-manager upgrade (dpkg trigger). +[pve_trigger] +enabled=0 + +[service] +mode=embedded diff --git a/src/test.yml b/src/test.yml index 2a256bd..b3a016e 100644 --- a/src/test.yml +++ b/src/test.yml @@ -1 +1 @@ -trigger workflow test 1, 2, 3 \ No newline at end of file +trigger workflow test 1, 2, 3, 4 \ No newline at end of file