├── pve-mod-nvidia.png
├── pve-mod-sensors.png
├── pve-mod-all.sh
├── .github
└── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── .gitignore
├── security.md
├── updateallcontainers.sh
├── pve-mod-nag-screen.sh
├── readme.md
├── pve-mod-gui-nvidia.sh
└── pve-mod-gui-sensors.sh
/pve-mod-nvidia.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/j4ys0n/PVE-mods/HEAD/pve-mod-nvidia.png
--------------------------------------------------------------------------------
/pve-mod-sensors.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/j4ys0n/PVE-mods/HEAD/pve-mod-sensors.png
--------------------------------------------------------------------------------
/pve-mod-all.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | # This script's working directory
4 | SCRIPT_CWD="$(dirname "$(readlink -f "$0")")"
5 |
6 | MODS="pve-mod-gui-sensors.sh pve-mod-nag-screen.sh"
7 |
8 | ACTION=${1:-install}
9 | for m in $MODS; do
10 | "$SCRIPT_CWD/$m" $ACTION
11 | done
12 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Sensors output**
27 | If applicable, provide sensor datadump (use the argument save-sensors-data)
28 |
29 | **Additional context**
30 | Add any other context about the problem here.
31 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: enhancement
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
22 | **Sensors output**
23 | If applicable, provide sensor datadump (use the argument save-sensors-data)
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ################################################################################
2 | # This .gitignore file was automatically created by Microsoft(R) Visual Studio.
3 | ################################################################################
4 |
5 | /.vs/PVE-mods/FileContentIndex/b0cd8cf0-a894-4523-bdd5-7f3d85072f7f.vsidx
6 | /.vs/PVE-mods/FileContentIndex/c351d5b4-2706-444a-87b2-a910a8873e48.vsidx
7 | /.vs/PVE-mods/FileContentIndex/cd6884c2-daf4-47a9-8bbd-cf8a4f886075.vsidx
8 | /.vs/PVE-mods/FileContentIndex/read.lock
9 | /.vs/PVE-mods/v17
10 | /.vs/PVE-mods/v17/.wsuo
11 | /.vs/slnx.sqlite
12 | /.vs/VSWorkspaceState.json
13 | /.vs/PVE-mods/FileContentIndex/8b60d584-2422-418a-808a-7a62a50425b3.vsidx
14 | /.vs/PVE-mods/FileContentIndex/aecfa7d2-4343-412e-a663-cd485991e27d.vsidx
15 | /.vs/PVE-mods/FileContentIndex/da25d4cb-f11b-4cdb-b231-55efa7695267.vsidx
16 | /.vs/PVE-mods/FileContentIndex
17 |
--------------------------------------------------------------------------------
/security.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | These scripts and modifications are designed for use with **Proxmox Virtual Environment (PVE)**.
6 |
7 | ⚠️ **Note:**
8 | - Proxmox upgrades may overwrite modified files. Reinstallation of the mods may be required.
9 | - Functionality on unsupported versions is not guaranteed.
10 |
11 | ---
12 |
13 | ## Security Considerations
14 |
15 | - **Root Access Required**:
16 | - All installation steps must be performed as `root`.
17 | - Normal users do not have access to required system paths.
18 | - Misuse may compromise your Proxmox installation.
19 | - **Backups**: Scripts automatically back up modified system files before applying changes.
20 | - **Network Security**:
21 | - UPS information requires network monitoring; secure communication is recommended.
22 | - Multi-node UPS support assumes identical credentials across nodes, which may pose risks if not properly secured.
23 |
24 | ---
25 |
26 | ## Reporting a Vulnerability
27 | If you discover a security issue in these scripts or modifications, please open an issue.
28 |
29 | ---
30 |
31 | ## Best Practices
32 | - Review code prior to installation
33 | - Always test on a **non-production node** before applying in production.
34 | - Keep a full **system backup and VM/container snapshots** before installing modifications.
35 | - Verify downloaded scripts from the official repo via checksum before execution.
36 | - After upgrades, re-check whether mods are still applied correctly.
37 | - Clear browser cache after applying GUI modifications.
38 |
39 | ---
40 |
41 | ## Disclaimer
42 | These scripts are provided **as-is**, without warranty of any kind.
43 | Use at your own risk. The author(s) are not responsible for data loss, downtime, or security breaches resulting from the use of these modifications.
--------------------------------------------------------------------------------
/updateallcontainers.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # This script updates all running Proxmox containers, skipping specified excluded containers, and generates a separate log file for each container.
3 | # The script first updates the Proxmox host system, then iterates through each container, updates the container, and reboots it if necessary.
4 | # Each container's log file is stored in $log_path and the main script log file is named container-upgrade-main.log.
5 |
6 | # Path where logs are saved
7 | log_path="/root/scripts"
8 |
9 | # array of container ids to exclude from updates
10 | exclude_containers=("106")
11 |
12 | # path to programs
13 | pct="/usr/sbin/pct"
14 |
15 | # list of container ids we need to iterate through
16 | containers=$($pct list | tail -n +2 | cut -f1 -d' ')
17 |
18 | #### CODE BELOW #########
19 | container_main_log_file="${log_path}/container-upgrade-main.log"
20 |
21 | echo "[Info] Updating proxmox containers at $(date)"
22 | echo "[Info] Updating proxmox containers at $(date)" >> $container_main_log_file
23 |
24 | #function to update individual containers
25 | function update_container() {
26 | container=$1
27 | # log file for individual container
28 | container_log_file="${log_path}/container-upgrade-$container.log"
29 |
30 | # log start of update
31 | echo "[Info] Starting update for container $container at $(date)" >> $container_log_file
32 |
33 | # perform the update
34 | $pct exec $container -- bash -c "apt update && apt upgrade -y && apt autoremove -y && reboot" >> $container_log_file 2>&1
35 |
36 | # log completion of update
37 | echo "[Info] Completed update for $container at $(date)" >> $container_log_file
38 | echo "--------------------------------------------------------------------------------------------" >> $container_log_file
39 | }
40 |
41 | for container in $containers; do
42 | # skip excluded containers
43 | if [[ " ${exclude_containers[@]} " =~ " ${container} " ]]; then
44 | echo "[Info] Skipping excluded container, $container"
45 | echo "[Info] Skipping excluded container, $container" >> $container_main_log_file
46 | continue
47 | fi
48 |
49 | status=$($pct status $container)
50 | if [ "$status" == "status: stopped" ]; then
51 | echo "[Info] Skipping offline container, $container"
52 | echo "[Info] Skipping offline container, $container" >> $container_main_log_file
53 | elif [ "$status" == "status: running" ]; then
54 | update_container $container
55 | fi
56 | done; wait
57 |
58 | # log completion of all updates
59 | echo "[Info] Updating proxmox containers completed at $(date)"
60 | echo "[Info] Updating proxmox containers completed at $(date)" >> $container_main_log_file
61 | echo "--------------------------------------------------------------------------------------------" >> $container_main_log_file
--------------------------------------------------------------------------------
/pve-mod-nag-screen.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | #
3 | # This bash script installs a modification to the Proxmox Virtual Environment (PVE) web user interface (UI) which deactivates the subscription nag screen.
4 | #
5 |
6 | ################### Configuration #############
7 |
8 | # This script's working directory
9 | SCRIPT_CWD="$(dirname "$(readlink -f "$0")")"
10 |
11 | # Files backup location
12 | BACKUP_DIR="$SCRIPT_CWD/backup"
13 |
14 | # File paths
15 | proxmoxlibjs="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
16 | proxmoxlibminjs="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
17 |
18 | ###############################################
19 |
20 | # Helper functions
21 | function msg {
22 | echo -e "\e[0m$1\e[0m"
23 | }
24 |
25 | #echo message in bold
26 | function msgb {
27 | echo -e "\e[1m$1\e[0m"
28 | }
29 |
30 | function warn {
31 | echo -e "\e[0;33m[warning] $1\e[0m"
32 | }
33 |
34 | function err {
35 | echo -e "\e[0;31m[error] $1\e[0m"
36 | exit 1
37 | }
38 | # End of helper functions
39 |
40 | # Function to display usage information
41 | function usage {
42 | msgb "\nUsage:\n$0 [install | uninstall]\n"
43 | exit 1
44 | }
45 |
46 | function restart_proxy {
47 | # Restart pveproxy
48 | msg "\nRestarting PVE proxy..."
49 | systemctl restart pveproxy
50 | }
51 |
52 | function install_mod {
53 | msg "\nPreparing mod installation..."
54 |
55 | local timestamp=$(date '+%Y-%m-%d_%H-%M-%S')
56 | local restart=false
57 |
58 | if (! grep -q "// disable subscription nag screen" "$proxmoxlibjs"); then
59 | if [ -f "$proxmoxlibjs" ]; then
60 | mkdir -p "$BACKUP_DIR" || err "Error creating backup directory."
61 |
62 | # Create backup of original file
63 | msg "Saving current version of \"$proxmoxlibjs\" to \"$BACKUP_DIR/proxmoxlib.js.$timestamp\"."
64 | cp -P "$proxmoxlibjs" "$BACKUP_DIR/proxmoxlib.js.$timestamp" || err "Error creating backup."
65 |
66 | msg "Deactivating the nag screen..."
67 | sed -i "/Ext.define('Proxmox.Utils',/ {
68 | :a;
69 | /checked_command:\s*function\s*(orig_cmd)\s*{/!{N;ba;}
70 | a\
71 | \\
72 | // disable subscription nag screen\n\
73 | orig_cmd();\n\
74 | return;
75 | }" "$proxmoxlibjs"
76 | fi
77 | restart=true
78 | else
79 | warn "Nag screen already deactivated."
80 | fi
81 |
82 | if [ ! -h "$proxmoxlibminjs" ]; then
83 | msg "Disabling minified front-end library file..."
84 | (mv "$proxmoxlibminjs" "$BACKUP_DIR/proxmoxlib.min.js.$timestamp" &&
85 | ln -s "$proxmoxlibjs" "$proxmoxlibminjs") || err "Error disabling minified front-end library file."
86 | restart=true
87 | else
88 | warn "Minified front-end library file already disabled."
89 | fi
90 |
91 | if [ $restart = true ]; then
92 | restart_proxy
93 | fi
94 | }
95 |
96 | function uninstall_mod {
97 | msg "\nRestoring modified files..."
98 |
99 | local restart=false
100 |
101 | # Find the latest backup file of proxmoxlib.js
102 | local latest_proxmoxlibjs=$(find "$BACKUP_DIR" -name "proxmoxlib.js.*" -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n 1 | awk '{print $2}')
103 |
104 | if [ -z "$latest_proxmoxlibjs" ]; then
105 | warn "No proxmoxlib.js backup files found."
106 | else
107 | # Remove the latest proxmoxlib.js file
108 | msg "Restoring \"$proxmoxlibjs\" from the latest backup file."
109 | cp "$latest_proxmoxlibjs" "$proxmoxlibjs"
110 | restart=true
111 | fi
112 |
113 | local latest_proxmoxlibminjs=$(find "$BACKUP_DIR" -name "proxmoxlib.min.js.*" -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n 1 | awk '{print $2}')
114 |
115 | if [ -z "$latest_proxmoxlibminjs" ]; then
116 | warn "No proxmoxlib.min.js backup files found."
117 | else
118 | # Remove the latest proxmoxlib.min.js file
119 | msg "Restoring \"$proxmoxlibminjs\" from the latest backup file."
120 | rm "$proxmoxlibminjs" && cp "$latest_proxmoxlibminjs" "$proxmoxlibminjs"
121 | restart=true
122 | fi
123 |
124 | if [ $restart = true ]; then
125 | restart_proxy
126 | fi
127 | }
128 |
129 | # Process the arguments using a while loop and a case statement
130 | executed=0
131 | while [[ $# -gt 0 ]]; do
132 | case "$1" in
133 | install)
134 | executed=$(($executed + 1))
135 | msgb "\nInstalling the Proxmox VE nag screen mod..."
136 | install_mod
137 | echo # add a new line
138 | ;;
139 | uninstall)
140 | executed=$(($executed + 1))
141 | msgb "\nUninstalling the Proxmox VE nag screen mod..."
142 | uninstall_mod
143 | echo # add a new line
144 | ;;
145 | esac
146 | shift
147 | done
148 |
149 | # If no arguments were provided or all arguments have been processed, print the usage message
150 | if [[ $executed -eq 0 ]]; then
151 | usage
152 | fi
153 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Proxmox Virtual Environment mods and scripts
2 | A small collection of scripts and mods for Proxmox Virtual Environment (PVE)
3 |
4 | If you find this helpful, a small donation is appreciated, [](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=K8XPMSEBERH3W).
5 |
6 | ## Node sensor readings view
7 | (Tested compatibility: 9.x. Using older version (7.x-8.x), use git version from Apr 6th 2025)
8 | 
9 |
10 | This bash script installs a modification to the Proxmox Virtual Environment (PVE) web user interface (UI) to display sensor readings in a flexible and readable manner.
11 | The following readings are possible:
12 | - CPU, NVMe/HDD/SSD temperatures (Celsius/Fahrenheit), fan speeds, ram temperatures via lm-sensors. Note: Hdds require kernel module *drivetemp* module installed.
13 | - UPS information via Network Monitoring Tool
14 | - Motherboard information or system information via dmidecode
15 |
16 | ### How it works
17 | The modification involves the following steps:
18 | 1. Backup original files in the home directory/backup
19 | - `/usr/share/pve-manager/js/pvemanagerlib.js`
20 | - `/usr/share/perl5/PVE/API2/Nodes.pm`
21 | 2. Patch `Nodes.pm` to enable readings.
22 | 3. Modify `pvemanagerlib.js` to:
23 | - Expand the node status view to full browser width.
24 | - Add reading (depending on & selections).
25 | - Allow collapsing the panel vertically.
26 | 4. Restart the `pveproxy` service to apply changes.
27 |
28 | The script provides three options:
29 | | **Option** | **Description** |
30 | |-------------------------|-----------------------------------------------------------------------------|
31 | | `install` | Apply the modification. |
32 | | `uninstall` | Restore original files from backups. |
33 | | `save-sensors-data` | Save a local copy of detected sensor data for reference or troubleshooting. |
34 |
35 | Notes:
36 | - UPS support in multi-node setups require identical login credentials across nodes. This has not been fully tested.
37 | - Proxmox upgrades may overwrite modified files; reinstallation of this mod could be required.
38 |
39 | ### Install
40 | Instructions be performed as 'root', as normal users do not have access to the files.
41 |
42 | ```
43 | apt-get install lm-sensors
44 | # lm-sensors must be configured, run below to configure your sensors, apply temperature offsets. Refer to lm-sensors manual for more information.
45 | sensors-detect
46 | wget https://raw.githubusercontent.com/Meliox/PVE-mods/refs/heads/main/pve-mod-gui-sensors.sh
47 | bash pve-mod-gui-sensors.sh install
48 | # Then clear the browser cache to ensure all changes are visualized.
49 | ```
50 | Additionally, adjustments are available in the first part of the script, where paths can be edited, cpucore offset and display information.
51 |
52 | ## NVIDIA GPU readings view
53 | 
54 |
55 | This bash script installs a modification to the Proxmox Virtual Environment (PVE) web user interface (UI) to display NVIDIA GPU information in the node status view.
56 |
57 | The following readings are displayed (per GPU):
58 | - GPU name and index
59 | - Temperature (Celsius/Fahrenheit)
60 | - GPU utilization
61 | - Memory utilization, used/total
62 | - Power draw / power limit
63 | - Fan speed (if supported)
64 |
65 | ### How it works
66 | The modification involves the following steps:
67 | 1. Backup original files in a backup directory (default: `~/PVE-MODS`, configurable via `BACKUP_DIR` in the script)
68 | - `/usr/share/pve-manager/js/pvemanagerlib.js`
69 | - `/usr/share/perl5/PVE/API2/Nodes.pm`
70 | 2. Patch `Nodes.pm` to add an API field (`nvidiaGpuOutput`) populated by `nvidia-smi`.
71 | 3. Modify `pvemanagerlib.js` to insert a new StatusView widget (“NVIDIA GPU Status”) before the CPU widget.
72 | 4. Restart the `pveproxy` service to apply changes.
73 |
74 | The script provides two options:
75 | | **Option** | **Description** |
76 | |-------------|------------------|
77 | | `install` | Apply the modification. |
78 | | `uninstall` | Restore original files from backups (see note below). |
79 |
80 | Notes:
81 | - Requires NVIDIA drivers installed on the Proxmox host (`nvidia-smi` must be available).
82 | - If you have other PVE UI mods installed (e.g. the sensors UI mod), uninstalling via backup restore may revert other changes depending on backup order. The script will warn if it detects other mods.
83 | - Proxmox upgrades may overwrite modified files; reinstallation of this mod could be required.
84 |
85 | ### Install
86 | Instructions be performed as 'root', as normal users do not have access to the files.
87 |
88 | ```
89 | # Ensure NVIDIA drivers are installed and nvidia-smi works.
90 | # (Example check)
91 | nvidia-smi
92 |
93 | wget https://raw.githubusercontent.com/Meliox/PVE-mods/refs/heads/main/pve-mod-gui-nvidia.sh
94 | bash pve-mod-gui-nvidia.sh install
95 | # Then clear the browser cache to ensure all changes are visualized.
96 | ```
97 |
98 | ## Nag screen deactivation
99 | (Tested compatibility: 7.x - 8.3.5)
100 | This bash script installs a modification to the Proxmox Virtual Environment (PVE) web user interface (UI) which deactivates the subscription nag screen.
101 |
102 | The modification includes two main steps:
103 | 1. Create backups of the original files in the `backup` directory relative to the script location.
104 | 2. Modify code.
105 |
106 | The script provides three options:
107 | | **Option** | **Description** |
108 | |-------------------------|-----------------------------------------------------------------------------|
109 | | `install` | Installs the modification by applying the necessary changes. |
110 | | `uninstall` | Removes the modification by restoring the original files from backups. |
111 |
112 | ### Install
113 | Instructions be performed as 'root', as normal users do not have access to the files.
114 | ```
115 | wget https://raw.githubusercontent.com/Meliox/PVE-mods/refs/heads/main/pve-mod-nag-screen.sh
116 | bash pve-mod-nag-screen.sh install
117 | ```
118 |
119 | ## Script to update all containers
120 | (Tested compatibility: 7.x - 8.3.5)
121 |
122 | This script updates all running Proxmox containers, skipping specified excluded containers, and generates a separate log file for each container.
123 | The script first updates the Proxmox host system, then iterates through each container, updates the container, and reboots it if necessary.
124 | Each container's log file is stored in $log_path and the main script log file is named container-upgrade-main.log.
125 |
126 | ### Install
127 | ```
128 | wget https://raw.githubusercontent.com/Meliox/PVE-mods/refs/heads/main/updateallcontainers.sh
129 | ```
130 | Or use git clone.
131 | Can be added to cron for e.g. monthly update: ```0 6 1 * * /root/scripts/updateallcontainers.sh```
132 |
--------------------------------------------------------------------------------
/pve-mod-gui-nvidia.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | #
3 | # This bash script installs a modification to the Proxmox Virtual Environment (PVE)
4 | # web user interface (UI) to display NVIDIA GPU information.
5 | #
6 | # Author: Based on pve-mod-gui-sensors.sh by Meliox
7 | # License: MIT
8 | #
9 |
10 | ################### Configuration #############
11 |
12 | # Temperature thresholds (Celsius)
13 | TEMP_WARNING=70
14 | TEMP_CRITICAL=85
15 |
16 | # Overwrite default backup location (leave empty for default ~/PVE-MODS)
17 | BACKUP_DIR=""
18 |
19 | ##################### DO NOT EDIT BELOW #######################
20 |
21 | # This script's working directory
22 | SCRIPT_CWD="$(dirname "$(readlink -f "$0")")"
23 |
24 | # File paths
25 | PVE_MANAGER_LIB_JS_FILE="/usr/share/pve-manager/js/pvemanagerlib.js"
26 | NODES_PM_FILE="/usr/share/perl5/PVE/API2/Nodes.pm"
27 |
28 | #region message tools
29 | # Section header (bold)
30 | function msgb() {
31 | local message="$1"
32 | echo -e "\e[1m${message}\e[0m"
33 | }
34 |
35 | # Info (green)
36 | function info() {
37 | local message="$1"
38 | echo -e "\e[0;32m[info] ${message}\e[0m"
39 | }
40 |
41 | # Warning (yellow)
42 | function warn() {
43 | local message="$1"
44 | echo -e "\e[0;33m[warning] ${message}\e[0m"
45 | }
46 |
47 | # Error (red)
48 | function err() {
49 | local message="$1"
50 | echo -e "\e[0;31m[error] ${message}\e[0m"
51 | exit 1
52 | }
53 |
54 | # Prompts (cyan)
55 | function ask() {
56 | local prompt="$1"
57 | local response
58 | read -p $'\n\e[1;36m'"${prompt}:"$'\e[0m ' response
59 | echo "$response"
60 | }
61 | #endregion message tools
62 |
63 | # Function to display usage information
64 | function usage {
65 | msgb "\nUsage:\n$0 [install | uninstall]\n"
66 | msgb "Options:"
67 | echo " install Install the NVIDIA GPU monitoring modification"
68 | echo " uninstall Remove the modification and restore original files"
69 | echo ""
70 | exit 1
71 | }
72 |
73 | # System checks
74 | function check_root_privileges() {
75 | if [[ $EUID -ne 0 ]]; then
76 | err "This script must be run as root. Please run it with 'sudo $0'."
77 | fi
78 | info "Root privileges verified."
79 | }
80 |
81 | # Check if nvidia-smi is available
82 | function check_nvidia_smi() {
83 | if ! command -v nvidia-smi &>/dev/null; then
84 | err "nvidia-smi is not installed or not in PATH. Please install NVIDIA drivers first."
85 | fi
86 | info "nvidia-smi found."
87 | }
88 |
89 | # Detect NVIDIA GPUs
90 | function detect_gpus() {
91 | local gpu_count
92 | gpu_count=$(nvidia-smi --query-gpu=count --format=csv,noheader,nounits 2>/dev/null | head -1)
93 |
94 | if [[ -z "$gpu_count" ]] || [[ "$gpu_count" -eq 0 ]]; then
95 | err "No NVIDIA GPUs detected by nvidia-smi."
96 | fi
97 |
98 | echo "$gpu_count"
99 | }
100 |
101 | # Configure installation options
102 | function configure() {
103 | msgb "\n=== Detecting NVIDIA GPUs ==="
104 |
105 | check_nvidia_smi
106 |
107 | local gpu_count
108 | gpu_count=$(detect_gpus)
109 |
110 | info "Detected $gpu_count NVIDIA GPU(s):"
111 |
112 | # Display detected GPUs
113 | nvidia-smi --query-gpu=index,name --format=csv,noheader 2>/dev/null | while read -r line; do
114 | echo " GPU $line"
115 | done
116 |
117 | # Temperature unit selection
118 | msgb "\n=== Display Settings ==="
119 | local unit
120 | unit=$(ask "Display temperatures in Celsius [C] or Fahrenheit [f]? (C/f)")
121 | case "$unit" in
122 | [fF])
123 | TEMP_UNIT="F"
124 | info "Using Fahrenheit."
125 | ;;
126 | *)
127 | TEMP_UNIT="C"
128 | info "Using Celsius."
129 | ;;
130 | esac
131 | }
132 |
133 | # Function to check if the modification is already installed
134 | function check_mod_installation() {
135 | if grep -q 'nvidiaGpuOutput' "$NODES_PM_FILE" 2>/dev/null; then
136 | err "NVIDIA GPU mod is already installed. Please uninstall first before reinstalling."
137 | fi
138 | }
139 |
140 | # Set backup directory
141 | function set_backup_directory() {
142 | if [[ -z "$BACKUP_DIR" ]]; then
143 | BACKUP_DIR="$HOME/PVE-MODS"
144 | info "Using default backup directory: $BACKUP_DIR"
145 | else
146 | if [[ ! -d "$BACKUP_DIR" ]]; then
147 | err "The specified backup directory does not exist: $BACKUP_DIR"
148 | fi
149 | info "Using custom backup directory: $BACKUP_DIR"
150 | fi
151 | }
152 |
153 | # Create backup directory
154 | function create_backup_directory() {
155 | set_backup_directory
156 |
157 | if [[ ! -d "$BACKUP_DIR" ]]; then
158 | mkdir -p "$BACKUP_DIR" 2>/dev/null || {
159 | err "Failed to create backup directory: $BACKUP_DIR. Please check permissions."
160 | }
161 | info "Created backup directory: $BACKUP_DIR"
162 | else
163 | info "Backup directory already exists: $BACKUP_DIR"
164 | fi
165 | }
166 |
167 | # Create file backup
168 | function create_file_backup() {
169 | local source_file="$1"
170 | local timestamp="$2"
171 | local filename
172 |
173 | filename=$(basename "$source_file")
174 | local backup_file="$BACKUP_DIR/nvidia-gpu.${filename}.$timestamp"
175 |
176 | [[ -f "$source_file" ]] || err "Source file does not exist: $source_file"
177 | [[ -r "$source_file" ]] || err "Cannot read source file: $source_file"
178 |
179 | cp "$source_file" "$backup_file" || err "Failed to create backup: $backup_file"
180 |
181 | # Verify backup integrity
182 | if ! cmp -s "$source_file" "$backup_file"; then
183 | err "Backup verification failed for: $backup_file"
184 | fi
185 |
186 | info "Created backup: $backup_file"
187 | }
188 |
189 | # Perform backup of files
190 | function perform_backup() {
191 | local timestamp
192 | timestamp=$(date +%Y%m%d_%H%M%S)
193 |
194 | msgb "\n=== Creating backups of modified files ==="
195 |
196 | create_backup_directory
197 | create_file_backup "$NODES_PM_FILE" "$timestamp"
198 | create_file_backup "$PVE_MANAGER_LIB_JS_FILE" "$timestamp"
199 | }
200 |
201 | # Restart pveproxy service
202 | function restart_proxy() {
203 | info "Restarting PVE proxy..."
204 | systemctl restart pveproxy
205 | }
206 |
207 | # Insert NVIDIA GPU data collection into Nodes.pm
208 | function insert_node_info() {
209 | msgb "\n=== Inserting NVIDIA GPU data retrieval code ==="
210 |
211 | local nvidia_cmd='nvidia-smi --query-gpu=index,name,temperature.gpu,utilization.gpu,utilization.memory,memory.used,memory.total,power.draw,power.limit,fan.speed --format=csv,noheader,nounits 2>/dev/null'
212 |
213 | # Insert the nvidia-smi command into Nodes.pm
214 | # This adds a new field to the API response
215 | sed -i "/my \$dinfo = df('\/', 1);/i\\
216 | # Collect NVIDIA GPU data\\
217 | \$res->{nvidiaGpuOutput} = \`$nvidia_cmd\`;\\
218 | " "$NODES_PM_FILE"
219 |
220 | if [[ $? -ne 0 ]]; then
221 | err "Failed to insert NVIDIA GPU retrieval code into Nodes.pm"
222 | fi
223 |
224 | info "NVIDIA GPU retriever added to \"$NODES_PM_FILE\"."
225 | }
226 |
227 | # Insert GPU widget before the 'cpus' item in pvemanagerlib.js
228 | function insert_gpu_widget() {
229 | msgb "\n=== Inserting GPU widget into UI ==="
230 |
231 | local temp_js_file="/tmp/nvidia_gpu_widget.js"
232 |
233 | # Generate the GPU widget JavaScript code with proper indentation
234 | # Indentation matches the pvemanagerlib.js style (tab-based)
235 | cat > "$temp_js_file" << 'WIDGET_EOF'
236 | {
237 | xtype: 'box',
238 | colspan: 2,
239 | html: gettext('GPU(s)'),
240 | },
241 | {
242 | itemId: 'nvidiaGpu',
243 | colspan: 2,
244 | printBar: false,
245 | title: gettext('NVIDIA GPU Status'),
246 | iconCls: 'fa fa-fw fa-television',
247 | textField: 'nvidiaGpuOutput',
248 | renderer: function(value) {
249 | if (!value || value.trim() === '') {
250 | return '
No NVIDIA GPUs detected or nvidia-smi not available
';
251 | }
252 |
253 | // Temperature conversion settings
254 | WIDGET_EOF
255 |
256 | # Add temperature settings based on user selection
257 | if [[ "$TEMP_UNIT" == "F" ]]; then
258 | cat >> "$temp_js_file" << 'WIDGET_EOF'
259 | var toFahrenheit = true;
260 | var tempUnit = '°F';
261 | var tempWarning = 158; // 70°C in F
262 | var tempCritical = 185; // 85°C in F
263 | WIDGET_EOF
264 | else
265 | cat >> "$temp_js_file" << 'WIDGET_EOF'
266 | var toFahrenheit = false;
267 | var tempUnit = '°C';
268 | var tempWarning = 70;
269 | var tempCritical = 85;
270 | WIDGET_EOF
271 | fi
272 |
273 | # Continue with the rest of the widget code
274 | cat >> "$temp_js_file" << 'WIDGET_EOF'
275 |
276 | function convertTemp(celsius) {
277 | if (toFahrenheit) {
278 | return (celsius * 9 / 5) + 32;
279 | }
280 | return celsius;
281 | }
282 |
283 | function formatTemp(celsius) {
284 | var temp = convertTemp(celsius);
285 | var style = '';
286 | var convertedWarning = toFahrenheit ? tempWarning : 70;
287 | var convertedCritical = toFahrenheit ? tempCritical : 85;
288 |
289 | if (temp >= convertedCritical) {
290 | style = 'color: #ff4444; font-weight: bold;';
291 | } else if (temp >= convertedWarning) {
292 | style = 'color: #FFC300; font-weight: bold;';
293 | }
294 | return '' + temp.toFixed(0) + tempUnit + '';
295 | }
296 |
297 | function formatMemory(used, total) {
298 | var percent = (used / total * 100).toFixed(1);
299 | var style = '';
300 | if (percent >= 90) {
301 | style = 'color: #ff4444; font-weight: bold;';
302 | } else if (percent >= 75) {
303 | style = 'color: #FFC300;';
304 | }
305 | return '' + used.toLocaleString() + ' of ' + total.toLocaleString() + ' MiB (' + percent + '%)';
306 | }
307 |
308 | function formatPower(draw, limit) {
309 | // Handle NaN power draw (some GPUs don't report power)
310 | if (isNaN(draw)) {
311 | draw = 0;
312 | }
313 | if (isNaN(limit) || limit === 0) {
314 | return '' + draw.toFixed(0) + 'W';
315 | }
316 | var percent = (draw / limit * 100);
317 | var style = '';
318 | if (percent >= 90) {
319 | style = 'color: #ff4444; font-weight: bold;';
320 | } else if (percent >= 75) {
321 | style = 'color: #FFC300;';
322 | }
323 | return '' + draw.toFixed(0) + ' of ' + limit.toFixed(0) + 'W';
324 | }
325 |
326 | function formatUtilization(util) {
327 | var style = '';
328 | if (util >= 90) {
329 | style = 'color: #FFC300;';
330 | }
331 | return '' + util + '%';
332 | }
333 |
334 | function formatFan(fan) {
335 | if (fan === null || fan === undefined || isNaN(fan) || fan === '[Not Supported]' || fan === '') {
336 | return 'N/A';
337 | }
338 | return fan + '%';
339 | }
340 |
341 | var lines = value.trim().split('\n');
342 | var result = [];
343 |
344 | for (var i = 0; i < lines.length; i++) {
345 | var line = lines[i].trim();
346 | if (!line) continue;
347 |
348 | var parts = line.split(',').map(function(p) { return p.trim(); });
349 |
350 | if (parts.length < 10) continue;
351 |
352 | var gpuIndex = parts[0];
353 | var gpuName = parts[1];
354 | var temp = parseFloat(parts[2]);
355 | var gpuUtil = parseInt(parts[3], 10);
356 | var memUtil = parseInt(parts[4], 10);
357 | var memUsed = parseFloat(parts[5]);
358 | var memTotal = parseFloat(parts[6]);
359 | var powerDraw = parseFloat(parts[7]);
360 | var powerLimit = parseFloat(parts[8]);
361 | var fanSpeed = parts[9];
362 |
363 | // Parse fan speed (may be [Not Supported] or a number)
364 | var fanValue = parseFloat(fanSpeed);
365 | if (isNaN(fanValue)) {
366 | fanValue = null;
367 | }
368 |
369 | var gpuHtml = '';
370 | gpuHtml += '
GPU ' + gpuIndex + ': ' + gpuName + '
';
371 | gpuHtml += '
';
372 | gpuHtml += 'Temp: ' + formatTemp(temp);
373 | gpuHtml += ' | GPU: ' + formatUtilization(gpuUtil);
374 | gpuHtml += ' | Mem: ' + formatMemory(memUsed, memTotal);
375 | gpuHtml += ' | Power: ' + formatPower(powerDraw, powerLimit);
376 | gpuHtml += ' | Fan: ' + formatFan(fanValue);
377 | gpuHtml += '
';
378 |
379 | result.push(gpuHtml);
380 | }
381 |
382 | if (result.length === 0) {
383 | return 'No GPU data available
';
384 | }
385 |
386 | return '' + result.join('') + '
';
387 | }
388 | },
389 | WIDGET_EOF
390 |
391 | if [[ $? -ne 0 ]]; then
392 | err "Failed to generate GPU widget code"
393 | fi
394 |
395 | # Insert the widget BEFORE the 'cpus' item
396 | # We use perl for reliable multi-line pattern matching and insertion
397 | # The BEGIN block reads the widget file before processing the main file
398 | perl -i -0777 -pe '
399 | BEGIN {
400 | open(my $fh, "<", "/tmp/nvidia_gpu_widget.js") or die "Cannot open widget file: $!";
401 | local $/;
402 | $::widget = <$fh>;
403 | close($fh);
404 | }
405 | # Find the cpus item within StatusView and insert widget before it
406 | s/(Ext\.define\('\''PVE\.node\.StatusView'\''.*?items:\s*\[.*?)({\s*itemId:\s*'\''cpus'\'')/$1$::widget$2/s;
407 | ' "$PVE_MANAGER_LIB_JS_FILE"
408 |
409 | local insert_status=$?
410 |
411 | # Verify the insertion succeeded
412 | if [[ $insert_status -ne 0 ]]; then
413 | rm -f "$temp_js_file"
414 | err "Failed to insert GPU widget into pvemanagerlib.js (perl error)"
415 | fi
416 |
417 | # Verify the widget was actually inserted by checking for our itemId
418 | if ! grep -q "itemId: 'nvidiaGpu'" "$PVE_MANAGER_LIB_JS_FILE"; then
419 | rm -f "$temp_js_file"
420 | err "Widget insertion verification failed - nvidiaGpu itemId not found in file"
421 | fi
422 |
423 | rm -f "$temp_js_file"
424 | info "GPU widget inserted into \"$PVE_MANAGER_LIB_JS_FILE\"."
425 | }
426 |
427 | # Main installation function
428 | function install_mod() {
429 | msgb "\n=== Preparing NVIDIA GPU mod installation ==="
430 |
431 | check_root_privileges
432 | check_mod_installation
433 | configure
434 | perform_backup
435 |
436 | insert_node_info
437 | insert_gpu_widget
438 |
439 | msgb "\n=== Finalizing installation ==="
440 |
441 | restart_proxy
442 |
443 | info "Installation completed successfully."
444 | msgb "\nIMPORTANT: Clear your browser cache (Ctrl+Shift+R) to see the changes."
445 | }
446 |
447 | # Uninstall the modification
448 | function uninstall_mod() {
449 | msgb "\n=== Uninstalling NVIDIA GPU Mod ==="
450 |
451 | check_root_privileges
452 |
453 | # Check if mod is installed
454 | if ! grep -q 'nvidiaGpuOutput' "$NODES_PM_FILE" 2>/dev/null; then
455 | err "NVIDIA GPU mod is not installed."
456 | fi
457 |
458 | set_backup_directory
459 |
460 | # Check for other mods that would be affected by backup restoration
461 | local other_mods_detected=false
462 | local detected_mods=""
463 |
464 | if grep -q 'sensorsOutput' "$NODES_PM_FILE" 2>/dev/null; then
465 | other_mods_detected=true
466 | detected_mods="pve-mod-gui-sensors"
467 | fi
468 |
469 | if [[ "$other_mods_detected" == true ]]; then
470 | warn "Other PVE mods detected: $detected_mods"
471 | warn "Restoring from backup will remove ALL mods installed after the nvidia-gpu backup was created."
472 | msgb "\nYou have two options:"
473 | echo " 1) Continue - Restore backup, then reinstall other mods afterward"
474 | echo " 2) Cancel - Manually remove nvidia-gpu code from files instead"
475 | echo ""
476 | local confirm
477 | confirm=$(ask "Continue with backup restoration? (y/N)")
478 | if [[ ! "$confirm" =~ ^[yY]$ ]]; then
479 | info "Uninstall cancelled."
480 | msgb "\nTo manually remove, edit these files:"
481 | echo " - $NODES_PM_FILE (remove nvidiaGpuOutput lines)"
482 | echo " - $PVE_MANAGER_LIB_JS_FILE (remove nvidiaGpu widget)"
483 | exit 0
484 | fi
485 | fi
486 |
487 | info "Restoring modified files..."
488 |
489 | # Find the latest Nodes.pm backup
490 | local latest_nodes_pm
491 | latest_nodes_pm=$(find "$BACKUP_DIR" -name "nvidia-gpu.Nodes.pm.*" -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n 1 | awk '{print $2}')
492 |
493 | if [[ -n "$latest_nodes_pm" ]]; then
494 | msgb "Restoring Nodes.pm from backup: $latest_nodes_pm"
495 | cp "$latest_nodes_pm" "$NODES_PM_FILE"
496 | info "Restored Nodes.pm successfully."
497 | else
498 | warn "No Nodes.pm backup found. Attempting manual removal..."
499 | # Remove the nvidia-smi lines manually
500 | sed -i '/# Collect NVIDIA GPU data/,/nvidiaGpuOutput.*nvidia-smi/d' "$NODES_PM_FILE"
501 | fi
502 |
503 | # Find the latest pvemanagerlib.js backup
504 | local latest_pvemanagerlibjs
505 | latest_pvemanagerlibjs=$(find "$BACKUP_DIR" -name "nvidia-gpu.pvemanagerlib.js.*" -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n 1 | awk '{print $2}')
506 |
507 | if [[ -n "$latest_pvemanagerlibjs" ]]; then
508 | msgb "Restoring pvemanagerlib.js from backup: $latest_pvemanagerlibjs"
509 | cp "$latest_pvemanagerlibjs" "$PVE_MANAGER_LIB_JS_FILE"
510 | info "Restored pvemanagerlib.js successfully."
511 | else
512 | warn "No pvemanagerlib.js backup found. Manual restoration may be required."
513 | warn "You can reinstall pve-manager package to restore: apt install --reinstall pve-manager"
514 | fi
515 |
516 | restart_proxy
517 |
518 | info "Uninstallation completed."
519 | msgb "\nIMPORTANT: Clear your browser cache (Ctrl+Shift+R) to see the changes."
520 | }
521 |
522 | # Process command line arguments
523 | executed=0
524 | while [[ $# -gt 0 ]]; do
525 | case "$1" in
526 | install)
527 | executed=$((executed + 1))
528 | install_mod
529 | ;;
530 | uninstall)
531 | executed=$((executed + 1))
532 | uninstall_mod
533 | ;;
534 | *)
535 | warn "Unknown option: $1"
536 | usage
537 | ;;
538 | esac
539 | shift
540 | done
541 |
542 | # If no arguments provided, show usage
543 | if [[ $executed -eq 0 ]]; then
544 | usage
545 | fi
546 |
--------------------------------------------------------------------------------
/pve-mod-gui-sensors.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | #
3 | # This bash script installs a modification to the Proxmox Virtual Environment (PVE) web user interface (UI) to display sensors information.
4 | #
5 |
6 | ################### Configuration #############
7 |
8 | # Display configuration for HDD, NVME, CPU
9 | # Set to 0 to disable line breaks
10 | # Note: use these settings only if the displayed layout is broken
11 | CPU_ITEMS_PER_ROW=0
12 | NVME_ITEMS_PER_ROW=0
13 | HDD_ITEMS_PER_ROW=0
14 |
15 | # Known CPU sensor names. They can be full or partial but should ensure unambiguous identification.
16 | # Should new ones be added, also update logic in configure() function.
17 | KNOWN_CPU_SENSORS=("coretemp-isa-" "k10temp-pci-")
18 |
19 | # Overwrite default backup location
20 | BACKUP_DIR=""
21 |
22 | ##################### DO NOT EDIT BELOW #######################
23 | # Only to be used to debug on other systems. Save the "sensor -j" output into a json file.
24 | # Information will be loaded for script configuration and presented in Proxmox.
25 |
26 | # DEV NOTE: lm-sensors version >3.6.0 breakes properly formatted JSON output using 'sensors -j'. This implements a workaround using uses a python3 for formatting
27 |
28 | DEBUG_REMOTE=false
29 | DEBUG_JSON_FILE="/tmp/sensordata.json"
30 | DEBUG_UPS_FILE="/tmp/upsc.txt"
31 |
32 | # This script's working directory
33 | SCRIPT_CWD="$(dirname "$(readlink -f "$0")")"
34 |
35 | # Debug location
36 | JSON_EXPORT_DIRECTORY="$SCRIPT_CWD"
37 | JSON_EXPORT_FILENAME="sensorsdata.json"
38 |
39 | # File paths
40 | PVE_MANAGER_LIB_JS_FILE="/usr/share/pve-manager/js/pvemanagerlib.js"
41 | NODES_PM_FILE="/usr/share/perl5/PVE/API2/Nodes.pm"
42 |
43 | #region message tools
44 | # Section header (bold)
45 | function msgb() {
46 | local message="$1"
47 | echo -e "\e[1m${message}\e[0m"
48 | }
49 |
50 | # Info (green)
51 | function info() {
52 | local message="$1"
53 | echo -e "\e[0;32m[info] ${message}\e[0m"
54 | }
55 |
56 | # Warning (yellow)
57 | function warn() {
58 | local message="$1"
59 | echo -e "\e[0;33m[warning] ${message}\e[0m"
60 | }
61 |
62 | # Error (red)
63 | function err() {
64 | local message="$1"
65 | echo -e "\e[0;31m[error] ${message}\e[0m"
66 | exit 1
67 | }
68 |
69 | # Prompts (cyan or bold)
70 | function ask() {
71 | local prompt="$1"
72 | local response
73 | read -p $'\n\e[1;36m'"${prompt}:"$'\e[0m ' response
74 | echo "$response"
75 | }
76 | #endregion message tools
77 |
78 | # Function to display usage information
79 | function usage {
80 | msgb "\nUsage:\n$0 [install | uninstall | save-sensors-data]\n"
81 | exit 1
82 | }
83 |
84 | # System checks
85 | function check_root_privileges() {
86 | [[ $EUID -eq 0 ]] || err "This script must be run as root. Please run it with 'sudo $0'."
87 | info "Root privileges verified."
88 | }
89 |
90 | # Define a function to install packages
91 | function install_packages {
92 | # Check if the 'sensors' command is available on the system
93 | if (! command -v sensors &>/dev/null); then
94 | # If the 'sensors' command is not available, prompt the user to install lm-sensors
95 | local choiceInstallLmSensors=$(ask "lm-sensors is not installed. Would you like to install it? (y/n)")
96 | case "$choiceInstallLmSensors" in
97 | [yY])
98 | # If the user chooses to install lm-sensors, update the package list and install the package
99 | apt-get update
100 | apt-get install lm-sensors
101 | ;;
102 | [nN])
103 | # If the user chooses not to install lm-sensors, exit the script with a zero status code
104 | msgb "Decided to not install lm-sensors. The mod cannot run without it. Exiting..."
105 | err "lm-sensors is required. Exiting..."
106 | ;;
107 | *)
108 | # If the user enters an invalid input, print an error message and exit the script with a non-zero status code
109 | err "Invalid input. Exiting..."
110 | ;;
111 | esac
112 | fi
113 |
114 | # Check if lm-sensors is installed correctly and exit if not
115 | if (! command -v sensors &>/dev/null); then
116 | err "lm-sensors installation failed or 'sensors' command is not available. Please install lm-sensors manually and re-run the script."
117 | fi
118 | }
119 |
120 | function configure {
121 | SENSORS_DETECTED=false
122 | local sensorsOutput
123 | local sanitisedSensorsOutput
124 | local upsOutput
125 | local modelName
126 |
127 | install_packages
128 |
129 | #### Collect lm-sensors output ####
130 | #region sensors collection
131 | if [ "$DEBUG_REMOTE" = true ]; then
132 | warn "Remote debugging is used. Sensor readings from dump file $DEBUG_JSON_FILE will be used."
133 | warn "Remote debugging is used. UPS readings from dump file $DEBUG_UPS_FILE will be used."
134 | sensorsOutput=$(cat "$DEBUG_JSON_FILE")
135 | else
136 | sensorsOutput=$(sensors -j 2>/dev/null)
137 | fi
138 |
139 | # Apply lm-sensors sanitization
140 | sanitisedSensorsOutput=$(sanitize_sensors_output "$sensorsOutput")
141 |
142 | if [ $? -ne 0 ]; then
143 | err "Sensor output error.\n\nCommand output:\n${sanitisedSensorsOutput}\n\nExiting..."
144 | fi
145 | #endregion sensors collection
146 |
147 | #### CPU ####
148 | #region cpu setup
149 | msgb "\n=== Detecting CPU temperature sensors ==="
150 | ENABLE_CPU=false
151 | local cpuList=""
152 | local cpuCount=0
153 |
154 | # Find all CPU sensors that match known patterns
155 | for pattern in "${KNOWN_CPU_SENSORS[@]}"; do
156 | found_sensors=$(echo "$sanitisedSensorsOutput" | grep -o "\"${pattern}[^\"]*\"" | sed 's/"//g')
157 | if [ -n "$found_sensors" ]; then
158 | while read -r sensor; do
159 | if [ -n "$sensor" ]; then
160 | cpuCount=$((cpuCount + 1))
161 | if [ -z "$cpuList" ]; then
162 | cpuList="$sensor"
163 | else
164 | cpuList="$cpuList,$sensor"
165 | fi
166 | ENABLE_CPU=true
167 | fi
168 | done <<< "$found_sensors"
169 | fi
170 | done
171 |
172 | if [ "$ENABLE_CPU" = true ]; then
173 | info "Detected CPU sensors ($cpuCount): $cpuList"
174 | SENSORS_DETECTED=true
175 | while true; do
176 | local choice=$(ask "Display temperatures for all cores [C] or average per CPU [a] (some newer AMD variants support per die)? (C/a)")
177 | case "$choice" in
178 | [cC]|"")
179 | CPU_TEMP_TARGET="Core"
180 | info "Temperatures will be displayed for all cores."
181 | break
182 | ;;
183 | [aA])
184 | CPU_TEMP_TARGET="Package"
185 | info "An average temperature will be displayed per CPU."
186 | break
187 | ;;
188 | *)
189 | warn "Invalid input, please choose C or a."
190 | ;;
191 | esac
192 | done
193 | else
194 | warn "No CPU temperature sensors found."
195 | fi
196 | #endregion cpu setup
197 |
198 | #### RAM ####
199 | #region ram setup
200 | msgb "\n=== Detecting RAM temperature sensors ==="
201 | local ramList=$(echo "$sanitisedSensorsOutput" | grep -o '"SODIMM[^"]*"' | sed 's/"//g' | paste -sd, -)
202 | local ramCount=$(grep -c '"SODIMM[^"]*"' <<<"$sanitisedSensorsOutput")
203 |
204 | if [ "$ramCount" -gt 0 ]; then
205 | info "Detected RAM sensors ($ramCount): $ramList"
206 | ENABLE_RAM_TEMP=true
207 | SENSORS_DETECTED=true
208 | else
209 | warn "No RAM temperature sensors found."
210 | ENABLE_RAM_TEMP=false
211 | fi
212 | #endregion ram setup
213 |
214 | #### HDD/SSD ####
215 | #region hdd setup
216 | msgb "\n=== Detecting HDD/SSD temperature sensors ==="
217 | local hddList=($(echo "$sanitisedSensorsOutput" | grep -o '"drivetemp-scsi[^"]*"' | sed 's/"//g'))
218 | if [ ${#hddList[@]} -gt 0 ]; then
219 | info "Detected HDD/SSD sensors (${#hddList[@]}): $(IFS=,; echo "${hddList[*]}")"
220 | ENABLE_HDD_TEMP=true
221 | SENSORS_DETECTED=true
222 | else
223 | warn "No HDD/SSD temperature sensors found."
224 | ENABLE_HDD_TEMP=false
225 | fi
226 | #endregion hdd setup
227 |
228 | #### NVMe ####
229 | #region nvme setup
230 | msgb "\n=== Detecting NVMe temperature sensors ==="
231 | local nvmeList=($(echo "$sanitisedSensorsOutput" | grep -o '"nvme[^"]*"' | sed 's/"//g'))
232 | if [ ${#nvmeList[@]} -gt 0 ]; then
233 | info "Detected NVMe sensors (${#nvmeList[@]}): $(IFS=,; echo "${nvmeList[*]}")"
234 | ENABLE_NVME_TEMP=true
235 | SENSORS_DETECTED=true
236 | else
237 | warn "No NVMe temperature sensors found."
238 | ENABLE_NVME_TEMP=false
239 | fi
240 | #endregion nvme setup
241 |
242 | #### Fans ####
243 | #region fan setup
244 | msgb "\n=== Detecting fan speed sensors ==="
245 |
246 | local fanList=""
247 | local fanCount=0
248 |
249 | # Find all fan names that have fan*_input entries
250 | fanList=$(echo "$sanitisedSensorsOutput" | grep -B2 '"fan[0-9]\+_input"' | grep '".*": {' | sed 's/.*"\([^"]*\)": {.*/\1/' | sort -u | paste -sd, -)
251 | fanCount=$(grep -c 'fan[0-9]\+_input' <<<"$sanitisedSensorsOutput")
252 |
253 | if [ "$fanCount" -gt 0 ]; then
254 | info "Detected fan speed sensors ($fanCount): $fanList"
255 | ENABLE_FAN_SPEED=true
256 | SENSORS_DETECTED=true
257 |
258 | local choice
259 | choice=$(ask "Display fans reporting zero speed? (Y/n)")
260 | case "$choice" in
261 | [yY]|"")
262 | DISPLAY_ZERO_SPEED_FANS=true
263 | info "Zero-speed fans will be displayed."
264 | ;;
265 | [nN])
266 | DISPLAY_ZERO_SPEED_FANS=false
267 | info "Only active fans will be displayed."
268 | ;;
269 | *)
270 | warn "Invalid input. Defaulting to show zero-speed fans."
271 | DISPLAY_ZERO_SPEED_FANS=true
272 | ;;
273 | esac
274 | else
275 | warn "No fan speed sensors found."
276 | ENABLE_FAN_SPEED=false
277 | fi
278 | #endregion fan setup
279 |
280 | #### Temperature Units ####
281 | #region temp unit setup
282 | msgb "\n=== Display temperature ==="
283 | if [ "$SENSORS_DETECTED" = true ]; then
284 | local unit=$(ask "Display temperatures in Celsius [C] or Fahrenheit [f]? (C/f)")
285 | case "$unit" in
286 | [cC]|"")
287 | TEMP_UNIT="C"
288 | info "Using Celsius."
289 | ;;
290 | [fF])
291 | TEMP_UNIT="F"
292 | info "Using Fahrenheit."
293 | ;;
294 | *)
295 | warn "Invalid selection. Defaulting to Celsius."
296 | TEMP_UNIT="C"
297 | ;;
298 | esac
299 | fi
300 | #endregion temp unit setup
301 |
302 | #### UPS ####
303 | #region ups setup
304 | local choiceUPS=$(ask "Enable UPS information? (y/N)")
305 | case "$choiceUPS" in
306 | [yY])
307 | if [ "$DEBUG_REMOTE" = true ]; then
308 | upsOutput=$(cat "$DEBUG_UPS_FILE")
309 | info "Remote debugging: UPS readings from $DEBUG_UPS_FILE"
310 | upsConnection="DEBUG_UPS"
311 | else
312 | upsConnection=$(ask "Enter UPS connection (e.g., upsname[@hostname[:port]])")
313 | if ! command -v upsc &>/dev/null; then
314 | err "The 'upsc' command is not available. Install 'nut-client'."
315 | fi
316 | upsOutput=$(upsc "$upsConnection" 2>&1)
317 | fi
318 |
319 | if echo "$upsOutput" | grep -q "device.model:"; then
320 | modelName=$(echo "$upsOutput" | grep "device.model:" | cut -d':' -f2- | xargs)
321 | ENABLE_UPS=true
322 | info "Connected to UPS model: $modelName at $upsConnection."
323 | else
324 | warn "Failed to connect to UPS at '$upsConnection'."
325 | ENABLE_UPS=false
326 | fi
327 | ;;
328 | [nN]|"")
329 | ENABLE_UPS=false
330 | info "UPS information will not be displayed."
331 | ;;
332 | *)
333 | warn "Invalid selection. UPS info will not be displayed."
334 | ENABLE_UPS=false
335 | ;;
336 | esac
337 | #endregion ups setup
338 |
339 | #### System Info ####
340 | #region system info setup
341 | msgb "\n=== Detecting System Information ==="
342 | for i in 1 2; do
343 | echo "type ${i})"
344 | dmidecode -t "$i" | awk -F': ' '/Manufacturer|Product Name|Serial Number/ {print $1": "$2}'
345 | done
346 | local choiceSysInfo=$(ask "Enable system information? (1/2/n)")
347 | case "$choiceSysInfo" in
348 | [1]|"")
349 | ENABLE_SYSTEM_INFO=true
350 | SYSTEM_INFO_TYPE=1
351 | info "System information will be displayed."
352 | ;;
353 | [2])
354 | ENABLE_SYSTEM_INFO=true
355 | SYSTEM_INFO_TYPE=2
356 | info "Motherboard information will be displayed."
357 | ;;
358 | [nN])
359 | ENABLE_SYSTEM_INFO=false
360 | info "System information will NOT be displayed."
361 | ;;
362 | *)
363 | warn "Invalid selection. Defaulting to system information."
364 | ENABLE_SYSTEM_INFO=true
365 | SYSTEM_INFO_TYPE=1
366 | ;;
367 | esac
368 | #endregion system info setup
369 |
370 | #### Final Check ####
371 | #region final check
372 | if [ "$SENSORS_DETECTED" = false ] && [ "$ENABLE_UPS" = false ] && [ "$ENABLE_SYSTEM_INFO" = false ]; then
373 | err "No sensors detected, UPS or system info enabled. Exiting."
374 | fi
375 | #endregion final check
376 | }
377 |
378 |
379 | # Function to install the modification
380 | function install_mod {
381 | local upsConnection
382 | msgb "\n=== Preparing mod installation ==="
383 | check_root_privileges
384 | check_mod_installation
385 | configure
386 | perform_backup
387 |
388 | #### Insert information retrieval code ####
389 | msgb "\n=== Inserting information retrieval code ==="
390 | insert_node_info
391 |
392 | #### Temperature helper parameters ####
393 | msgb "\n=== Creating temperature conversion helper ==="
394 | HELPERCTORPARAMS=$([[ "$TEMP_UNIT" = "F" ]] && \
395 | echo '{srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.FAHRENHEIT}' || \
396 | echo '{srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.CELSIUS}')
397 | info "Temperature helper configured for $TEMP_UNIT."
398 |
399 | #### Expand StatusView space ####
400 | expand_statusview_space
401 |
402 | #### Insert temperature helper ####
403 | generate_and_insert_temp_helper
404 |
405 | #### Generate and insert widgets ####
406 | msgb "\n=== Making visual adjustments ==="
407 |
408 | generate_and_insert_widget "$ENABLE_SYSTEM_INFO" "generate_system_info" "system_info"
409 | generate_and_insert_widget "$ENABLE_UPS" "generate_ups_widget" "ups"
410 | generate_and_insert_widget "$ENABLE_HDD_TEMP" "generate_hdd_widget" "hdd"
411 | generate_and_insert_widget "$ENABLE_NVME_TEMP" "generate_nvme_widget" "nvme"
412 |
413 | if [[ "$ENABLE_HDD_TEMP" = true || "$ENABLE_NVME_TEMP" = true ]]; then
414 | generate_drive_header
415 | info "Drive headers added."
416 | fi
417 |
418 | generate_and_insert_widget "$ENABLE_FAN_SPEED" "generate_fan_widget" "fan"
419 | generate_and_insert_widget "$ENABLE_RAM_TEMP" "generate_ram_widget" "ram"
420 | generate_and_insert_widget "$ENABLE_CPU" "generate_cpu_widget" "cpu"
421 |
422 | #### Visual separation ####
423 | add_visual_separator
424 | info "Added visual separator for modified items."
425 |
426 | #### Node summary ####
427 | setup_node_summary_container
428 | info "Node summary box moved into its own container."
429 |
430 | msgb "\n=== Finalizing installation ==="
431 |
432 | restart_proxy
433 | info "Installation completed."
434 | ask "Clear the browser cache to ensure all changes are visualized. (any key to continue)"
435 | }
436 |
437 | # Sanitize sensors output to handle common lm-sensors parsing issues
438 | sanitize_sensors_output() {
439 | local input="$1"
440 |
441 | # Pipe the text into Perl:
442 | # -0777 → "slurp mode": read the entire stream as one string so
443 | # regexes can match across line breaks.
444 | # -pe → loop over input, applying the script (-e) and printing.
445 | # Apply python3 json.tool for proper formatting and validation
446 | echo "$input" | perl -0777 -pe '
447 | # Replace ERROR lines with placeholder values
448 | s/ERROR:.+\s(\w+):\s(.+)/"$1": 0.000,/g;
449 | s/ERROR:.+\s(\w+)!/"$1": 0.000,/g;
450 |
451 | # Remove trailing commas before closing braces
452 | s/,\s*(\})/$1/g;
453 |
454 | # Replace NaN values with null
455 | s/\bNaN\b/null/g;
456 |
457 | # Fix duplicate SODIMM keys - handle both pretty and one-line JSON
458 | s/"SODIMM"\s*:\s*\{\s*"temp(\d+)_input"/"SODIMM $1": {\n "temp$1_input"/g;
459 |
460 | # Fix duplicate fan keys - handle both pretty and one-line JSON
461 | s/"([^"]*Fan[^"]*)"\s*:\s*\{\s*"fan(\d+)_input"/"$1 $2": {\n "fan$2_input"/g;
462 | ' | python3 -m json.tool 2>/dev/null || echo "$input"
463 | }
464 |
465 | #region node info insertion
466 | # Main insertion routine
467 | insert_node_info() {
468 | local output_file="$NODES_PM_FILE"
469 |
470 | collect_sensors_output "$output_file"
471 |
472 | if [[ $ENABLE_UPS == true ]]; then
473 | collect_ups_output "$output_file"
474 | fi
475 |
476 | if [[ $ENABLE_SYSTEM_INFO == true ]]; then
477 | collect_system_info "$output_file"
478 | fi
479 | }
480 |
481 | # Collect lm-sensors data
482 | collect_sensors_output() {
483 | local output_file="$1"
484 | local sensorsCmd
485 |
486 | if [[ $DEBUG_REMOTE == true ]]; then
487 | sensorsCmd="cat \"$DEBUG_JSON_FILE\""
488 | else
489 | # Note: sensors -f (Fahrenheit) breaks fan speeds
490 | sensorsCmd="sensors -j 2>/dev/null"
491 | fi
492 |
493 | # Remember to reflect this in sanitize_sensors_output()
494 | #region sensors heredoc
495 | sed -i '/my \$dinfo = df('\''\/'\'', 1);/i\
496 | # Collect sensor data from lm-sensors\
497 | $res->{sensorsOutput} = `'"$sensorsCmd"'`;\
498 | \
499 | # Sanitize JSON output to handle common lm-sensors parsing issues\
500 | # Replace ERROR lines with placeholder values\
501 | $res->{sensorsOutput} =~ s/ERROR:.+\\s(\\w+):\\s(.+)/\\"$1\\": 0.000,/g;\
502 | $res->{sensorsOutput} =~ s/ERROR:.+\\s(\\w+)!/\\"$1\\": 0.000,/g;\
503 | \
504 | # Remove trailing commas before closing braces\
505 | $res->{sensorsOutput} =~ s/,\\s*(\})/$1/g;\
506 | \
507 | # Replace NaN values with null for valid JSON\
508 | $res->{sensorsOutput} =~ s/\\bNaN\\b/null/g;\
509 | \
510 | # Fix duplicate SODIMM keys by appending temperature sensor number with a space - handle both pretty and one-line JSON\
511 | # Example: "SODIMM":{"temp3_input":34.0} becomes "SODIMM 3":{"temp3_input":34.0}\
512 | $res->{sensorsOutput} =~ s/"SODIMM"\\s*:\\s*\\{\\s*"temp(\\d+)_input"/"SODIMM $1": {\\n "temp$1_input"/g;\
513 | \
514 | # Fix duplicate fans keys by appending fan number with a space - handle both pretty and one-line JSON\
515 | # Example: "Processor Fan":{"fan2_input":1000,...} → "Processor Fan 2":{"fan2_input":1000,...}\
516 | $res->{sensorsOutput} =~ s/"([^"]+)"\\s*:\\s*\{\\s*"fan(\\d+)_input"/"$1 $2": {\\n "fan$2_input"/g;\
517 | \
518 | # Format JSON output properly (workaround for lm-sensors >3.6.0 issues)\
519 | $res->{sensorsOutput} =~ /^(.*)$/s;\
520 | $res->{sensorsOutput} = `echo \\Q$1\\E | python3 -m json.tool 2>/dev/null || echo \\Q$1\E`;\
521 | ' "$NODES_PM_FILE"
522 | #endregion sensors heredoc
523 | info "Sensors' retriever added to \"$output_file\"."
524 | }
525 |
526 | # Collect UPS data
527 | collect_ups_output() {
528 | local output_file="$1"
529 | local ups_cmd
530 |
531 | if [[ $DEBUG_REMOTE == true ]]; then
532 | ups_cmd="cat \"$DEBUG_UPS_FILE\""
533 | else
534 | ups_cmd="upsc \"$upsConnection\" 2>/dev/null"
535 | fi
536 |
537 | # region ups heredoc
538 | sed -i "/my \$dinfo = df('\/', 1);/i\\
539 | # Collect UPS status information\\
540 | sub get_upsc {\\
541 | my \$cmd = '$ups_cmd';\\
542 | my \$output = \`\\\$cmd\`;\\
543 | return \$output;\\
544 | }\\
545 | \$res->{upsc} = get_upsc();\\
546 | " "$NODES_PM_FILE"
547 | # endregion ups heredoc
548 |
549 | info "UPS retriever added to \"$output_file\"."
550 | }
551 |
552 |
553 | # Collect system information
554 | collect_system_info() {
555 | local output_file="$1"
556 | local systemInfoCmd
557 |
558 | systemInfoCmd=$(dmidecode -t "${SYSTEM_INFO_TYPE}" \
559 | | awk -F': ' '/Manufacturer|Product Name|Serial Number/ {print $1": "$2}' \
560 | | awk '{$1=$1};1' \
561 | | sed 's/$/ |/' \
562 | | paste -sd " " - \
563 | | sed 's/ |$//')
564 | #region system info heredoc
565 | sed -i "/my \$dinfo = df('\/', 1);/i\\
566 | # Add system information to response\\
567 | \$res->{systemInfo} = \"$(echo "$systemInfoCmd")\";\\
568 | " "$NODES_PM_FILE"
569 | #endregion system info heredoc
570 | info "System information retriever added to \"$output_file\"."
571 | }
572 | #endregion node info insertion
573 |
574 | #region widget generation functions
575 | # Helper function to insert widget after thermal items
576 | insert_widget_after_thermal() {
577 | local widget_file="$1"
578 | sed -i "/^Ext.define('PVE.node.StatusView',/ {
579 | :a
580 | /items:/!{N;ba;}
581 | :b
582 | /'cpus.*},/!{N;bb;}
583 | r $widget_file
584 | }" "$PVE_MANAGER_LIB_JS_FILE"
585 | }
586 |
587 | # Helper function to generate widget and insert it
588 | generate_and_insert_widget() {
589 | local enable_flag="$1"
590 | local generator_func="$2"
591 | local widget_name="$3"
592 |
593 | if [ "$enable_flag" = true ]; then
594 | local temp_js_file="/tmp/${widget_name}_widget.js"
595 | "$generator_func" "$temp_js_file"
596 | insert_widget_after_thermal "$temp_js_file"
597 | rm "$temp_js_file"
598 | info "Inserted $widget_name widget."
599 | fi
600 | }
601 |
602 | # Function to generate drive header
603 | generate_drive_header() {
604 | if [ "$ENABLE_NVME_TEMP" = true ] || [ "$ENABLE_HDD_TEMP" = true ]; then
605 | local temp_js_file="/tmp/drive_header.js"
606 | #region drive header heredoc
607 | cat > "$temp_js_file" <<'EOF'
608 | {
609 | xtype: 'box',
610 | colspan: 2,
611 | html: gettext('Drive(s)'),
612 | },
613 | EOF
614 | #endregion drive header heredoc
615 | if [[ $? -ne 0 ]]; then
616 | echo "Error: Failed to generate drive header code" >&2
617 | exit 1
618 | fi
619 |
620 | insert_widget_after_thermal "$temp_js_file"
621 | rm "$temp_js_file"
622 | fi
623 | }
624 |
625 | # Function to expand space and modify StatusView properties
626 | expand_statusview_space() {
627 | msgb "\n=== Expanding StatusView space ==="
628 |
629 | # Apply multiple modifications to the StatusView definition
630 | sed -i "/Ext.define('PVE\.node\.StatusView'/,/\},/ {
631 | s/\(bodyPadding:\) '[^']*'/\1 '20 15 20 15'/
632 | s/height: [0-9]\+/minHeight: 360,\n\tflex: 1,\n\tcollapsible: true,\n\ttitleCollapse: true/
633 | s/\(tableAttrs:.*$\)/trAttrs: \{ valign: 'top' \},\n\t\1/
634 | }" "$PVE_MANAGER_LIB_JS_FILE"
635 |
636 | if [[ $? -ne 0 ]]; then
637 | echo "Error: Failed to expand StatusView space" >&2
638 | exit 1
639 | fi
640 |
641 | info "Expanded space in \"$PVE_MANAGER_LIB_JS_FILE\"."
642 | }
643 |
644 | # Function to move node summary into its own container
645 | setup_node_summary_container() {
646 | # Move the node summary box into its own container
647 | local temp_js_file="/tmp/summary_container.js"
648 | #region summary container heredoc
649 | cat > "$temp_js_file" <<'EOF'
650 | {
651 | xtype: 'container',
652 | itemId: 'summarycontainer',
653 | layout: 'column',
654 | minWidth: 700,
655 | defaults: {
656 | minHeight: 350,
657 | padding: 5,
658 | columnWidth: 1,
659 | },
660 | items: [
661 | nodeStatus,
662 | ]
663 | },
664 | EOF
665 | #endregion summary container heredoc
666 | if [[ $? -ne 0 ]]; then
667 | echo "Error: Failed to generate summary container code" >&2
668 | exit 1
669 | fi
670 |
671 | # Insert the new container after finding the nodeStatus and items pattern
672 | sed -i "/^\s*nodeStatus: nodeStatus,/ {
673 | :a
674 | /items: \[/ !{N;ba;}
675 | r $temp_js_file
676 | }" "$PVE_MANAGER_LIB_JS_FILE"
677 |
678 | rm "$temp_js_file"
679 |
680 | # Deactivate the original box instance
681 | sed -i "/^\s*nodeStatus: nodeStatus,/ {
682 | :a
683 | /itemId: 'itemcontainer',/ !{N;ba;}
684 | n;
685 | :b
686 | /nodeStatus,/ !{N;bb;}
687 | s/nodeStatus/\/\/nodeStatus/
688 | }" "$PVE_MANAGER_LIB_JS_FILE"
689 |
690 | if [[ $? -ne 0 ]]; then
691 | echo "Error: Failed to deactivate original nodeStatus instance" >&2
692 | exit 1
693 | fi
694 | }
695 |
696 | # Function to add visual spacing separator after the last widget
697 | add_visual_separator() {
698 | # Check for the presence of items in the reverse order of display
699 | local lastItemId=""
700 |
701 | if [ "$ENABLE_UPS" = true ]; then
702 | lastItemId="upsc"
703 | elif [ "$ENABLE_HDD_TEMP" = true ]; then
704 | lastItemId="thermalHdd"
705 | elif [ "$ENABLE_NVME_TEMP" = true ]; then
706 | lastItemId="thermalNvme"
707 | elif [ "$ENABLE_FAN_SPEED" = true ]; then
708 | lastItemId="speedFan"
709 | else
710 | lastItemId="thermalCpu"
711 | fi
712 |
713 | if [ -n "$lastItemId" ]; then
714 | local temp_js_file="/tmp/visual_separator.js"
715 |
716 | #region visual spacing heredoc
717 | cat > "$temp_js_file" <<'EOF'
718 | {
719 | xtype: 'box',
720 | colspan: 2,
721 | padding: '0 0 20 0',
722 | },
723 | EOF
724 | #endregion visual spacing heredoc
725 | if [[ $? -ne 0 ]]; then
726 | echo "Error: Failed to generate visual separator code" >&2
727 | exit 1
728 | fi
729 |
730 | # Insert after the specific lastItemId (different pattern than thermal)
731 | sed -i "/^Ext.define('PVE.node.StatusView',/ {
732 | :a;
733 | /^.*{.*'$lastItemId'.*},/!{N;ba;}
734 | r $temp_js_file
735 | }" "$PVE_MANAGER_LIB_JS_FILE"
736 |
737 | rm "$temp_js_file"
738 | fi
739 | }
740 |
741 | # Function to generate system info widget
742 | generate_system_info() {
743 | #region system info heredoc
744 | cat > "$1" <<'EOF'
745 | {
746 | itemId: 'sysinfo',
747 | colspan: 2,
748 | printBar: false,
749 | title: gettext('System Information'),
750 | textField: 'systemInfo',
751 | renderer: function(value){
752 | return value;
753 | }
754 | },
755 | EOF
756 | #endregion system info heredoc
757 | if [[ $? -ne 0 ]]; then
758 | echo "Error: Failed to generate system info code" >&2
759 | exit 1
760 | fi
761 | }
762 |
763 | # Function to generate and insert temperature conversion helper class
764 | generate_and_insert_temp_helper() {
765 | local temp_js_file="/tmp/temp_helper.js"
766 |
767 | msgb "\n=== Inserting temperature helper ==="
768 |
769 | #region temp helper heredoc
770 | cat > "$temp_js_file" <<'EOF'
771 | Ext.define('PVE.mod.TempHelper', {
772 | //singleton: true,
773 |
774 | requires: ['Ext.util.Format'],
775 |
776 | statics: {
777 | CELSIUS: 0,
778 | FAHRENHEIT: 1
779 | },
780 |
781 | srcUnit: null,
782 | dstUnit: null,
783 |
784 | isValidUnit: function (unit) {
785 | return (
786 | Ext.isNumber(unit) && (unit === this.self.CELSIUS || unit === this.self.FAHRENHEIT)
787 | );
788 | },
789 |
790 | constructor: function (config) {
791 | this.srcUnit = config && this.isValidUnit(config.srcUnit) ? config.srcUnit : this.self.CELSIUS;
792 | this.dstUnit = config && this.isValidUnit(config.dstUnit) ? config.dstUnit : this.self.CELSIUS;
793 | },
794 |
795 | toFahrenheit: function (tempCelsius) {
796 | return Ext.isNumber(tempCelsius)
797 | ? tempCelsius * 9 / 5 + 32
798 | : NaN;
799 | },
800 |
801 | toCelsius: function (tempFahrenheit) {
802 | return Ext.isNumber(tempFahrenheit)
803 | ? (tempFahrenheit - 32) * 5 / 9
804 | : NaN;
805 | },
806 |
807 | getTemp: function (value) {
808 | if (this.srcUnit !== this.dstUnit) {
809 | switch (this.srcUnit) {
810 | case this.self.CELSIUS:
811 | switch (this.dstUnit) {
812 | case this.self.FAHRENHEIT:
813 | return this.toFahrenheit(value);
814 |
815 | default:
816 | Ext.raise({
817 | msg:
818 | 'Unsupported destination temperature unit: ' + this.dstUnit,
819 | });
820 | }
821 | case this.self.FAHRENHEIT:
822 | switch (this.dstUnit) {
823 | case this.self.CELSIUS:
824 | return this.toCelsius(value);
825 |
826 | default:
827 | Ext.raise({
828 | msg:
829 | 'Unsupported destination temperature unit: ' + this.dstUnit,
830 | });
831 | }
832 | default:
833 | Ext.raise({
834 | msg: 'Unsupported source temperature unit: ' + this.srcUnit,
835 | });
836 | }
837 | } else {
838 | return value;
839 | }
840 | },
841 |
842 | getUnit: function(plainText) {
843 | switch (this.dstUnit) {
844 | case this.self.CELSIUS:
845 | return plainText !== true ? '°C' : '\'C';
846 |
847 | case this.self.FAHRENHEIT:
848 | return plainText !== true ? '°F' : '\'F';
849 |
850 | default:
851 | Ext.raise({
852 | msg: 'Unsupported destination temperature unit: ' + this.srcUnit,
853 | });
854 | }
855 | },
856 | });
857 | EOF
858 | #endregion temp helper heredoc
859 | if [[ $? -ne 0 ]]; then
860 | echo "Error: Failed to generate temp helper code" >&2
861 | exit 1
862 | fi
863 |
864 | sed -i "/^Ext.define('PVE.node.StatusView'/e cat /tmp/temp_helper.js" "$PVE_MANAGER_LIB_JS_FILE"
865 | rm "$temp_js_file"
866 |
867 | info "Temperature helper inserted successfully."
868 | }
869 |
870 | # Function to generate CPU widget
871 | generate_cpu_widget() {
872 | #region cpu widget heredoc
873 | # use subshell to allow variable expansion
874 | (
875 | export CPU_ITEMS_PER_ROW
876 | export CPU_TEMP_TARGET
877 | export HELPERCTORPARAMS
878 |
879 | cat <<'EOF' | envsubst '$CPU_ITEMS_PER_ROW $CPU_TEMP_TARGET $HELPERCTORPARAMS' > "$1"
880 | {
881 | itemId: 'thermalCpu',
882 | colspan: 2,
883 | printBar: false,
884 | title: gettext('CPU Thermal State'),
885 | iconCls: 'fa fa-fw fa-thermometer-half',
886 | textField: 'sensorsOutput',
887 | renderer: function(value){
888 | // sensors configuration
889 | const cpuTempHelper = Ext.create('PVE.mod.TempHelper', $HELPERCTORPARAMS);
890 | // display configuration
891 | const itemsPerRow = $CPU_ITEMS_PER_ROW;
892 | // ---
893 | let objValue;
894 | try {
895 | objValue = JSON.parse(value) || {};
896 | } catch(e) {
897 | objValue = {};
898 | }
899 | const cpuKeysI = Object.keys(objValue).filter(item => String(item).startsWith('coretemp-isa-')).sort();
900 | const cpuKeysA = Object.keys(objValue).filter(item => String(item).startsWith('k10temp-pci-')).sort();
901 | const bINTEL = cpuKeysI.length > 0 ? true : false;
902 | const INTELPackagePrefix = '$CPU_TEMP_TARGET' == 'Core' ? 'Core ' : 'Package id';
903 | const INTELPackageCaption = '$CPU_TEMP_TARGET' == 'Core' ? 'Core' : 'Package';
904 | let AMDPackagePrefix = 'Tccd';
905 | let AMDPackageCaption = 'CCD';
906 |
907 | if (cpuKeysA.length > 0) {
908 | let bTccd = false;
909 | let bTctl = false;
910 | let bTdie = false;
911 | let bCpuCoreTemp = false;
912 | cpuKeysA.forEach((cpuKey, cpuIndex) => {
913 | let items = objValue[cpuKey];
914 | bTccd = Object.keys(items).findIndex(item => { return String(item).startsWith('Tccd'); }) >= 0;
915 | bTctl = Object.keys(items).findIndex(item => { return String(item).startsWith('Tctl'); }) >= 0;
916 | bTdie = Object.keys(items).findIndex(item => { return String(item).startsWith('Tdie'); }) >= 0;
917 | bCpuCoreTemp = Object.keys(items).findIndex(item => { return String(item) === 'CPU Core Temp'; }) >= 0;
918 | });
919 | if (bTccd && '$CPU_TEMP_TARGET' == 'Core') {
920 | AMDPackagePrefix = 'Tccd';
921 | AMDPackageCaption = 'ccd';
922 | } else if (bCpuCoreTemp && '$CPU_TEMP_TARGET' == 'Package') {
923 | AMDPackagePrefix = 'CPU Core Temp';
924 | AMDPackageCaption = 'CPU Core Temp';
925 | } else if (bTdie) {
926 | AMDPackagePrefix = 'Tdie';
927 | AMDPackageCaption = 'die';
928 | } else if (bTctl) {
929 | AMDPackagePrefix = 'Tctl';
930 | AMDPackageCaption = 'ctl';
931 | } else {
932 | AMDPackagePrefix = 'temp';
933 | AMDPackageCaption = 'Temp';
934 | }
935 | }
936 |
937 | const cpuKeys = bINTEL ? cpuKeysI : cpuKeysA;
938 | const cpuItemPrefix = bINTEL ? INTELPackagePrefix : AMDPackagePrefix;
939 | const cpuTempCaption = bINTEL ? INTELPackageCaption : AMDPackageCaption;
940 | const formatTemp = bINTEL ? '0' : '0.0';
941 | const cpuCount = cpuKeys.length;
942 | let temps = [];
943 |
944 | cpuKeys.forEach((cpuKey, cpuIndex) => {
945 | let cpuTemps = [];
946 | const items = objValue[cpuKey];
947 | const itemKeys = Object.keys(items).filter(item => {
948 | if ('$CPU_TEMP_TARGET' == 'Core') {
949 | // In Core mode: only show individual cores/CCDs, exclude overall CPU temp
950 | return String(item).includes(cpuItemPrefix) || String(item).startsWith('Tccd');
951 | } else {
952 | // In Package mode: show overall CPU temp and package-level readings
953 | return String(item).includes(cpuItemPrefix) || String(item) === 'CPU Core Temp';
954 | }
955 | });
956 |
957 | itemKeys.forEach((coreKey) => {
958 | try {
959 | let tempVal = NaN, tempMax = NaN, tempCrit = NaN;
960 | Object.keys(items[coreKey]).forEach((secondLevelKey) => {
961 | if (secondLevelKey.endsWith('_input')) {
962 | tempVal = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey]));
963 | } else if (secondLevelKey.endsWith('_max')) {
964 | tempMax = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey]));
965 | } else if (secondLevelKey.endsWith('_crit')) {
966 | tempCrit = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey]));
967 | }
968 | });
969 |
970 | if (!isNaN(tempVal)) {
971 | let tempStyle = '';
972 | if (!isNaN(tempMax) && tempVal >= tempMax) {
973 | tempStyle = 'color: #FFC300; font-weight: bold;';
974 | }
975 | if (!isNaN(tempCrit) && tempVal >= tempCrit) {
976 | tempStyle = 'color: red; font-weight: bold;';
977 | }
978 |
979 | let tempStr = '';
980 |
981 | // Enhanced parsing for AMD temperatures
982 | if (coreKey.startsWith('Tccd')) {
983 | let tempIndex = coreKey.match(/Tccd(\d+)/);
984 | if (tempIndex !== null && tempIndex.length > 1) {
985 | tempIndex = tempIndex[1];
986 | tempStr = `${cpuTempCaption} ${tempIndex}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`;
987 | } else {
988 | tempStr = `${cpuTempCaption}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`;
989 | }
990 | }
991 | // Handle CPU Core Temp (single overall temperature)
992 | else if (coreKey === 'CPU Core Temp') {
993 | tempStr = `${cpuTempCaption}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`;
994 | }
995 | // Enhanced parsing for Intel cores (P-Core, E-Core, regular Core)
996 | else {
997 | let tempIndex = coreKey.match(/(?:P\s+Core|E\s+Core|Core)\s*(\d+)/);
998 | if (tempIndex !== null && tempIndex.length > 1) {
999 | tempIndex = tempIndex[1];
1000 | let coreType = coreKey.startsWith('P Core') ? 'P Core' :
1001 | coreKey.startsWith('E Core') ? 'E Core' :
1002 | cpuTempCaption;
1003 | tempStr = `${coreType} ${tempIndex}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`;
1004 | } else {
1005 | // fallback for CPUs which do not have a core index
1006 | let coreType = coreKey.startsWith('P Core') ? 'P Core' :
1007 | coreKey.startsWith('E Core') ? 'E Core' :
1008 | cpuTempCaption;
1009 | tempStr = `${coreType}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`;
1010 | }
1011 | }
1012 |
1013 | cpuTemps.push(tempStr);
1014 | }
1015 | } catch (e) { /*_*/ }
1016 | });
1017 |
1018 | if(cpuTemps.length > 0) {
1019 | temps.push(cpuTemps);
1020 | }
1021 | });
1022 |
1023 | let result = '';
1024 | temps.forEach((cpuTemps, cpuIndex) => {
1025 | const strCoreTemps = cpuTemps.map((strTemp, index, arr) => {
1026 | return strTemp + (index + 1 < arr.length ? (itemsPerRow > 0 && (index + 1) % itemsPerRow === 0 ? '
' : ' | ') : '');
1027 | })
1028 | if(strCoreTemps.length > 0) {
1029 | result += (cpuCount > 1 ? `CPU ${cpuIndex+1}: ` : '') + strCoreTemps.join('') + (cpuIndex < cpuCount ? '
' : '');
1030 | }
1031 | });
1032 |
1033 | return '' + (result.length > 0 ? result : 'N/A') + '
';
1034 | }
1035 | },
1036 | EOF
1037 | )
1038 | #endregion cpu widget heredoc
1039 | if [[ $? -ne 0 ]]; then
1040 | echo "Error: Failed to generate cpu widget code" >&2
1041 | exit 1
1042 | fi
1043 | }
1044 |
1045 | # Function to generate nvme widget
1046 | generate_nvme_widget() {
1047 | #region nvme widget heredoc
1048 | # use subshell to allow variable expansion
1049 | (
1050 | export HELPERCTORPARAMS
1051 | export NVME_ITEMS_PER_ROW
1052 | cat <<'EOF' | envsubst '$HELPERCTORPARAMS $NVME_ITEMS_PER_ROW' > "$1"
1053 | {
1054 | itemId: 'thermalNvme',
1055 | colspan: 2,
1056 | printBar: false,
1057 | title: gettext('NVMe Thermal State'),
1058 | iconCls: 'fa fa-fw fa-thermometer-half',
1059 | textField: 'sensorsOutput',
1060 | renderer: function(value) {
1061 | // sensors configuration
1062 | const addressPrefix = "nvme-pci-";
1063 | const sensorName = "Composite";
1064 | const tempHelper = Ext.create('PVE.mod.TempHelper', $HELPERCTORPARAMS);
1065 | // display configuration
1066 | const itemsPerRow = $NVME_ITEMS_PER_ROW;
1067 | // ---
1068 | let objValue;
1069 | try {
1070 | objValue = JSON.parse(value) || {};
1071 | } catch(e) {
1072 | objValue = {};
1073 | }
1074 | const nvmeKeys = Object.keys(objValue).filter(item => String(item).startsWith(addressPrefix)).sort();
1075 | let temps = [];
1076 | nvmeKeys.forEach((nvmeKey, index) => {
1077 | try {
1078 | let tempVal = NaN, tempMax = NaN, tempCrit = NaN;
1079 | Object.keys(objValue[nvmeKey][sensorName]).forEach((secondLevelKey) => {
1080 | if (secondLevelKey.endsWith('_input')) {
1081 | tempVal = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey]));
1082 | } else if (secondLevelKey.endsWith('_max')) {
1083 | tempMax = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey]));
1084 | } else if (secondLevelKey.endsWith('_crit')) {
1085 | tempCrit = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey]));
1086 | }
1087 | });
1088 | if (!isNaN(tempVal)) {
1089 | let tempStyle = '';
1090 | if (!isNaN(tempMax) && tempVal >= tempMax) {
1091 | tempStyle = 'color: #FFC300; font-weight: bold;';
1092 | }
1093 | if (!isNaN(tempCrit) && tempVal >= tempCrit) {
1094 | tempStyle = 'color: red; font-weight: bold;';
1095 | }
1096 | const tempStr = `Drive ${index + 1}: ${Ext.util.Format.number(tempVal, '0.0')}${tempHelper.getUnit()}`;
1097 | temps.push(tempStr);
1098 | }
1099 | } catch(e) { /*_*/ }
1100 | });
1101 | const result = temps.map((strTemp, index, arr) => { return strTemp + (index + 1 < arr.length ? ((index + 1) % itemsPerRow === 0 ? '
' : ' | ') : ''); });
1102 | return '' + (result.length > 0 ? result.join('') : 'N/A') + '
';
1103 | }
1104 | },
1105 | EOF
1106 | )
1107 | #endregion nvme widget heredoc
1108 | if [[ $? -ne 0 ]]; then
1109 | echo "Error: Failed to generate nvme widget code" >&2
1110 | exit 1
1111 | fi
1112 | }
1113 |
1114 | # Function to generate Fan widget
1115 | generate_fan_widget() {
1116 | #region fan widget heredoc
1117 | # use subshell to allow variable expansion
1118 | (
1119 | export DISPLAY_ZERO_SPEED_FANS
1120 | cat <<'EOF' | envsubst '$DISPLAY_ZERO_SPEED_FANS' > "$1"
1121 | {
1122 | xtype: 'box',
1123 | colspan: 2,
1124 | html: gettext('Cooling'),
1125 | },
1126 | {
1127 | itemId: 'speedFan',
1128 | colspan: 2,
1129 | printBar: false,
1130 | title: gettext('Fan Speed(s)'),
1131 | iconCls: 'fa fa-fw fa-snowflake-o',
1132 | textField: 'sensorsOutput',
1133 | renderer: function(value) {
1134 | // ---
1135 | let objValue;
1136 | try {
1137 | objValue = JSON.parse(value) || {};
1138 | } catch(e) {
1139 | objValue = {};
1140 | }
1141 |
1142 | // Recursive function to find fan keys and values
1143 | function findFanKeys(obj, fanKeys, parentKey = null) {
1144 | Object.keys(obj).forEach(key => {
1145 | const value = obj[key];
1146 | if (typeof value === 'object' && value !== null) {
1147 | // If the value is an object, recursively call the function
1148 | findFanKeys(value, fanKeys, key);
1149 | } else if (/^fan[0-9]+(_input)?$/.test(key)) {
1150 | if ($DISPLAY_ZERO_SPEED_FANS != true && value === 0) {
1151 | // Skip this fan if DISPLAY_ZERO_SPEED_FANS is false and value is 0
1152 | return;
1153 | }
1154 | // If the key matches the pattern, add the parent key and value to the fanKeys array
1155 | fanKeys.push({ key: parentKey, value: value });
1156 | }
1157 | });
1158 | }
1159 |
1160 | let speeds = [];
1161 | // Loop through the parent keys
1162 | Object.keys(objValue).forEach(parentKey => {
1163 | const parentObj = objValue[parentKey];
1164 | // Array to store fan keys and values
1165 | const fanKeys = [];
1166 | // Call the recursive function to find fan keys and values
1167 | findFanKeys(parentObj, fanKeys);
1168 | // Sort the fan keys
1169 | fanKeys.sort();
1170 | // Process each fan key and value
1171 | fanKeys.forEach(({ key: fanKey, value: fanSpeed }) => {
1172 | try {
1173 | const fan = fanKey.charAt(0).toUpperCase() + fanKey.slice(1); // Capitalize the first letter of fanKey
1174 | speeds.push(`${fan}: ${fanSpeed} RPM`);
1175 | } catch(e) {
1176 | console.error(`Error retrieving fan speed for ${fanKey} in ${parentKey}:`, e); // Debug: Log specific error
1177 | }
1178 | });
1179 | });
1180 | return '' + (speeds.length > 0 ? speeds.join(' | ') : 'N/A') + '
';
1181 | }
1182 | },
1183 | EOF
1184 | )
1185 | #endregion fan widget heredoc
1186 | if [[ $? -ne 0 ]]; then
1187 | echo "Error: Failed to generate fan widget code" >&2
1188 | exit 1
1189 | fi
1190 | }
1191 |
1192 | # Function to generate UPS widget
1193 | generate_hdd_widget() {
1194 | #region hdd widget heredoc
1195 | # use subshell to allow variable expansion
1196 | (
1197 | export HELPERCTORPARAMS
1198 | export HDD_ITEMS_PER_ROW
1199 | cat <<'EOF' | envsubst '$HDD_ITEMS_PER_ROW $HELPERCTORPARAMS' > "$1"
1200 | {
1201 | itemId: 'thermalHdd',
1202 | colspan: 2,
1203 | printBar: false,
1204 | title: gettext('HDD/SSD Thermal State'),
1205 | iconCls: 'fa fa-fw fa-thermometer-half',
1206 | textField: 'sensorsOutput',
1207 | renderer: function(value) {
1208 | // sensors configuration
1209 | const addressPrefix = "drivetemp-scsi-";
1210 | const sensorName = "temp1";
1211 | const tempHelper = Ext.create('PVE.mod.TempHelper', $HELPERCTORPARAMS);
1212 | // display configuration
1213 | const itemsPerRow = $HDD_ITEMS_PER_ROW;
1214 | // ---
1215 | let objValue;
1216 | try {
1217 | objValue = JSON.parse(value) || {};
1218 | } catch(e) {
1219 | objValue = {};
1220 | }
1221 | const drvKeys = Object.keys(objValue).filter(item => String(item).startsWith(addressPrefix)).sort();
1222 | let temps = [];
1223 | drvKeys.forEach((drvKey, index) => {
1224 | try {
1225 | let tempVal = NaN, tempMax = NaN, tempCrit = NaN;
1226 | Object.keys(objValue[drvKey][sensorName]).forEach((secondLevelKey) => {
1227 | if (secondLevelKey.endsWith('_input')) {
1228 | tempVal = tempHelper.getTemp(parseFloat(objValue[drvKey][sensorName][secondLevelKey]));
1229 | } else if (secondLevelKey.endsWith('_max')) {
1230 | tempMax = tempHelper.getTemp(parseFloat(objValue[drvKey][sensorName][secondLevelKey]));
1231 | } else if (secondLevelKey.endsWith('_crit')) {
1232 | tempCrit = tempHelper.getTemp(parseFloat(objValue[drvKey][sensorName][secondLevelKey]));
1233 | }
1234 | });
1235 | if (!isNaN(tempVal)) {
1236 | let tempStyle = '';
1237 | if (!isNaN(tempMax) && tempVal >= tempMax) {
1238 | tempStyle = 'color: #FFC300; font-weight: bold;';
1239 | }
1240 | if (!isNaN(tempCrit) && tempVal >= tempCrit) {
1241 | tempStyle = 'color: red; font-weight: bold;';
1242 | }
1243 | const tempStr = `Drive ${index + 1}: ${Ext.util.Format.number(tempVal, '0.0')}${tempHelper.getUnit()}`;
1244 | temps.push(tempStr);
1245 | }
1246 | } catch(e) { /*_*/ }
1247 | });
1248 | const result = temps.map((strTemp, index, arr) => { return strTemp + (index + 1 < arr.length ? ((index + 1) % itemsPerRow === 0 ? '
' : ' | ') : ''); });
1249 | return '' + (result.length > 0 ? result.join('') : 'N/A') + '
';
1250 | }
1251 | },
1252 | EOF
1253 | )
1254 | #endregion hdd widget heredoc
1255 | if [[ $? -ne 0 ]]; then
1256 | echo "Error: Failed to generate hhd widget code" >&2
1257 | exit 1
1258 | fi
1259 | }
1260 |
1261 | # Function to generate RAM widget
1262 | generate_ram_widget() {
1263 | #region ram widget heredoc
1264 | # use subshell to allow variable expansion
1265 | (
1266 | export HELPERCTORPARAMS
1267 | cat <<'EOF' | envsubst '$HELPERCTORPARAMS' > "$1"
1268 | {
1269 | xtype: 'box',
1270 | colspan: 2,
1271 | html: gettext('RAM'),
1272 | },
1273 | {
1274 | itemId: 'thermalRam',
1275 | colspan: 2,
1276 | printBar: false,
1277 | title: gettext('Thermal State'),
1278 | iconCls: 'fa fa-fw fa-thermometer-half',
1279 | textField: 'sensorsOutput',
1280 | renderer: function(value) {
1281 | const cpuTempHelper = Ext.create('PVE.mod.TempHelper', $HELPERCTORPARAMS);
1282 |
1283 | let objValue;
1284 | try {
1285 | objValue = JSON.parse(value) || {};
1286 | } catch(e) {
1287 | objValue = {};
1288 | }
1289 |
1290 | // Recursive function to find ram keys and values
1291 | function findRamKeys(obj, ramKeys, parentKey = null) {
1292 | Object.keys(obj).forEach(key => {
1293 | const value = obj[key];
1294 | if (typeof value === 'object' && value !== null) {
1295 | // If the value is an object, recursively call the function
1296 | findRamKeys(value, ramKeys, key);
1297 | } else if (/^temp\d+_input$/.test(key) && parentKey && parentKey.startsWith("SODIMM")) {
1298 | if (value !== 0) {
1299 | ramKeys.push({ key: parentKey, value: value});
1300 | }
1301 | }
1302 | });
1303 | }
1304 |
1305 | let ramTemps = [];
1306 | // Loop through the parent keys
1307 | Object.keys(objValue).forEach(parentKey => {
1308 | const parentObj = objValue[parentKey];
1309 | // Array to store ram keys and values
1310 | const ramKeys = [];
1311 | // Call the recursive function to find ram keys and values
1312 | findRamKeys(parentObj, ramKeys);
1313 | // Sort the ramKeys keys
1314 | ramKeys.sort();
1315 | // Process each ram key and value
1316 | ramKeys.forEach(({ key: ramKey, value: ramTemp }) => {
1317 | try {
1318 | ramTemps.push(`${ramKey}: ${ramTemp}${cpuTempHelper.getUnit()}`);
1319 | } catch(e) {
1320 | console.error(`Error retrieving Ram Temp for ${ramTemps} in ${parentKey}:`, e); // Debug: Log specific error
1321 | }
1322 | });
1323 | });
1324 | return '' + (ramTemps.length > 0 ? ramTemps.join(' | ') : 'N/A') + '
';
1325 | }
1326 | },
1327 | EOF
1328 | )
1329 | #endregion ram widget heredoc
1330 | if [[ $? -ne 0 ]]; then
1331 | echo "Error: Failed to generate ram widget code" >&2
1332 | exit 1
1333 | fi
1334 | }
1335 |
1336 | # Function to generate UPS widget
1337 | generate_ups_widget() {
1338 | #region UPS widget heredoc
1339 | cat > "$1" <<'EOF'
1340 | {
1341 | xtype: 'box',
1342 | colspan: 2,
1343 | html: gettext('UPS'),
1344 | },
1345 | {
1346 | itemId: 'upsc',
1347 | colspan: 2,
1348 | printBar: false,
1349 | title: gettext('Device'),
1350 | iconCls: 'fa fa-fw fa-battery-three-quarters',
1351 | textField: 'upsc',
1352 | renderer: function(value) {
1353 | let objValue = {};
1354 | try {
1355 | // Parse the UPS data
1356 | if (typeof value === 'string') {
1357 | const lines = value.split('\n');
1358 | lines.forEach(line => {
1359 | const colonIndex = line.indexOf(':');
1360 | if (colonIndex > 0) {
1361 | const key = line.substring(0, colonIndex).trim();
1362 | const val = line.substring(colonIndex + 1).trim();
1363 | objValue[key] = val;
1364 | }
1365 | });
1366 | } else if (typeof value === 'object') {
1367 | objValue = value || {};
1368 | }
1369 | } catch(e) {
1370 | objValue = {};
1371 | }
1372 |
1373 | // If objValue is null or empty, return N/A
1374 | if (!objValue || Object.keys(objValue).length === 0) {
1375 | return 'N/A
';
1376 | }
1377 |
1378 | // Helper function to get status color
1379 | // Returns a CSS color string for non-default states, or null for default (no inline color)
1380 | function getStatusColor(status) {
1381 | if (!status) return '#999';
1382 | const statusUpper = status.toUpperCase();
1383 | if (statusUpper.includes('OL')) return null; // default (no explicit color)
1384 | if (statusUpper.includes('OB')) return '#d9534f'; // Red for on battery
1385 | if (statusUpper.includes('LB')) return '#d9534f'; // Red for low battery
1386 | return '#f0ad4e'; // Orange for other states
1387 | }
1388 |
1389 | // Helper function to get load/charge color
1390 | // Returns null for default/good values so no inline style is emitted
1391 | function getPercentageColor(value, isLoad = false) {
1392 | if (!value || isNaN(value)) return '#999';
1393 | const num = parseFloat(value);
1394 | if (isLoad) {
1395 | if (num >= 80) return '#d9534f'; // Red for high load
1396 | if (num >= 60) return '#f0ad4e'; // Orange for medium load
1397 | return null; // default (no explicit color)
1398 | } else {
1399 | // For battery charge
1400 | if (num <= 20) return '#d9534f'; // Red for low charge
1401 | if (num <= 50) return '#f0ad4e'; // Orange for medium charge
1402 | return null; // default (no explicit color)
1403 | }
1404 | }
1405 |
1406 | // Helper function to format runtime
1407 | function formatRuntime(seconds) {
1408 | if (!seconds || isNaN(seconds)) return 'N/A';
1409 | const mins = Math.floor(seconds / 60);
1410 | const secs = seconds % 60;
1411 | return `${mins}m ${secs}s`;
1412 | }
1413 |
1414 | // Extract key UPS information
1415 | const batteryCharge = objValue['battery.charge'];
1416 | const batteryRuntime = objValue['battery.runtime'];
1417 | const inputVoltage = objValue['input.voltage'];
1418 | const upsLoad = objValue['ups.load'];
1419 | const upsStatus = objValue['ups.status'];
1420 | const upsModel = objValue['ups.model'] || objValue['device.model'];
1421 | const testResult = objValue['ups.test.result'];
1422 | const batteryChargeLow = objValue['battery.charge.low'];
1423 | const batteryRuntimeLow = objValue['battery.runtime.low'];
1424 | const upsRealPowerNominal = objValue['ups.realpower.nominal'];
1425 | const batteryMfrDate = objValue['battery.mfr.date'];
1426 |
1427 | // Build the status display
1428 | let displayItems = [];
1429 |
1430 | // First line: Model info (no explicit color for default)
1431 | let modelLine = '';
1432 | if (upsModel) {
1433 | modelLine = `${upsModel}`;
1434 | } else {
1435 | modelLine = `N/A`;
1436 | }
1437 | displayItems.push(modelLine);
1438 |
1439 | // Main status line with all metrics
1440 | let statusLine = '';
1441 |
1442 | // Status
1443 | if (upsStatus) {
1444 | const statusUpper = upsStatus.toUpperCase();
1445 | let statusText = 'Unknown';
1446 | let statusColor = '#f0ad4e';
1447 |
1448 | if (statusUpper.includes('OL')) {
1449 | statusText = 'Online';
1450 | statusColor = null; // default (no explicit color)
1451 | } else if (statusUpper.includes('OB')) {
1452 | statusText = 'On Battery';
1453 | statusColor = '#d9534f'; // Red for on battery
1454 | } else if (statusUpper.includes('LB')) {
1455 | statusText = 'Low Battery';
1456 | statusColor = '#d9534f'; // Red for low battery
1457 | } else {
1458 | statusText = upsStatus;
1459 | statusColor = '#f0ad4e'; // Orange for unknown status
1460 | }
1461 |
1462 | let statusStyle = statusColor ? ('color: ' + statusColor + ';') : '';
1463 | statusLine += 'Status: ' + statusText + '';
1464 | } else {
1465 | statusLine += 'Status: N/A';
1466 | }
1467 |
1468 | // Battery charge
1469 | if (statusLine) statusLine += ' | ';
1470 | if (batteryCharge) {
1471 | const chargeColor = getPercentageColor(batteryCharge, false);
1472 | let chargeStyle = chargeColor ? ('color: ' + chargeColor + ';') : '';
1473 | statusLine += 'Battery: ' + batteryCharge + '%';
1474 | } else {
1475 | statusLine += 'Battery: N/A';
1476 | }
1477 |
1478 | // Load percentage
1479 | if (statusLine) statusLine += ' | ';
1480 | if (upsLoad) {
1481 | const loadColor = getPercentageColor(upsLoad, true);
1482 | let loadStyle = loadColor ? ('color: ' + loadColor + ';') : '';
1483 | statusLine += 'Load: ' + upsLoad + '%';
1484 | } else {
1485 | statusLine += 'Load: N/A';
1486 | }
1487 |
1488 | // Runtime
1489 | if (statusLine) statusLine += ' | ';
1490 | if (batteryRuntime) {
1491 | const runtime = parseInt(batteryRuntime);
1492 | const runtimeLowThreshold = batteryRuntimeLow ? parseInt(batteryRuntimeLow) : 600;
1493 | let runtimeColor = null;
1494 | if (runtime <= runtimeLowThreshold / 2) runtimeColor = '#d9534f'; // Red if less than half of low threshold
1495 | else if (runtime <= runtimeLowThreshold) runtimeColor = '#f0ad4e'; // Orange if at low threshold
1496 | let runtimeStyle = runtimeColor ? ('color: ' + runtimeColor + ';') : '';
1497 | statusLine += 'Runtime: ' + formatRuntime(runtime) + '';
1498 | } else {
1499 | statusLine += 'Runtime: N/A';
1500 | }
1501 |
1502 | // Input voltage
1503 | if (statusLine) statusLine += ' | ';
1504 | if (inputVoltage) {
1505 | statusLine += 'Input: ' + parseFloat(inputVoltage).toFixed(0) + 'V';
1506 | } else {
1507 | statusLine += 'Input: N/A';
1508 | }
1509 |
1510 | // Calculate actual watt usage
1511 | if (statusLine) statusLine += ' | ';
1512 | let actualWattage = null;
1513 | if (upsLoad && upsRealPowerNominal) {
1514 | const load = parseFloat(upsLoad);
1515 | const nominal = parseFloat(upsRealPowerNominal);
1516 | if (!isNaN(load) && !isNaN(nominal)) {
1517 | actualWattage = Math.round((load / 100) * nominal);
1518 | }
1519 | }
1520 |
1521 | // Real power (calculated watt usage)
1522 | if (actualWattage !== null) {
1523 | statusLine += 'Output: ' + actualWattage + 'W';
1524 | } else {
1525 | statusLine += 'Output: N/A';
1526 | }
1527 |
1528 | displayItems.push(statusLine);
1529 |
1530 | // Combined battery and test line
1531 | let batteryTestLine = '';
1532 | if (batteryMfrDate) {
1533 | batteryTestLine += 'Battery MFD: ' + batteryMfrDate + '';
1534 | } else {
1535 | batteryTestLine += 'Battery MFD: N/A';
1536 | }
1537 |
1538 | if (testResult && !testResult.toLowerCase().includes('no test')) {
1539 | const testColor = testResult.toLowerCase().includes('passed') ? null : '#d9534f';
1540 | let testStyle = testColor ? ('color: ' + testColor + ';') : '';
1541 | batteryTestLine += ' | Test: ' + testResult + '';
1542 | } else {
1543 | batteryTestLine += ' | Test: N/A';
1544 | }
1545 |
1546 | displayItems.push(batteryTestLine);
1547 |
1548 | // Format the final output
1549 | return '' + displayItems.join('
') + '
';
1550 | }
1551 | },
1552 | EOF
1553 | #endregion UPS widget heredoc
1554 | if [[ $? -ne 0 ]]; then
1555 | echo "Error: Failed to generate UPS widget code" >&2
1556 | exit 1
1557 | fi
1558 | }
1559 |
1560 | #endregion widget generation functions
1561 |
1562 | # Function to uninstall the modification
1563 | function uninstall_mod {
1564 | msgb "=== Uninstalling Mod ==="
1565 |
1566 | check_root_privileges
1567 |
1568 | if [[ -z $(grep -e "$res->{sensorsOutput}" "$NODES_PM_FILE") ]] && [[ -z $(grep -e "$res->{systemInfo}" "$NODES_PM_FILE") ]]; then
1569 | err "Mod is not installed."
1570 | fi
1571 |
1572 | set_backup_directory
1573 | info "Restoring modified files..."
1574 |
1575 | # Find the latest Nodes.pm file using the find command
1576 | local latest_nodes_pm=$(find "$BACKUP_DIR" -name "Nodes.pm.*" -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n 1 | awk '{print $2}')
1577 |
1578 | if [ -n "$latest_nodes_pm" ]; then
1579 | # Remove the latest Nodes.pm file
1580 | msgb "Restoring latest Nodes.pm from backup: $latest_nodes_pm to \"$NODES_PM_FILE\"."
1581 | cp "$latest_nodes_pm" "$NODES_PM_FILE"
1582 | info "Restored Nodes.pm successfully."
1583 | else
1584 | warn "No Nodes.pm backup files found."
1585 | fi
1586 |
1587 | # Find the latest pvemanagerlib.js file using the find command
1588 | local latest_pvemanagerlibjs=$(find "$BACKUP_DIR" -name "pvemanagerlib.js.*" -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n 1 | awk '{print $2}')
1589 |
1590 | if [ -n "$latest_pvemanagerlibjs" ]; then
1591 | # Remove the latest pvemanagerlib.js file
1592 | msgb "Restoring latest pvemanagerlib.js from backup: $latest_pvemanagerlibjs to \"$PVE_MANAGER_LIB_JS_FILE\"."
1593 | cp "$latest_pvemanagerlibjs" "$PVE_MANAGER_LIB_JS_FILE"
1594 | info "Restored pvemanagerlib.js successfully."
1595 | else
1596 | warn "No pvemanagerlib.js backup files found."
1597 | fi
1598 |
1599 | if [ -n "$latest_nodes_pm" ] || [ -n "$latest_pvemanagerlibjs" ]; then
1600 | # At least one of the variables is not empty, restart the proxy
1601 | restart_proxy
1602 | fi
1603 |
1604 | ask "Clear the browser cache to ensure all changes are visualized. (any key to continue)"
1605 | }
1606 |
1607 | # Function to check if the modification is installed
1608 | check_mod_installation() {
1609 | if [[ -n $(grep -F '$res->{sensorsOutput}' "$NODES_PM_FILE") ]] && \
1610 | [[ -n $(grep -F '$res->{systemInfo}' "$NODES_PM_FILE") ]] && \
1611 | [[ -n $(grep -E "itemId: 'thermal[[:alnum:]]*'" "$PVE_MANAGER_LIB_JS_FILE") ]]; then
1612 | err "Mod is already installed. Uninstall existing before installing."
1613 | fi
1614 | }
1615 |
1616 | function restart_proxy {
1617 | # Restart pveproxy
1618 | info "Restarting PVE proxy..."
1619 | systemctl restart pveproxy
1620 | }
1621 |
1622 | function save_sensors_data {
1623 | msgb "=== Saving Sensors Data ==="
1624 |
1625 | # Check if JSON_EXPORT_DIRECTORY exists and is writable
1626 | if [[ ! -d "$JSON_EXPORT_DIRECTORY" || ! -w "$JSON_EXPORT_DIRECTORY" ]]; then
1627 | err "Directory $JSON_EXPORT_DIRECTORY does not exist or is not writable. No file could be saved."
1628 | return
1629 | fi
1630 |
1631 |
1632 | # Check if command exists
1633 | if (command -v sensors &>/dev/null); then
1634 | # Save sensors output
1635 | local debug_save_filename="sensorsdata.json"
1636 | local filepath="${JSON_EXPORT_DIRECTORY}/${debug_save_filename}"
1637 | msgb "Sensors data will be saved in $filepath"
1638 |
1639 | # Prompt user for confirmation
1640 | local choiceContinue=$(ask "Do you wish to continue? (Y/n)")
1641 | case "$choiceContinue" in
1642 | [yY]|"")
1643 | echo "lm-sensors raw output:" >"$filepath"
1644 | sensorsOutput=$(sensors -j 2>/dev/null)
1645 | echo "$sensorsOutput" >>"$filepath"
1646 | echo -e "\n\nSanitised lm-sensors output:" >>"$filepath"
1647 | # Apply lm-sensors sanitization
1648 | sanitisedSensorsOutput=$(sanitize_sensors_output "$sensorsOutput")
1649 | echo "$sanitisedSensorsOutput" >>"$filepath"
1650 | info "Sensors data saved in $filepath."
1651 | ;;
1652 | *)
1653 | warn "Operation cancelled by user."
1654 | ;;
1655 | esac
1656 | else
1657 | err "Sensors is not installed. No file could be saved."
1658 | fi
1659 | echo
1660 | }
1661 |
1662 | function set_backup_directory {
1663 | # Check if the BACKUP_DIR variable is set, if not, use the default backup
1664 | if [[ -z "$BACKUP_DIR" ]]; then
1665 | # If not set, use the default backup directory, which is based on the home directory and PVE-MODS
1666 | BACKUP_DIR="$HOME/PVE-MODS"
1667 | info "Using default backup directory: $BACKUP_DIR"
1668 | else
1669 | # If set, ensure it is a valid directory
1670 | if [[ ! -d "$BACKUP_DIR" ]]; then
1671 | err "The specified backup directory does not exist: $BACKUP_DIR"
1672 | fi
1673 | info "Using custom backup directory: $BACKUP_DIR"
1674 | fi
1675 | }
1676 |
1677 | function create_backup_directory {
1678 | set_backup_directory
1679 |
1680 | # Create the backup directory if it does not exist
1681 | if [[ ! -d "$BACKUP_DIR" ]]; then
1682 | mkdir -p "$BACKUP_DIR" 2>/dev/null || {
1683 | err "Failed to create backup directory: $BACKUP_DIR. Please check permissions."
1684 | }
1685 | info "Created backup directory: $BACKUP_DIR"
1686 | else
1687 | info "Backup directory already exists: $BACKUP_DIR"
1688 | fi
1689 | }
1690 |
1691 | function create_file_backup() {
1692 | local source_file="$1"
1693 | local timestamp="$2"
1694 | local filename
1695 |
1696 | filename=$(basename "$source_file")
1697 | local backup_file="$BACKUP_DIR/${filename}.$timestamp"
1698 |
1699 | [[ -f "$source_file" ]] || err "Source file does not exist: $source_file"
1700 | [[ -r "$source_file" ]] || err "Cannot read source file: $source_file"
1701 |
1702 | cp "$source_file" "$backup_file" || err "Failed to create backup: $backup_file"
1703 |
1704 | # Verify backup integrity
1705 | if ! cmp -s "$source_file" "$backup_file"; then
1706 | err "Backup verification failed for: $backup_file"
1707 | fi
1708 |
1709 | info "Created backup: $backup_file"
1710 | }
1711 |
1712 | function perform_backup {
1713 | local timestamp
1714 | timestamp=$(date +%Y%m%d_%H%M%S)
1715 |
1716 | msgb "\n=== Creating backups of modified files ==="
1717 |
1718 | create_backup_directory
1719 | create_file_backup "$NODES_PM_FILE" "$timestamp"
1720 | create_file_backup "$PVE_MANAGER_LIB_JS_FILE" "$timestamp"
1721 | }
1722 |
1723 | # Process the arguments using a while loop and a case statement
1724 | executed=0
1725 | while [[ $# -gt 0 ]]; do
1726 | case "$1" in
1727 | install)
1728 | executed=$(($executed + 1))
1729 | msgb "\nInstalling the Proxmox VE sensors display mod..."
1730 | install_packages
1731 | install_mod
1732 | ;;
1733 | uninstall)
1734 | executed=$(($executed + 1))
1735 | msgb "\nUninstalling the Proxmox VE sensors display mod..."
1736 | uninstall_mod
1737 | ;;
1738 | save-sensors-data)
1739 | executed=$(($executed + 1))
1740 | msgb "\nSaving current sensor readings in a file for debugging..."
1741 | save_sensors_data
1742 | ;;
1743 | esac
1744 | shift
1745 | done
1746 |
1747 | # If no arguments were provided or all arguments have been processed, print the usage message
1748 | if [[ $executed -eq 0 ]]; then
1749 | usage
1750 | fi
1751 |
--------------------------------------------------------------------------------