585 Commits

Author SHA1 Message Date
MacRimi 85860cdddd Merge pull request #187 from MacRimi/develop
Create CHANGELOG.md
2026-04-21 21:23:08 +02:00
MacRimi da2e89bc94 Create CHANGELOG.md 2026-04-21 21:22:34 +02:00
MacRimi 01ed2da10e Merge pull request #186 from MacRimi/develop
Develop
2026-04-21 21:22:13 +02:00
MacRimi 5fea839e34 Delete CHANGELOG.md 2026-04-21 21:22:01 +02:00
MacRimi d725910e7a Delete CHANGELOG.md 2026-04-21 21:20:37 +02:00
MacRimi 99f73ad745 Fix image link in CHANGELOG for SR-IOV state
Updated image link for SR-IOV active state in the Monitor UI.
2026-04-21 21:19:16 +02:00
MacRimi b599a990f6 Rename riov-indicator.png to sriov-indicator.png 2026-04-21 21:17:33 +02:00
MacRimi c742393efc Update CHANGELOG.md 2026-04-21 21:15:31 +02:00
MacRimi c403300cd2 Merge pull request #185 from MacRimi/develop
new version v1.2.1
2026-04-21 21:14:12 +02:00
MacRimi c3a5d6201e Delete AppImage/ProxMenux-Monitor.AppImage.sha256 2026-04-21 21:12:02 +02:00
MacRimi 9e7350c3bb Delete AppImage/ProxMenux-1.2.0.AppImage 2026-04-21 21:09:21 +02:00
MacRimi 5bee471884 Update version.txt 2026-04-21 21:07:15 +02:00
MacRimi 77eb8c7b78 update pci_passthrough_helpers.sh 2026-04-21 21:06:22 +02:00
ProxMenuxBot acf2302755 Update helpers_cache.json 2026-04-21 18:20:49 +00:00
ProxMenuxBot 8fedd3defe Update helpers_cache.json 2026-04-21 12:19:33 +00:00
ProxMenuxBot adda8181a6 Update helpers_cache.json 2026-04-21 06:35:35 +00:00
ProxMenuxBot 789494cc89 Update helpers_cache.json 2026-04-21 00:23:27 +00:00
ProxMenuxBot 561086e940 Update helpers_cache.json 2026-04-19 18:10:57 +00:00
MacRimi 20c1140676 Create build-appimage-manual.yml 2026-04-19 19:33:58 +02:00
github-actions[bot] 9bf99c0fdd Update AppImage beta build (2026-04-19 11:49:17) 2026-04-19 11:49:17 +00:00
MacRimi 899eb61dcf update verified_ai_models.json 2026-04-19 13:47:12 +02:00
github-actions[bot] a5e6e112a5 Update AppImage beta build (2026-04-19 10:29:06) 2026-04-19 10:29:06 +00:00
MacRimi 834795d6d9 update gpu-switch-mode-indicator.tsx 2026-04-19 12:26:52 +02:00
ProxMenuxBot 8442cbca77 Update helpers_cache.json 2026-04-19 00:21:12 +00:00
github-actions[bot] bcca760403 Update AppImage beta build (2026-04-19 00:10:21) 2026-04-19 00:10:21 +00:00
github-actions[bot] 102a58a068 Update AppImage release build (2026-04-19 00:07:37) 2026-04-19 00:07:37 +00:00
MacRimi 4e849d5309 Merge pull request #178 from MacRimi/develop
update notification_events.py
2026-04-19 02:03:53 +02:00
MacRimi 44e92c8bf0 Delete AppImage/ProxMenux-Monitor.AppImage.sha256 2026-04-19 02:03:35 +02:00
MacRimi 873f5ae51e Delete AppImage/ProxMenux-1.2.0.AppImage 2026-04-19 02:03:24 +02:00
MacRimi 3e0b907138 update notification_events.py 2026-04-19 02:01:04 +02:00
MacRimi 7ee6b6a96b Merge pull request #177 from MacRimi/develop
Update nvidia_installer.sh
2026-04-18 19:21:21 +02:00
MacRimi 14adb673f6 Update nvidia_installer.sh 2026-04-18 19:20:36 +02:00
MacRimi 91381f0850 Merge pull request #176 from MacRimi/develop
Develop
2026-04-18 19:01:18 +02:00
MacRimi d3e91b5d06 Update menu 2026-04-18 19:00:08 +02:00
MacRimi 74fcd7d569 Update beta_version.txt 2026-04-18 18:53:31 +02:00
MacRimi 0843cd8363 Create beta_version.txt 2026-04-18 18:52:07 +02:00
MacRimi 8c3d022506 Merge pull request #175 from MacRimi/develop
Develop
2026-04-18 18:51:06 +02:00
MacRimi a5a55f3c7d Delete beta_version.txt 2026-04-18 18:50:29 +02:00
MacRimi 2fb9e74a13 Update menu 2026-04-18 18:48:33 +02:00
MacRimi f950882ffd Update beta_version.txt 2026-04-18 18:20:57 +02:00
MacRimi 18b3b572f0 Delete beta_version.txt 2026-04-18 18:16:31 +02:00
MacRimi 023e3ff59b Merge pull request #174 from MacRimi/develop
Create beta_version.txt
2026-04-18 18:02:59 +02:00
MacRimi 7318c81fe0 Create beta_version.txt 2026-04-18 18:01:40 +02:00
MacRimi f516a1cf4c Merge pull request #173 from MacRimi/develop
Develop
2026-04-18 17:58:51 +02:00
MacRimi 43959fc758 Delete beta_version.txt 2026-04-18 17:58:02 +02:00
MacRimi ff7b1e10a4 Create version.txt 2026-04-18 17:55:35 +02:00
MacRimi b049712cd6 Update CHANGELOG for ProxMenux v1.2.0 release
Document the release of ProxMenux v1.2.0 with AI enhancements and performance improvements.
2026-04-18 14:21:20 +02:00
MacRimi 2cdfc60fe1 Update CHANGELOG for ProxMenux v1.2.0 release
This release introduces AI-enhanced notifications, a redesigned multi-channel notification system, and performance improvements. It consolidates recent work on Storage, Hardware, and GPU/TPU scripts.
2026-04-18 14:20:31 +02:00
MacRimi 2739cb0894 Revise CHANGELOG for version v1.1.9
Updated changelog for version v1.1.9 with new entries.
2026-04-18 14:15:15 +02:00
MacRimi 3e5ef4fa08 Fix menu option display height in hw_grafics_menu.sh 2026-04-18 09:15:37 +02:00
MacRimi a7a010d660 Fix spinner stop call and add newline at end of file 2026-04-18 09:14:30 +02:00
MacRimi 67000f5ff1 Update nvidia_installer.sh 2026-04-18 09:13:52 +02:00
MacRimi efa111e2dd Refactor message building in menu_Helper_Scripts.sh 2026-04-18 09:06:01 +02:00
MacRimi 813798ec2b Add message for completed NVIDIA uninstallation
Add confirmation message after NVIDIA uninstallation steps.
2026-04-18 09:05:18 +02:00
MacRimi c8b1cd0fab Update nvidia_installer.sh 2026-04-18 08:58:50 +02:00
MacRimi 9220dfb7a3 Update version.txt 2026-04-18 01:24:52 +02:00
MacRimi f6e9497f1e Delete version.txt 2026-04-18 01:23:47 +02:00
MacRimi 45e7713638 Update menu 2026-04-18 01:21:11 +02:00
MacRimi 802dc491f8 Update menu 2026-04-18 01:20:39 +02:00
MacRimi 3ca5a36240 Update config_menu.sh 2026-04-18 01:13:13 +02:00
MacRimi 3046299414 Update hw_grafics_menu.sh 2026-04-18 01:03:16 +02:00
MacRimi 46edd4e3e4 Remove safe self-update and beta check functions 2026-04-18 00:59:54 +02:00
MacRimi 800a18ac60 Eliminate syntax check for installed launcher
Removed defensive checks for launcher script syntax during installation.
2026-04-18 00:59:23 +02:00
MacRimi 37c60cb82a Upate menu 2026-04-18 00:58:25 +02:00
MacRimi 9446112081 Update beta_version.txt 2026-04-18 00:38:32 +02:00
MacRimi 07384e4d7c Implement safe self-update mechanism
Add safe self-update functionality to prevent launcher corruption.
2026-04-18 00:36:56 +02:00
MacRimi 1f7bf74970 Add syntax check for installed launcher script
Added defensive checks to ensure the installed launcher script passes syntax validation before proceeding with installation.
2026-04-18 00:36:30 +02:00
MacRimi 4b72490486 update menu 2026-04-18 00:33:40 +02:00
MacRimi 9e4e0bc24a Update version.txt 2026-04-18 00:11:49 +02:00
MacRimi 18687666a6 Update README with security note on VirusTotal
Added a security note regarding VirusTotal false positives.
2026-04-18 00:08:58 +02:00
MacRimi ccb924f8f6 Bump version from 1.1.9 to 1.2.0 2026-04-18 00:03:40 +02:00
MacRimi 58ac4ee743 Update CHANGELOG to remove UI flow consistency details
Removed UI flow consistency section from CHANGELOG.
2026-04-18 00:02:25 +02:00
MacRimi d515448113 Release v1.2.0 with AI enhancements and optimizations
This release introduces AI-enhanced notifications, a redesigned multi-channel notification system, expanded hardware detection, and significant performance optimizations.
2026-04-18 00:00:52 +02:00
github-actions[bot] ec3de30b14 Update AppImage release build (2026-04-17 22:00:49) 2026-04-17 22:00:49 +00:00
MacRimi 09ff203662 Update README with security note on VirusTotal
Added a security note regarding VirusTotal false positives for the installation script.
2026-04-17 23:58:31 +02:00
MacRimi 4837b66089 Update README.md 2026-04-17 23:57:15 +02:00
MacRimi c312cf3d38 Delete beta_version.txt 2026-04-17 23:56:38 +02:00
MacRimi 46f5af1966 Delete install_proxmenux_beta.sh 2026-04-17 23:56:25 +02:00
MacRimi 85f98dff9a Delete AppImage/ProxMenux-Monitor.AppImage.sha256 2026-04-17 23:54:41 +02:00
MacRimi bd56964860 Delete AppImage/ProxMenux-1.2.0.AppImage 2026-04-17 23:54:30 +02:00
MacRimi 481a653b75 Delete AppImage/ProxMenux-1.0.2.AppImage 2026-04-17 23:54:20 +02:00
MacRimi f94b59954f Merge pull request #169 from MacRimi/develop
ProxMenux v1.2.0
2026-04-17 23:51:51 +02:00
MacRimi 4f7977b5ca Merge branch 'main' into develop 2026-04-17 23:49:29 +02:00
MacRimi 512cc11894 Delete AppImage/ProxMenux-1.0.1.AppImage 2026-04-17 23:39:24 +02:00
github-actions[bot] 51be0bd3bd Update AppImage release build (2026-04-17 21:38:53) 2026-04-17 21:38:53 +00:00
MacRimi 0f6095f8c3 Create ProxMenux_ai.png 2026-04-17 23:28:16 +02:00
MacRimi 831bf67ee4 update install 2026-04-17 22:26:25 +02:00
MacRimi e7cca5e532 Delete AppImage/ProxMenux-1.0.2-beta.AppImage 2026-04-17 22:02:18 +02:00
MacRimi 75b08de934 update hw_grafics_menu.sh 2026-04-17 21:55:53 +02:00
github-actions[bot] 987b665115 Update AppImage beta build (2026-04-17 18:58:06) 2026-04-17 18:58:06 +00:00
MacRimi 68ca68c6f1 update hardware.tsx 2026-04-17 20:56:07 +02:00
ProxMenuxBot 5c4ca290fb Update helpers_cache.json 2026-04-17 18:16:35 +00:00
github-actions[bot] 1d61442a49 Update AppImage beta build (2026-04-17 18:03:26) 2026-04-17 18:03:26 +00:00
MacRimi 0e2ede5e66 update v1.2.0 2026-04-17 20:01:30 +02:00
github-actions[bot] 0db74814be Update AppImage beta build (2026-04-17 17:56:07) 2026-04-17 17:56:07 +00:00
MacRimi 75c6f74fc4 update nstall_coral_pve9.sh 2026-04-17 19:53:17 +02:00
MacRimi e6faec24fa update nvidia_installer.sh 2026-04-17 19:02:46 +02:00
MacRimi 35b7d01d7e update nvidia_installer.sh 2026-04-17 18:35:42 +02:00
MacRimi 415bc439bb Update nvidia_update.sh 2026-04-17 18:24:07 +02:00
MacRimi fe47522275 Update nvidia_update.sh 2026-04-17 18:07:43 +02:00
MacRimi 24b97831a4 Update nvidia_update.sh 2026-04-17 18:06:21 +02:00
github-actions[bot] ac95a5afba Update AppImage beta build (2026-04-17 15:39:07) 2026-04-17 15:39:07 +00:00
MacRimi 03850d2958 update virtual-machines.tsx 2026-04-17 17:36:57 +02:00
github-actions[bot] c7b49cfc4a Update AppImage beta build (2026-04-17 15:03:32) 2026-04-17 15:03:32 +00:00
MacRimi 5398211ab5 update virtual-machines.tsx 2026-04-17 17:01:24 +02:00
github-actions[bot] dc8ebb651a Update AppImage beta build (2026-04-17 14:41:07) 2026-04-17 14:41:07 +00:00
MacRimi 039e35f3c5 update health_monitor.py 2026-04-17 16:39:08 +02:00
github-actions[bot] ffadb2c508 Update AppImage beta build (2026-04-17 13:42:17) 2026-04-17 13:42:17 +00:00
MacRimi 019e98e6b6 update flask_proxmenux_routes.py 2026-04-17 15:33:50 +02:00
github-actions[bot] c998e39038 Update AppImage beta build (2026-04-17 08:42:52) 2026-04-17 08:42:52 +00:00
MacRimi baa2ff4fa9 update health_persistence.py 2026-04-17 10:38:39 +02:00
github-actions[bot] 4b6a91e74c Update AppImage beta build (2026-04-16 18:54:07) 2026-04-16 18:54:07 +00:00
MacRimi 37f56c8a16 Update settings.tsx 2026-04-16 20:51:52 +02:00
github-actions[bot] 09bb47f408 Update AppImage beta build (2026-04-16 18:42:29) 2026-04-16 18:42:29 +00:00
MacRimi 5db6762690 upddate flask_proxmenux_routes.py 2026-04-16 20:40:34 +02:00
github-actions[bot] b1cc880253 Update AppImage beta build (2026-04-16 18:15:34) 2026-04-16 18:15:34 +00:00
MacRimi 7d4ea806a2 Update flask_proxmenux_routes.py 2026-04-16 20:13:13 +02:00
github-actions[bot] 2b306c9033 Update AppImage beta build (2026-04-16 18:05:44) 2026-04-16 18:05:44 +00:00
MacRimi a776d6b746 Update health_persistence.py 2026-04-16 20:01:56 +02:00
github-actions[bot] 07f87de742 Update AppImage beta build (2026-04-16 17:36:53) 2026-04-16 17:36:53 +00:00
MacRimi cf871da880 update health_persistence.py 2026-04-16 19:33:47 +02:00
github-actions[bot] 774d42d5be Update AppImage beta build (2026-04-16 17:20:45) 2026-04-16 17:20:46 +00:00
MacRimi 6660122e69 Update health_persistence.py 2026-04-16 19:18:42 +02:00
github-actions[bot] 1ef4bc4fed Update AppImage beta build (2026-04-16 17:13:00) 2026-04-16 17:13:00 +00:00
MacRimi ee1204c566 update health_monitor.py 2026-04-16 19:10:47 +02:00
github-actions[bot] 7f2b0c5de1 Update AppImage beta build (2026-04-16 16:12:35) 2026-04-16 16:12:35 +00:00
MacRimi 9737ffd996 Update storage-overview.tsx 2026-04-16 18:10:27 +02:00
github-actions[bot] be60b7e17c Update AppImage beta build (2026-04-16 16:02:46) 2026-04-16 16:02:46 +00:00
MacRimi 97368a6f44 update storage-overview.tsx 2026-04-16 17:58:27 +02:00
github-actions[bot] c3d7f01b40 Update AppImage beta build (2026-04-16 15:41:42) 2026-04-16 15:41:42 +00:00
MacRimi cbebd5147c update storage-overview.tsx 2026-04-16 17:36:23 +02:00
github-actions[bot] 4611be734f Update AppImage beta build (2026-04-16 15:00:04) 2026-04-16 15:00:04 +00:00
MacRimi 528b57664f Update storage-overview.tsx 2026-04-16 16:57:40 +02:00
github-actions[bot] cc86d68507 Update AppImage beta build (2026-04-16 14:44:10) 2026-04-16 14:44:10 +00:00
MacRimi 7c8da462db update storage-overview.tsx 2026-04-16 16:42:11 +02:00
github-actions[bot] b7963c3b70 Update AppImage beta build (2026-04-16 14:11:03) 2026-04-16 14:11:03 +00:00
MacRimi d51dd35376 Update storage-overview.tsx 2026-04-16 16:08:58 +02:00
github-actions[bot] 196086498e Update AppImage beta build (2026-04-16 13:54:35) 2026-04-16 13:54:35 +00:00
MacRimi f6ff76f9ce Update storage-overview.tsx 2026-04-16 15:52:26 +02:00
github-actions[bot] 20a2db6739 Update AppImage beta build (2026-04-16 13:38:55) 2026-04-16 13:38:55 +00:00
MacRimi aa3b8ebe82 Update storage-overview.tsx 2026-04-16 15:28:48 +02:00
github-actions[bot] d03afa1793 Update AppImage beta build (2026-04-16 13:11:30) 2026-04-16 13:11:30 +00:00
MacRimi 056cee2f94 Update storage-overview.tsx 2026-04-16 15:09:16 +02:00
github-actions[bot] f80e087429 Update AppImage beta build (2026-04-16 12:47:49) 2026-04-16 12:47:49 +00:00
MacRimi eea765300e Update flask_server.py 2026-04-16 14:45:47 +02:00
ProxMenuxBot c2396d7e81 Update helpers_cache.json 2026-04-16 12:19:51 +00:00
github-actions[bot] 0b94acf7f6 Update AppImage beta build (2026-04-16 10:10:25) 2026-04-16 10:10:25 +00:00
MacRimi 324cb23f75 Update storage-overview.tsx 2026-04-16 12:08:36 +02:00
github-actions[bot] b341ba8297 Update AppImage beta build (2026-04-16 09:46:02) 2026-04-16 09:46:02 +00:00
MacRimi f5b9da0908 update storage-overview.tsx 2026-04-16 11:43:42 +02:00
ProxMenuxBot c83672a4bc Update helpers_cache.json 2026-04-16 00:24:34 +00:00
ProxMenuxBot af8e3f6a71 Update helpers_cache.json 2026-04-15 12:18:08 +00:00
ProxMenuxBot 5025d38a76 Update helpers_cache.json 2026-04-14 12:18:35 +00:00
ProxMenuxBot f3c7fb97fb Update helpers_cache.json 2026-04-13 18:23:34 +00:00
github-actions[bot] cb2ab5f67b Update AppImage beta build (2026-04-13 17:32:33) 2026-04-13 17:32:33 +00:00
MacRimi 003c8850b7 Update storage-overview.tsx 2026-04-13 19:30:19 +02:00
github-actions[bot] d9461c170d Update AppImage beta build (2026-04-13 17:25:04) 2026-04-13 17:25:04 +00:00
MacRimi 57b5de4a4a Update storage-overview.tsx 2026-04-13 19:22:59 +02:00
github-actions[bot] c406c52086 Update AppImage beta build (2026-04-13 17:16:44) 2026-04-13 17:16:44 +00:00
MacRimi df4855ec47 Update storage-overview.tsx 2026-04-13 19:11:57 +02:00
github-actions[bot] 8c7f4a4c20 Update AppImage beta build (2026-04-13 16:53:47) 2026-04-13 16:53:47 +00:00
MacRimi 3ec733d9c6 update storage-overview.tsx 2026-04-13 18:49:18 +02:00
github-actions[bot] 71c64d1ae5 Update AppImage beta build (2026-04-13 13:31:25) 2026-04-13 13:31:25 +00:00
MacRimi 98becfd368 update storage-overview.tsx 2026-04-13 15:29:22 +02:00
github-actions[bot] 4eea90bd97 Update AppImage beta build (2026-04-13 12:51:54) 2026-04-13 12:51:54 +00:00
MacRimi 2344935357 update storage-overview.tsx 2026-04-13 14:49:48 +02:00
ProxMenuxBot 550279ec68 Update helpers_cache.json 2026-04-13 12:20:26 +00:00
github-actions[bot] 44aefb5d3b Update AppImage beta build (2026-04-13 09:13:46) 2026-04-13 09:13:46 +00:00
MacRimi 7d3cf4d364 Update flask_server.py 2026-04-13 11:11:42 +02:00
github-actions[bot] f6ef383598 Update AppImage beta build (2026-04-13 08:09:07) 2026-04-13 08:09:07 +00:00
MacRimi a6149e3cd8 update storage-overview.tsx 2026-04-13 10:07:09 +02:00
github-actions[bot] 07f1098418 Update AppImage beta build (2026-04-13 07:50:38) 2026-04-13 07:50:38 +00:00
MacRimi 3d00f33dbf Update flask_server.py 2026-04-13 09:48:48 +02:00
MacRimi 98c859fbf8 Update storage-overview.tsx 2026-04-13 09:35:23 +02:00
github-actions[bot] 9460bee72f Update AppImage beta build (2026-04-13 07:20:17) 2026-04-13 07:20:17 +00:00
MacRimi 5a957fe904 Update storage-overview.tsx 2026-04-13 09:18:09 +02:00
github-actions[bot] 59a4b6e4ca Update AppImage beta build (2026-04-13 06:42:00) 2026-04-13 06:42:00 +00:00
MacRimi 9000316224 Update storage-overview.tsx 2026-04-13 08:38:32 +02:00
github-actions[bot] da96c57819 Update AppImage beta build (2026-04-12 21:56:49) 2026-04-12 21:56:49 +00:00
MacRimi b3cef0b009 Update storage-overview.tsx 2026-04-12 23:54:52 +02:00
MacRimi 78ace237dd Update storage-overview.tsx 2026-04-12 23:48:49 +02:00
MacRimi e94e065eca update storage-overview.tsx 2026-04-12 23:45:23 +02:00
github-actions[bot] 4ffe0f3f46 Update AppImage beta build (2026-04-12 21:13:40) 2026-04-12 21:13:40 +00:00
MacRimi 7af4150e44 update storage-overview.tsx 2026-04-12 23:11:31 +02:00
github-actions[bot] 71950369e1 Update AppImage beta build (2026-04-12 21:01:05) 2026-04-12 21:01:05 +00:00
MacRimi adb4815c9b Update storage-overview.tsx 2026-04-12 22:58:49 +02:00
github-actions[bot] 1841feb643 Update AppImage beta build (2026-04-12 20:52:32) 2026-04-12 20:52:32 +00:00
MacRimi f14a0393b7 update storage-overview.tsx 2026-04-12 22:50:30 +02:00
github-actions[bot] 710f77764b Update AppImage beta build (2026-04-12 20:37:41) 2026-04-12 20:37:41 +00:00
MacRimi ae2e86d1d1 Update storage-overview.tsx 2026-04-12 22:35:12 +02:00
github-actions[bot] 167fcb2921 Update AppImage beta build (2026-04-12 20:30:17) 2026-04-12 20:30:17 +00:00
MacRimi 47145ab9d1 update storage-overview.tsx 2026-04-12 22:28:15 +02:00
github-actions[bot] 441ee8e948 Update AppImage beta build (2026-04-12 19:58:21) 2026-04-12 19:58:21 +00:00
github-actions[bot] 9c4528dfcb Update AppImage beta build (2026-04-12 19:39:05) 2026-04-12 19:39:05 +00:00
MacRimi d7c60631b4 Update storage-overview.tsx 2026-04-12 21:37:02 +02:00
github-actions[bot] 530f5c2dbc Update AppImage beta build (2026-04-12 19:30:36) 2026-04-12 19:30:36 +00:00
MacRimi 03dc2afe8d Update storage-overview.tsx 2026-04-12 21:28:36 +02:00
MacRimi 7d35d91415 Update storage-overview.tsx 2026-04-12 21:06:01 +02:00
MacRimi 4843fae0eb Update scripts 2026-04-12 20:32:34 +02:00
ProxMenuxBot 3dfbeac541 Update helpers_cache.json 2026-04-11 00:18:18 +00:00
MacRimi 4fa4bbb08b update switch_gpu_mode.sh 2026-04-10 09:51:03 +02:00
github-actions[bot] 4204e619db Update AppImage beta build (2026-04-10 07:10:14) 2026-04-10 07:10:14 +00:00
MacRimi a663b83daa Update script-terminal-modal.tsx 2026-04-10 09:07:57 +02:00
github-actions[bot] fb218a9331 Update AppImage beta build (2026-04-09 20:08:09) 2026-04-09 20:08:09 +00:00
MacRimi 75b677576e update switch_gpu_mode_direct.sh 2026-04-09 22:05:23 +02:00
MacRimi 04bda0bf10 update switch_gpu_mode_direct.sh 2026-04-09 21:42:44 +02:00
MacRimi 8ca33dec6f update switch_gpu_mode_direct.sh 2026-04-09 21:03:06 +02:00
MacRimi eed9303e41 Update script-terminal-modal.tsx 2026-04-09 20:50:56 +02:00
MacRimi 5277e7b47d Update script-terminal-modal.tsx 2026-04-09 20:41:12 +02:00
github-actions[bot] 727c86a804 Update AppImage beta build (2026-04-09 18:32:02) 2026-04-09 18:32:02 +00:00
MacRimi 21edae5944 Update script-terminal-modal.tsx 2026-04-09 20:29:50 +02:00
github-actions[bot] a0d48a1191 Update AppImage beta build (2026-04-09 18:22:51) 2026-04-09 18:22:51 +00:00
MacRimi 086ba9e577 Update switch_gpu_mode_direct.sh 2026-04-09 20:20:46 +02:00
github-actions[bot] bda5fdbecd Update AppImage beta build (2026-04-09 18:14:32) 2026-04-09 18:14:32 +00:00
MacRimi 13d2eeb9b2 update gpu-switch-mode-indicator.tsx 2026-04-09 20:12:21 +02:00
github-actions[bot] 1bbf814ea3 Update AppImage beta build (2026-04-09 18:01:03) 2026-04-09 18:01:03 +00:00
MacRimi 6d0e5add0b create switch_gpu_mode_direct.sh 2026-04-09 19:58:54 +02:00
github-actions[bot] 06849ca666 Update AppImage beta build (2026-04-09 17:42:58) 2026-04-09 17:42:58 +00:00
MacRimi 4346a5554f Update hardware.tsx 2026-04-09 19:40:49 +02:00
github-actions[bot] 2a174df697 Update AppImage beta build (2026-04-09 17:30:29) 2026-04-09 17:30:29 +00:00
MacRimi 28df51092c update gpu-switch-mode-indicator.tsx 2026-04-09 19:27:45 +02:00
github-actions[bot] ca70852060 Update AppImage beta build (2026-04-09 13:44:32) 2026-04-09 13:44:32 +00:00
MacRimi 86df5cda6e Update hardware.tsx 2026-04-09 15:42:05 +02:00
github-actions[bot] 7db7b7d98f Update AppImage beta build (2026-04-09 13:31:57) 2026-04-09 13:31:57 +00:00
MacRimi 3cede88a3d update gpu-switch-mode-indicator.tsx 2026-04-09 15:29:41 +02:00
github-actions[bot] af63b71ab8 Update AppImage beta build (2026-04-09 13:04:37) 2026-04-09 13:04:38 +00:00
MacRimi 2805c46a22 update gpu-switch-mode-indicator.tsx 2026-04-09 15:02:25 +02:00
github-actions[bot] 104353f013 Update AppImage beta build (2026-04-09 12:22:57) 2026-04-09 12:22:57 +00:00
MacRimi 435f346d98 update notification_events.py 2026-04-09 14:08:56 +02:00
MacRimi 2b8caa924f update notification_events.py 2026-04-09 12:34:03 +02:00
ProxMenuxBot dd22f303ac Update helpers_cache.json 2026-04-08 18:23:52 +00:00
ProxMenuxBot 02141ae16f Update helpers_cache.json 2026-04-08 12:16:35 +00:00
ProxMenuxBot b9b10a69d5 Update helpers_cache.json 2026-04-07 18:17:36 +00:00
github-actions[bot] d8631a8594 Update AppImage beta build (2026-04-07 13:37:06) 2026-04-07 13:37:06 +00:00
MacRimi 463769aba9 Update health_persistence.py 2026-04-07 15:34:48 +02:00
ProxMenuxBot a9fbaf15b2 Update helpers_cache.json 2026-04-07 12:16:27 +00:00
ProxMenuxBot b04c3b9f78 Update helpers_cache.json 2026-04-07 00:19:15 +00:00
MacRimi f2229c2393 Update add_gpu_vm.sh 2026-04-06 23:10:02 +02:00
MacRimi b4aadfee3e Update add_gpu_vm.sh 2026-04-06 22:58:17 +02:00
MacRimi 840e8a774e Update add_gpu_vm.sh 2026-04-06 22:51:30 +02:00
MacRimi c429d391f5 Update add_gpu_vm.sh 2026-04-06 22:34:37 +02:00
MacRimi 2fda3dd1d5 Update add_gpu_vm.sh 2026-04-06 22:26:54 +02:00
MacRimi 4018093c9d Update add_gpu_vm.sh 2026-04-06 22:21:38 +02:00
MacRimi 2f5b8ce4ed update add_gpu_vm.sh 2026-04-06 22:04:38 +02:00
MacRimi 2174c04e4f update add_gpu_vm.sh 2026-04-06 21:50:46 +02:00
MacRimi 109a4d7f54 update switch_gpu_mode.sh 2026-04-06 21:12:13 +02:00
ProxMenuxBot 080ee5cff0 Update helpers_cache.json 2026-04-06 18:16:41 +00:00
MacRimi 257668ab96 Update gpu_hook_guard_helpers.sh 2026-04-06 19:36:35 +02:00
MacRimi d747ac7659 Update gpu_hook_guard_helpers.sh 2026-04-06 19:25:47 +02:00
MacRimi d7c04ebbc7 update vm_storage_helpers.sh 2026-04-06 19:13:31 +02:00
MacRimi c5b01b4bb7 update switch_gpu_mode.sh 2026-04-06 18:40:57 +02:00
MacRimi e8cc90b83d update m_storage_helpers.sh 2026-04-06 17:26:01 +02:00
MacRimi d3974018d8 add_controller_nvme_vm.sh 2026-04-06 17:16:26 +02:00
MacRimi 10ce9dbcde add_controller_nvme_vm.sh 2026-04-06 13:39:07 +02:00
github-actions[bot] 5be4bd2ae9 Update AppImage beta build (2026-04-06 10:16:08) 2026-04-06 10:16:08 +00:00
MacRimi ea1d8ab037 Update system-overview.tsx 2026-04-06 12:06:08 +02:00
MacRimi adde2ce5b9 update health_persistence.py 2026-04-06 12:02:05 +02:00
ProxMenuxBot 9d37a7293b Update helpers_cache.json 2026-04-06 00:19:03 +00:00
ProxMenuxBot 975d4aab60 Update helpers_cache.json 2026-04-05 12:07:31 +00:00
github-actions[bot] 5ead9ee661 Update AppImage beta build (2026-04-05 10:19:43) 2026-04-05 10:19:43 +00:00
MacRimi 95e876b37f update health_monitor.py 2026-04-05 12:17:42 +02:00
github-actions[bot] 4c72d0b3ef Update AppImage beta build (2026-04-05 10:05:01) 2026-04-05 10:05:01 +00:00
MacRimi e7dc030304 Update health_monitor.py 2026-04-05 12:02:59 +02:00
github-actions[bot] c9d5c84d35 Update AppImage beta build (2026-04-05 10:00:14) 2026-04-05 10:00:14 +00:00
MacRimi 4b01ba1d2f update health_monitor.py 2026-04-05 11:58:14 +02:00
github-actions[bot] 723e56ada2 Update AppImage beta build (2026-04-05 09:53:20) 2026-04-05 09:53:20 +00:00
MacRimi e9851da12f update virtual-machines.tsx 2026-04-05 11:51:26 +02:00
MacRimi 5826b0419b update pci_passthrough_helpers.sh 2026-04-05 11:24:08 +02:00
ProxMenuxBot 77124c4549 Update helpers_cache.json 2026-04-05 00:19:09 +00:00
github-actions[bot] 00dbd1c87e Update AppImage beta build (2026-04-03 23:33:41) 2026-04-03 23:33:41 +00:00
MacRimi e0e732dd2c update health_persistence.py 2026-04-04 01:31:37 +02:00
MacRimi ce69c0ba1f Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-04-04 00:55:09 +02:00
MacRimi 58c4e115ba update add_gpu_vm.sh 2026-04-04 00:54:57 +02:00
github-actions[bot] 44478057dd Update AppImage beta build (2026-04-03 22:53:29) 2026-04-03 22:53:29 +00:00
MacRimi d1efae37a4 update health_persistence.py 2026-04-04 00:51:20 +02:00
ProxMenuxBot 8d8e4bab26 Update helpers_cache.json 2026-04-03 12:10:34 +00:00
MacRimi db113f0433 Update menu_post_install.sh 2026-04-03 10:44:10 +02:00
MacRimi 5a66167709 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-04-03 10:34:56 +02:00
MacRimi 7326201b0a update system_utils.sh 2026-04-03 10:34:46 +02:00
github-actions[bot] f116a6b0f2 Update AppImage beta build (2026-04-03 08:07:42) 2026-04-03 08:07:42 +00:00
MacRimi 3087ab36da Update flask_server.py 2026-04-03 10:05:44 +02:00
ProxMenuxBot 28a7189905 Update helpers_cache.json 2026-04-03 00:19:43 +00:00
ProxMenuxBot 0f8e215706 Update helpers_cache.json 2026-04-02 18:15:08 +00:00
github-actions[bot] fd92bed465 Update AppImage beta build (2026-04-02 18:01:23) 2026-04-02 18:01:23 +00:00
MacRimi 281f6975ec Update notification_templates.py 2026-04-02 19:59:27 +02:00
github-actions[bot] e2c40eae48 Update AppImage beta build (2026-04-02 17:26:45) 2026-04-02 17:26:45 +00:00
MacRimi 26968b02a1 update post_install.sh 2026-04-02 19:23:55 +02:00
MacRimi 0d8070455d Update notification_templates.py 2026-04-02 18:29:35 +02:00
MacRimi 2537e964a5 Update notification_templates.py 2026-04-02 17:38:47 +02:00
MacRimi ca7134e610 Update notification_templates.py 2026-04-02 17:20:02 +02:00
MacRimi a8c591affd Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-04-02 17:05:10 +02:00
MacRimi e3842f200d Update add_gpu_lxc.sh 2026-04-02 17:05:00 +02:00
github-actions[bot] cc952d8c79 Update AppImage beta build (2026-04-02 15:01:15) 2026-04-02 15:01:15 +00:00
MacRimi e11daa0b36 update ai_context_enrichment.py 2026-04-02 16:59:09 +02:00
github-actions[bot] 873c77d659 Update AppImage beta build (2026-04-02 09:40:56) 2026-04-02 09:40:56 +00:00
MacRimi 1756a6eb28 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-04-02 11:38:51 +02:00
MacRimi 9636d3671c Update notification_events.py 2026-04-02 11:38:36 +02:00
github-actions[bot] 22e2ebb96c Update AppImage beta build (2026-04-02 09:12:04) 2026-04-02 09:12:04 +00:00
MacRimi c53d3807e7 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-04-02 11:10:03 +02:00
MacRimi 23a6392979 Update notification_events.py 2026-04-02 11:09:50 +02:00
github-actions[bot] fa599ad183 Update AppImage beta build (2026-04-02 07:52:09) 2026-04-02 07:52:09 +00:00
MacRimi e79fdcfe58 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-04-02 09:50:10 +02:00
MacRimi 3746f356b9 Update notification_events.py 2026-04-02 09:49:54 +02:00
github-actions[bot] a5f3362d6e Update AppImage beta build (2026-04-02 07:31:50) 2026-04-02 07:31:50 +00:00
MacRimi 2072264918 update notification_manager.py 2026-04-02 09:29:29 +02:00
github-actions[bot] a5cb01133e Update AppImage beta build (2026-04-02 07:01:18) 2026-04-02 07:01:18 +00:00
MacRimi 62b55cbf16 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-04-02 08:58:02 +02:00
MacRimi 7ea0c4d36c Update notification_events.py 2026-04-02 08:57:37 +02:00
github-actions[bot] 546200844e Update AppImage beta build (2026-04-02 06:40:10) 2026-04-02 06:40:10 +00:00
MacRimi 007e3d1c0e update notification_templates.py 2026-04-02 08:38:01 +02:00
ProxMenuxBot 74a508e3a8 Update helpers_cache.json 2026-04-02 00:17:06 +00:00
MacRimi 5f5dc171be update scripts gpu 2026-04-01 23:09:51 +02:00
github-actions[bot] bccba6e9b9 Update AppImage beta build (2026-04-01 13:27:08) 2026-04-01 13:27:08 +00:00
MacRimi c2073a5db5 Update health_monitor.py 2026-04-01 15:24:47 +02:00
ProxMenuxBot 52ad229d93 Update helpers_cache.json 2026-04-01 12:16:07 +00:00
github-actions[bot] f6a7352672 Update AppImage beta build (2026-04-01 11:24:43) 2026-04-01 11:24:43 +00:00
MacRimi 46e0322e6f update health_persistence.py 2026-04-01 13:01:48 +02:00
MacRimi 618538a854 update health_persistence.py 2026-04-01 12:30:19 +02:00
MacRimi d62396717a update health_persistence.py 2026-04-01 12:03:54 +02:00
MacRimi a734fa5566 Update health_monitor.py 2026-04-01 00:01:12 +02:00
MacRimi f98b302b94 Update health_persistence.py 2026-03-31 23:47:38 +02:00
MacRimi 215d36900a Update health_persistence.py 2026-03-31 23:20:33 +02:00
MacRimi 2df55d2839 Update health_monitor.py 2026-03-31 23:14:48 +02:00
MacRimi e00051caa7 update health_monitor.py 2026-03-31 23:00:00 +02:00
MacRimi 5138b2f1d5 update health_persistence.py 2026-03-31 20:55:17 +02:00
MacRimi 65dfb9103f update /system-logs.tsx 2026-03-31 20:10:58 +02:00
MacRimi 39bbc036cd update system-logs.tsx 2026-03-31 19:38:23 +02:00
MacRimi aaf6dd36f0 update system-logs.tsx 2026-03-31 19:30:10 +02:00
MacRimi e7519e68a3 update system-logs.tsx 2026-03-31 19:09:41 +02:00
MacRimi ad5803ef9c update proxmox-dashboard.tsx 2026-03-31 18:51:38 +02:00
MacRimi c04b514a2a update proxmox-dashboard.tsx 2026-03-31 18:42:35 +02:00
MacRimi cb9f567154 Fix link in security note in README.md
Updated the link in the security note to point to the correct GitHub issue.
2026-03-30 23:40:06 +02:00
MacRimi 80afa789e7 Update notification service 2026-03-30 22:26:20 +02:00
MacRimi 43f2ce52a5 Update notification service 2026-03-30 22:10:40 +02:00
MacRimi cf2e24269e Update notification_templates.py 2026-03-30 21:34:17 +02:00
MacRimi 6899650bf8 Update health_persistence.py 2026-03-30 21:19:08 +02:00
MacRimi c16df51892 Update notification_templates.py 2026-03-30 21:10:54 +02:00
MacRimi c549737ad0 Update HealthMonitor 2026-03-30 20:52:25 +02:00
MacRimi a85b51843a Update switch.tsx 2026-03-30 20:27:40 +02:00
MacRimi 60d401f5ea Update settings.tsx 2026-03-30 20:19:00 +02:00
ProxMenuxBot ca02b9001f Update helpers_cache.json 2026-03-30 18:17:00 +00:00
MacRimi 2fc5e2865d Update notification service 2026-03-30 19:55:19 +02:00
MacRimi 261b2bfb3c Update notification_manager.py 2026-03-30 19:16:31 +02:00
MacRimi 30e32e89b2 Update notification_manager.py 2026-03-30 19:11:19 +02:00
MacRimi 8b1a2b9bff Update security note in README.md
Clarified security note regarding VirusTotal false positives.
2026-03-30 19:04:53 +02:00
MacRimi f71289b248 Update README with security note and support request
Added a security note regarding VirusTotal false positives and encouraged support for the project.
2026-03-30 19:04:02 +02:00
MacRimi 54eab9af49 Update notification service 2026-03-30 18:53:03 +02:00
ProxMenuxBot a05546e811 Update helpers_cache.json 2026-03-30 12:16:12 +00:00
ProxMenuxBot 276c648f29 Update helpers_cache.json 2026-03-29 18:07:42 +00:00
ProxMenuxBot 8c389f4790 Update helpers_cache.json 2026-03-29 12:07:15 +00:00
ProxMenuxBot a09144d21a Update helpers_cache.json 2026-03-29 00:18:49 +00:00
MacRimi cb9a43f496 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-28 23:18:42 +01:00
MacRimi 30606e4743 Update beta_version.txt 2026-03-28 23:18:03 +01:00
github-actions[bot] d05913fbdf Update AppImage beta build (2026-03-28 22:15:36) 2026-03-28 22:15:36 +00:00
MacRimi a34efb50e0 Update notification service 2026-03-28 23:13:35 +01:00
MacRimi a26c69fc8d Update beta_version.txt 2026-03-28 23:09:39 +01:00
MacRimi 107803705c Update beta_version.txt 2026-03-28 23:02:01 +01:00
MacRimi 858b1689bf Update security_menu.sh 2026-03-28 22:56:07 +01:00
MacRimi 641acbd1f4 Update security_menu.sh 2026-03-28 22:53:56 +01:00
MacRimi 1de76ae6c1 Create security_menu.sh 2026-03-28 22:50:44 +01:00
github-actions[bot] 94f535b8ec Update AppImage beta build (2026-03-28 21:48:28) 2026-03-28 21:48:28 +00:00
MacRimi f072e285fc Update startup_grace.py 2026-03-28 22:44:58 +01:00
MacRimi 2c363bbb8e Update health_persistence.py 2026-03-28 22:44:52 +01:00
MacRimi 1c2b87d584 Update notification_templates.py 2026-03-28 22:37:21 +01:00
MacRimi e55154bd5e Update notification service 2026-03-28 22:19:33 +01:00
MacRimi 71098abb65 Update terminal-panel.tsx 2026-03-28 22:13:20 +01:00
MacRimi 7a3a4d1413 Update terminal-panel.tsx 2026-03-28 22:04:47 +01:00
MacRimi 7858fb0283 Update terminal-panel.tsx 2026-03-28 21:50:04 +01:00
MacRimi 2f9959c009 Update virtual-machines.tsx 2026-03-28 21:32:59 +01:00
MacRimi e7d3b20295 Update menus 2026-03-28 19:32:15 +01:00
MacRimi 264fa4982f Update notification service 2026-03-28 19:29:16 +01:00
MacRimi 9636614761 Update notification service 2026-03-28 19:25:05 +01:00
MacRimi ac6561ca52 Update menus 2026-03-28 18:29:58 +01:00
MacRimi 923172d39b udate lynis install 2026-03-28 17:27:29 +01:00
MacRimi d46c42d26b Unistall Fail2ban 2026-03-28 17:01:08 +01:00
MacRimi d628233982 Update notification service 2026-03-28 15:50:30 +01:00
ProxMenuxBot e9e1d471ec Update helpers_cache.json 2026-03-28 12:07:42 +00:00
MacRimi f4740916f5 Update install_coral_lxc.sh 2026-03-28 12:49:52 +01:00
ProxMenuxBot 3a2c9b1b05 Update helpers_cache.json 2026-03-28 00:17:05 +00:00
MacRimi 4cc1147579 Update notification service 2026-03-27 20:42:03 +01:00
MacRimi 976f23a90e update notification service 2026-03-27 20:31:21 +01:00
MacRimi 8ed500adf7 Update notification service 2026-03-27 20:17:59 +01:00
MacRimi 8658044c0c Update notification service 2026-03-27 19:55:15 +01:00
MacRimi 0edc2cc3af Update notification service 2026-03-27 19:40:17 +01:00
ProxMenuxBot f4db4cde13 Update helpers_cache.json 2026-03-27 18:16:33 +00:00
MacRimi 6bb9313b95 Update notification service 2026-03-27 19:15:11 +01:00
ProxMenuxBot 6447dfef50 Update helpers_cache.json 2026-03-27 12:12:51 +00:00
ProxMenuxBot 55cb3a1267 Update helpers_cache.json 2026-03-27 00:18:11 +00:00
MacRimi 7c5e7208b9 Update notification service 2026-03-26 20:04:53 +01:00
ProxMenuxBot aad4b13fda Update helpers_cache.json 2026-03-26 18:19:00 +00:00
MacRimi 839a20df97 Update notification service 2026-03-26 19:05:11 +01:00
MacRimi d497763e38 Update repository and clean up 2026-03-26 19:03:01 +01:00
MacRimi 12c088c10b Update nvidia_installer.sh 2026-03-26 18:18:26 +01:00
MacRimi 8c6a6bece6 Update menu_Helper_Scripts.sh 2026-03-26 17:43:34 +01:00
ProxMenuxBot 819ca8a212 Update helpers_cache.json 2026-03-26 12:16:15 +00:00
MacRimi 8d1becbd8c Update shutdown-notify.sh 2026-03-26 01:11:12 +01:00
MacRimi ebb7491c58 Create install_proxmenux_beta.sh 2026-03-26 00:56:20 +01:00
MacRimi 4f1278c37a Delete install_proxmenux_beta.sh 2026-03-26 00:54:28 +01:00
MacRimi 8ddee9013b Update install_proxmenux_beta.sh 2026-03-26 00:53:14 +01:00
MacRimi 7fe233ae2e Update install_proxmenux_beta.sh 2026-03-26 00:51:14 +01:00
MacRimi f7b37b1559 Update install_proxmenux_beta.sh 2026-03-26 00:49:06 +01:00
MacRimi 0b3624dbd5 Update notification service 2026-03-26 00:32:52 +01:00
MacRimi fb066eb2e9 Update install_proxmenux_beta.sh 2026-03-25 23:57:33 +01:00
MacRimi 22fadbb87b Update install_proxmenux_beta.sh 2026-03-25 23:56:27 +01:00
MacRimi 3b119d528c Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-25 23:50:59 +01:00
MacRimi bb3d3d759e Update install_proxmenux_beta.sh 2026-03-25 23:50:39 +01:00
github-actions[bot] 3c08ae9399 Update AppImage beta build (2026-03-25 22:48:45) 2026-03-25 22:48:45 +00:00
MacRimi 7a6fa2afa5 Upgrade GitHub Actions to use latest versions 2026-03-25 23:47:00 +01:00
MacRimi ba4e3c3adb Update notification service 2026-03-25 23:45:34 +01:00
MacRimi 66892f69ce Update notification service 2026-03-25 23:30:00 +01:00
MacRimi e37469ac2b Update install_proxmenux_beta.sh 2026-03-25 23:17:29 +01:00
MacRimi cdc2d7bbcb update notification service 2026-03-25 23:13:11 +01:00
MacRimi 92c0e6ff09 Update install_proxmenux_beta.sh 2026-03-25 22:58:51 +01:00
MacRimi 9b3fd324c3 Update install_proxmenux_beta.sh 2026-03-25 22:56:55 +01:00
MacRimi 23bd692f8e Update install_proxmenux_beta.sh 2026-03-25 22:53:54 +01:00
MacRimi 29b4573ca9 Update install_proxmenux_beta.sh 2026-03-25 22:50:32 +01:00
MacRimi 8b6755d866 Update notification service 2026-03-25 22:43:42 +01:00
MacRimi 6da20aab05 update notification service 2026-03-25 22:14:38 +01:00
MacRimi 5aa5942bcd Upgrade GitHub Actions to latest versions 2026-03-25 20:16:09 +01:00
MacRimi 68872d0e06 Update notification service 2026-03-25 20:12:08 +01:00
MacRimi d53c1dc402 Utdate notification service 2026-03-25 19:47:47 +01:00
MacRimi 6c2b03ae76 Update notification service 2026-03-25 19:21:37 +01:00
ProxMenuxBot 9f79d2b737 Update helpers_cache.json 2026-03-25 18:17:47 +00:00
MacRimi 2241b125d6 Update notification service 2026-03-25 18:28:54 +01:00
github-actions[bot] 152624302c Update AppImage beta build (2026-03-25 12:13:46) 2026-03-25 12:13:46 +00:00
ProxMenuxBot 6a703ee6a4 Update helpers_cache.json 2026-03-25 12:13:03 +00:00
MacRimi 0c0caa422d Update notification service 2026-03-25 13:10:36 +01:00
ProxMenuxBot 6fa7c1d4eb Update helpers_cache.json 2026-03-25 00:16:22 +00:00
MacRimi 509fff3972 Refactor discussion template for AI prompts
Removed prompt name and output language fields from the template. Updated description field to include output language information.
2026-03-24 18:20:58 +01:00
MacRimi bcacd8b98e Update notification service 2026-03-24 17:48:52 +01:00
MacRimi d2c8178772 Update notification service 2026-03-24 17:34:05 +01:00
MacRimi 365a246461 Update health-status-modal.tsx 2026-03-24 17:18:38 +01:00
MacRimi 098ae13f94 Update install_proxmenux_beta.sh 2026-03-24 15:14:36 +01:00
ProxMenuxBot 6a92225630 Update helpers_cache.json 2026-03-24 12:14:00 +00:00
github-actions[bot] c98044be9a Update AppImage beta build (2026-03-24 12:02:15) 2026-03-24 12:02:15 +00:00
MacRimi e6eb81cc61 Update notification_events.py 2026-03-24 12:48:26 +01:00
github-actions[bot] 0e50caadec Update AppImage beta build (2026-03-24 11:06:32) 2026-03-24 11:06:32 +00:00
MacRimi aad44ad42f Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-24 12:04:31 +01:00
MacRimi 59fd055526 Update beta_version.txt 2026-03-24 12:04:18 +01:00
github-actions[bot] 5fce75a60d Update AppImage beta build (2026-03-24 11:01:15) 2026-03-24 11:01:15 +00:00
MacRimi 74390726b4 Update flask_server.py 2026-03-24 11:41:49 +01:00
MacRimi 10f37b88c3 Upgrade GitHub Actions to v6 for build workflow 2026-03-24 10:58:16 +01:00
MacRimi 9bfacd9da9 Upgrade GitHub Actions to version 6 2026-03-24 10:57:45 +01:00
MacRimi a286770fd2 Upgrade GitHub Actions to version 6 2026-03-24 10:56:50 +01:00
github-actions[bot] 3101e84830 Update AppImage beta build (2026-03-24 09:41:18) 2026-03-24 09:41:18 +00:00
MacRimi 815b3bebda Update notification_templates.py 2026-03-24 10:38:06 +01:00
MacRimi c71eda1229 Update notification service 2026-03-24 10:27:52 +01:00
MacRimi 60518be5bd Update notification service 2026-03-23 23:05:27 +01:00
MacRimi 83254d9d70 Update notification_events.py 2026-03-23 21:04:01 +01:00
MacRimi d34cebc90d Update health_monitor.py 2026-03-23 20:25:27 +01:00
MacRimi c7ef51a73c Update notification service 2026-03-23 20:14:25 +01:00
MacRimi ab34fb08c1 Update health_monitor.py 2026-03-23 19:31:21 +01:00
ProxMenuxBot 6f99e1e8c1 Update helpers_cache.json 2026-03-23 18:14:42 +00:00
MacRimi 5fe87a04f0 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-23 18:08:35 +01:00
MacRimi 4ac71381da Update health_monitor.py 2026-03-23 18:08:22 +01:00
github-actions[bot] f95e1ad4b8 Update AppImage beta build (2026-03-23 16:56:41) 2026-03-23 16:56:41 +00:00
MacRimi 168726c131 Update notification service 2026-03-23 17:49:39 +01:00
ProxMenuxBot 4545aeb9c6 Update helpers_cache.json 2026-03-23 12:13:22 +00:00
ProxMenuxBot 84cf3e6a15 Update helpers_cache.json 2026-03-23 00:17:27 +00:00
MacRimi cd123b3479 Update notification_channels.py 2026-03-22 22:32:28 +01:00
MacRimi 4b99de8841 Update notification service 2026-03-22 22:24:17 +01:00
MacRimi 147ca0a41a Update gemini_provider.py 2026-03-22 21:35:25 +01:00
MacRimi 3f24d55945 update notification service 2026-03-22 21:14:19 +01:00
MacRimi 70871330d3 Update notification service 2026-03-22 20:58:57 +01:00
MacRimi 08bc354fa5 Update notification service 2026-03-22 20:47:51 +01:00
MacRimi 54c7322b23 Update flask_server.py 2026-03-22 20:20:59 +01:00
github-actions[bot] 4d2ceee26b Update AppImage beta build (2026-03-22 18:56:48) 2026-03-22 18:56:48 +00:00
MacRimi b6321e9698 Update notification_templates.py 2026-03-22 19:42:12 +01:00
MacRimi dd197c9826 Update auto_post_install.sh 2026-03-22 19:32:08 +01:00
MacRimi 01427f4926 Update notification service 2026-03-22 18:43:33 +01:00
MacRimi c47a7ba2a5 Update notification_events.py 2026-03-22 15:02:29 +01:00
MacRimi 04564bc9cf Update health_monitor.py 2026-03-22 14:57:46 +01:00
MacRimi 04661ce340 Update flask_server.py 2026-03-22 14:53:34 +01:00
MacRimi fcc54e2d6a Update flask_server.py 2026-03-22 14:48:30 +01:00
MacRimi 52fc3b03b7 Update flask_server.py 2026-03-22 14:39:09 +01:00
MacRimi 70c29ed7b6 Update flask_server.py 2026-03-22 14:29:46 +01:00
MacRimi d33741a90d Update notification service 2026-03-22 14:20:47 +01:00
ProxMenuxBot 484f117b8e Update helpers_cache.json 2026-03-22 12:05:28 +00:00
MacRimi 317739b508 Update beta_version.txt 2026-03-22 11:16:44 +01:00
github-actions[bot] d8062b4859 Update AppImage beta build (2026-03-22 10:15:19) 2026-03-22 10:15:19 +00:00
MacRimi 452eb70faf Update notification-settings.tsx 2026-03-22 10:22:17 +01:00
MacRimi 83889d7e3c Add discussion template for AI notifications prompts 2026-03-22 00:28:09 +01:00
MacRimi 2eb970a6a2 Create discussion template for custom prompts
Added a discussion template for sharing custom prompts, including fields for prompt name, AI provider, model, output language, description, prompt content, example output, additional notes, and confirmation checkboxes.
2026-03-22 00:18:20 +01:00
MacRimi 7838762a4e Update health_monitor.py 2026-03-21 23:19:41 +01:00
MacRimi f370a670ad Update flask_server.py 2026-03-21 23:03:30 +01:00
MacRimi 41fa6f4b10 Update notification service 2026-03-21 22:27:54 +01:00
MacRimi 18aa9a77dd Update notification service 2026-03-21 21:53:46 +01:00
MacRimi e53f6c0c52 Update notification-settings.tsx 2026-03-21 21:09:06 +01:00
MacRimi 642539cdfc Update notification-settings.tsx 2026-03-21 20:55:34 +01:00
MacRimi 6534fa7171 Update notification-settings.tsx 2026-03-21 20:41:02 +01:00
MacRimi ff08f4c0b5 Update notification-settings.tsx 2026-03-21 20:29:30 +01:00
MacRimi 134c62d543 Update notification-settings.tsx 2026-03-21 20:16:06 +01:00
MacRimi 1243842c68 Update notification-settings.tsx 2026-03-21 20:11:52 +01:00
MacRimi c1093be548 Update notification service 2026-03-21 19:57:28 +01:00
MacRimi b6f58758f2 Update notification-settings.tsx 2026-03-21 19:17:02 +01:00
MacRimi 1dbb59bc3f Update notification service 2026-03-21 18:53:35 +01:00
MacRimi 5e32857729 Update flask_server.py 2026-03-21 17:22:31 +01:00
MacRimi e3a611f33d Remove star history chart from README
Removed the star history chart HTML section from README.
2026-03-21 11:08:33 +01:00
MacRimi 8fb2a9094e Enhance README with star history chart
Added responsive star history chart with dark and light themes.
2026-03-21 11:07:56 +01:00
ProxMenuxBot d1e7154040 Update helpers_cache.json 2026-03-20 18:10:20 +00:00
ProxMenuxBot e695b4e764 Update helpers_cache.json 2026-03-20 12:08:24 +00:00
ProxMenuxBot b1eae7b768 Update helpers_cache.json 2026-03-19 12:09:20 +00:00
MacRimi 55bb5b5a1c Add beta program details to README
Added a beta program section with installation instructions and feedback guidelines.
2026-03-18 22:16:28 +01:00
ProxMenuxBot e8232a9ea0 Update helpers_cache.json 2026-03-18 18:18:46 +00:00
ProxMenuxBot aeabb99be6 Update helpers_cache.json 2026-03-18 12:14:02 +00:00
MacRimi 38ee6d836d Add GitHub Actions workflow for AppImage beta build 2026-03-18 11:22:29 +01:00
MacRimi 7bb4bd3da5 Rename workflow for building AppImage release 2026-03-18 11:21:26 +01:00
MacRimi 7524615671 Save compiled AppImage files before git operations
Added steps to save compiled AppImage files before cleaning local changes.
2026-03-18 11:15:14 +01:00
MacRimi f5fe883d49 Enhance AppImage workflow to include checksum upload
Updated the workflow to upload both AppImage and checksum artifacts, and modified the commit process to use the current branch instead of main.
2026-03-18 11:11:05 +01:00
MacRimi bec6406216 Update GitHub Actions workflow for AppImage build 2026-03-18 10:48:41 +01:00
ProxMenuxBot ef041f2702 Update helpers_cache.json 2026-03-18 00:17:00 +00:00
MacRimi df0f15419e Add files via upload 2026-03-17 18:07:18 +01:00
ProxMenuxBot dc531eaa37 Update helpers_cache.json 2026-03-17 12:13:15 +00:00
ProxMenuxBot 1eaabd14bd Update helpers_cache.json 2026-03-16 18:18:06 +00:00
ProxMenuxBot c9c8987cca Update helpers_cache.json 2026-03-16 12:14:04 +00:00
ProxMenuxBot 06dc6ea23f Update helpers_cache.json 2026-03-16 00:17:59 +00:00
MacRimi 8b3a76dfc5 Upgrade GitHub Actions to latest versions 2026-03-15 14:14:00 +01:00
ProxMenuxBot 60398210c7 Update helpers_cache.json 2026-03-15 12:06:54 +00:00
ProxMenuxBot 486c7ef530 Update helpers_cache.json 2026-03-15 00:18:01 +00:00
ProxMenuxBot 94131097a5 Update helpers_cache.json 2026-03-14 18:06:03 +00:00
MacRimi 6d69e009dc Rebuild Helper Scripts catalog for new data architecture
Rebuilt the Helper Scripts catalog to connect directly to the PocketBase API, enhancing data structure and script options. Acknowledged contributions from Community Scripts maintainers for their support in the integration.
2026-03-14 18:29:27 +01:00
MacRimi 6d9b132ab8 Bump version from 1.1.8 to 1.1.9 2026-03-14 18:18:44 +01:00
MacRimi efec1aff18 Update script version and improve loading logic
Updated version to 1.3 and last updated date to 14/03/2025. Removed dependency on metadata.json and improved script loading and error handling.
2026-03-14 18:16:55 +01:00
MacRimi 258d6d9a49 Update menu_Helper_Scripts.sh 2026-03-14 18:15:15 +01:00
MacRimi 1c4b7c7b97 Update menu_Helper_Scripts.sh 2026-03-14 18:13:34 +01:00
ProxMenuxBot 8bc6306813 Update helpers_cache.json 2026-03-14 17:04:43 +00:00
MacRimi 2923c00738 Add files via upload 2026-03-14 18:04:09 +01:00
MacRimi b30b6a062a Delete .github/scripts/generate_helpers_cache.py 2026-03-14 18:03:51 +01:00
MacRimi 8f5df889ab Rename helpers_cache__.json to helpers_cache.json 2026-03-14 17:50:43 +01:00
MacRimi 4ec8b19251 Add backup JSON cache file 2026-03-14 17:50:25 +01:00
MacRimi 1035a94775 Rename helpers_cache_back.json to helpers_cache.json 2026-03-14 17:32:12 +01:00
MacRimi 3ca2ae7175 Add helpers_cache__.json file 2026-03-14 17:31:56 +01:00
ProxMenuxBot 4ba1ca890c Update helpers_cache.json 2026-03-14 15:44:57 +00:00
MacRimi cba012bd15 Add backup JSON helpers cache file 2026-03-14 16:44:28 +01:00
MacRimi 9515ccd816 Add files via upload 2026-03-14 16:43:44 +01:00
MacRimi 46622f5028 Add generate_helpers_cache_back.py script 2026-03-14 16:40:25 +01:00
MacRimi 9190c8e5bf Update API_URL for JSON content retrieval 2026-03-13 17:15:01 +01:00
MacRimi 109498e2df Update API_URL to point to Frontend-Archive 2026-03-13 17:08:57 +01:00
MacRimi 60d7c395bc Update Node.js version and add environment variable 2026-03-12 19:35:23 +01:00
ProxMenuxBot 782d847e54 Update helpers_cache.json 2026-03-08 18:04:29 +00:00
ProxMenuxBot d96e4019aa Update helpers_cache.json 2026-03-08 00:15:29 +00:00
ProxMenuxBot 6b438bc4aa Update helpers_cache.json 2026-03-07 00:14:59 +00:00
ProxMenuxBot 50d07f81fd Update helpers_cache.json 2026-03-06 12:08:42 +00:00
ProxMenuxBot 7d69e64adc Update helpers_cache.json 2026-03-05 18:34:30 +00:00
ProxMenuxBot c2fa6095cc Update helpers_cache.json 2026-03-03 12:08:48 +00:00
ProxMenuxBot 0b8b72be5c Update helpers_cache.json 2026-03-02 18:12:26 +00:00
ProxMenuxBot fd6f0967b0 Update helpers_cache.json 2026-03-02 12:08:55 +00:00
ProxMenuxBot ca9698f75d Update helpers_cache.json 2026-02-28 12:05:10 +00:00
ProxMenuxBot 968a5bd789 Update helpers_cache.json 2026-02-27 18:10:15 +00:00
ProxMenuxBot 1fe4ee5b81 Update helpers_cache.json 2026-02-26 12:12:55 +00:00
ProxMenuxBot 137aeac91a Update helpers_cache.json 2026-02-25 18:21:39 +00:00
ProxMenuxBot ccb0b58a2d Update helpers_cache.json 2026-02-25 12:11:18 +00:00
ProxMenuxBot 680123eb64 Update helpers_cache.json 2026-02-24 12:11:48 +00:00
ProxMenuxBot aec04f0b8c Update helpers_cache.json 2026-02-24 00:15:52 +00:00
ProxMenuxBot e75bbc0a22 Update helpers_cache.json 2026-02-23 18:20:58 +00:00
ProxMenuxBot 81fc625c5d Update helpers_cache.json 2026-02-23 12:10:39 +00:00
ProxMenuxBot f85683239f Update helpers_cache.json 2026-02-22 12:05:24 +00:00
ProxMenuxBot c0f54c334e Update helpers_cache.json 2026-02-21 00:16:10 +00:00
ProxMenuxBot 5c2d4e4718 Update helpers_cache.json 2026-02-19 18:16:51 +00:00
ProxMenuxBot 64a0aa6157 Update helpers_cache.json 2026-02-19 12:11:34 +00:00
ProxMenuxBot ff2e40d49a Update helpers_cache.json 2026-02-17 12:10:47 +00:00
ProxMenuxBot 1226e7bee1 Update helpers_cache.json 2026-02-16 12:10:34 +00:00
ProxMenuxBot 342203bb81 Update helpers_cache.json 2026-02-16 00:16:44 +00:00
ProxMenuxBot e4bc526a09 Update helpers_cache.json 2026-02-15 18:05:49 +00:00
ProxMenuxBot 5941bd4b68 Update helpers_cache.json 2026-02-13 12:08:41 +00:00
ProxMenuxBot 1c95319608 Update helpers_cache.json 2026-02-11 12:14:33 +00:00
ProxMenuxBot eeea948844 Update helpers_cache.json 2026-02-11 00:20:59 +00:00
ProxMenuxBot 59bb0070e9 Update helpers_cache.json 2026-02-10 12:15:22 +00:00
ProxMenuxBot ec2206ade0 Update helpers_cache.json 2026-02-09 18:16:40 +00:00
ProxMenuxBot 7796f7d3bc Update helpers_cache.json 2026-02-09 12:14:11 +00:00
ProxMenuxBot b806bf80b1 Update helpers_cache.json 2026-02-08 12:05:47 +00:00
ProxMenuxBot 173ea58701 Update helpers_cache.json 2026-02-08 00:20:03 +00:00
ProxMenuxBot 775b6ff4fd Update helpers_cache.json 2026-02-07 00:15:38 +00:00
ProxMenuxBot b0f18461b3 Update helpers_cache.json 2026-02-06 18:13:29 +00:00
ProxMenuxBot b8ccbfd222 Update helpers_cache.json 2026-02-06 12:09:32 +00:00
ProxMenuxBot c2fa497137 Update helpers_cache.json 2026-02-05 18:16:47 +00:00
ProxMenuxBot bdcfa6929c Update helpers_cache.json 2026-02-05 12:09:37 +00:00
ProxMenuxBot 8470b58b60 Update helpers_cache.json 2026-02-04 18:14:29 +00:00
ProxMenuxBot 002413c067 Update helpers_cache.json 2026-02-04 12:08:26 +00:00
ProxMenuxBot ecce59e734 Update helpers_cache.json 2026-02-04 00:14:14 +00:00
ProxMenuxBot 1935c76f30 Update helpers_cache.json 2026-02-02 18:11:22 +00:00
ProxMenuxBot 81b7a3e665 Update helpers_cache.json 2026-02-02 12:08:58 +00:00
ProxMenuxBot a68bf6fc8f Update helpers_cache.json 2026-02-01 00:16:53 +00:00
ProxMenuxBot 459dd2d9c7 Update helpers_cache.json 2026-01-31 00:14:20 +00:00
ProxMenuxBot fed4cc2a97 Update helpers_cache.json 2026-01-29 19:56:29 +00:00
156 changed files with 61204 additions and 23472 deletions
@@ -0,0 +1,117 @@
title: "[Prompt] "
labels:
- custom-prompt
- community
body:
- type: markdown
attributes:
value: |
## Share Your Custom Prompt
Thank you for sharing your custom prompt with the community!
**Title format suggestion:** Include the provider in the title for easy filtering.
Example: `[Gemini] Clean Spanish - Structured, no emojis`
This helps others find prompts for their specific AI provider.
- type: dropdown
id: provider
attributes:
label: AI Provider
description: Which AI provider did you test this prompt with?
options:
- OpenAI
- Gemini
- Groq
- Ollama
- Anthropic
- OpenRouter
- DeepSeek
- Other
validations:
required: true
- type: input
id: model
attributes:
label: Model
description: The specific model you tested with
placeholder: "e.g., gpt-4o-mini, gemini-2.0-flash, llama3.2:3b"
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: Describe what your prompt does, main features, and output language
placeholder: |
This prompt generates concise notifications in Spanish.
Features:
- Brief format (2-3 lines)
- Includes severity indicators
- Uses emojis for visual clarity
validations:
required: true
- type: textarea
id: prompt-content
attributes:
label: Prompt Content
description: Paste your complete custom prompt here
render: text
placeholder: |
You are a notification formatter for ProxMenux Monitor.
Your task is to...
RULES:
1. ...
2. ...
OUTPUT FORMAT:
[TITLE]
...
[BODY]
...
validations:
required: true
- type: textarea
id: example-output
attributes:
label: Example Output
description: Show an example of how a notification looks with your prompt
placeholder: |
**Input notification:**
CPU usage high on node pve01
**Output with this prompt:**
pve01: High CPU Usage
CPU at 95% for 5 minutes. Check running processes.
validations:
required: false
- type: textarea
id: additional-notes
attributes:
label: Additional Notes
description: Any tips, variations, or known limitations
placeholder: |
- Works best with models that support system prompts
- May need adjustment for very long notifications
- Tested with Proxmox VE 8.x
validations:
required: false
- type: checkboxes
id: confirmation
attributes:
label: Confirmation
options:
- label: I have tested this prompt and it works correctly
required: true
- label: I am sharing this prompt for the community to use freely
required: true
+174 -113
View File
@@ -3,26 +3,28 @@ import json
import re
import sys
from pathlib import Path
from typing import Any
import requests
# ---------- Config ----------
API_URL = "https://api.github.com/repos/community-scripts/ProxmoxVE/contents/frontend/public/json"
SCRIPT_BASE = "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main"
POCKETBASE_BASE = "https://db.community-scripts.org/api/collections"
SCRIPT_COLLECTION_URL = f"{POCKETBASE_BASE}/script_scripts/records"
CATEGORY_COLLECTION_URL = f"{POCKETBASE_BASE}/script_categories/records"
# Escribimos siempre en <raiz_repo>/json/helpers_cache.json, independientemente del cwd
REPO_ROOT = Path(__file__).resolve().parents[2]
OUTPUT_FILE = REPO_ROOT / "json" / "helpers_cache.json"
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
# ----------------------------
TYPE_TO_PATH_PREFIX = {
"lxc": "ct",
"vm": "vm",
"addon": "tools/addon",
"pve": "tools/pve",
}
def to_mirror_url(raw_url: str) -> str:
"""
Convierte una URL raw de GitHub al raw del mirror.
GH : https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/docker.sh
MIR: https://git.community-scripts.org/community-scripts/ProxmoxVE/raw/branch/main/ct/docker.sh
"""
m = re.match(r"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)$", raw_url or "")
if not m:
return ""
@@ -32,143 +34,202 @@ def to_mirror_url(raw_url: str) -> str:
return f"https://git.community-scripts.org/community-scripts/ProxmoxVE/raw/branch/{branch}/{path}"
def guess_os_from_script_path(script_path: str) -> str | None:
"""
Heurística suave cuando el JSON no publica resources.os:
- tools/pve/* -> proxmox
- ct/alpine-* -> alpine
- tools/addon/* -> generic (suele ejecutarse sobre LXC existente)
- ct/* -> debian (por defecto para CTs)
"""
if not script_path:
return None
if script_path.startswith("tools/pve/") or script_path == "tools/pve/host-backup.sh" or script_path.startswith("vm/"):
return "proxmox"
if "/alpine-" in script_path or script_path.startswith("ct/alpine-"):
return "alpine"
if script_path.startswith("tools/addon/"):
return "generic"
if script_path.startswith("ct/"):
return "debian"
return None
def fetch_directory_json(api_url: str) -> list[dict]:
r = requests.get(api_url, timeout=30)
def fetch_json(url: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
r = requests.get(url, params=params, timeout=60)
r.raise_for_status()
data = r.json()
if not isinstance(data, list):
raise RuntimeError("GitHub API no devolvió una lista.")
if not isinstance(data, dict):
raise RuntimeError(f"Unexpected response from {url}: expected object")
return data
def fetch_all_records(url: str, *, expand: str | None = None, per_page: int = 500) -> list[dict[str, Any]]:
page = 1
items: list[dict[str, Any]] = []
while True:
params: dict[str, Any] = {"page": page, "perPage": per_page}
if expand:
params["expand"] = expand
data = fetch_json(url, params=params)
page_items = data.get("items", [])
if not isinstance(page_items, list):
raise RuntimeError(f"Unexpected items list from {url}")
items.extend(page_items)
total_pages = data.get("totalPages", page)
if not isinstance(total_pages, int) or page >= total_pages:
break
page += 1
return items
def normalize_os_variants(install_methods_json: list[dict[str, Any]]) -> list[str]:
os_values: list[str] = []
for item in install_methods_json:
if not isinstance(item, dict):
continue
resources = item.get("resources", {})
if not isinstance(resources, dict):
continue
os_name = resources.get("os")
if isinstance(os_name, str) and os_name.strip():
normalized = os_name.strip().lower()
if normalized not in os_values:
os_values.append(normalized)
return os_values
def build_script_path(type_name: str, slug: str) -> str:
type_name = (type_name or "").strip().lower()
slug = (slug or "").strip()
if type_name == "turnkey":
return "turnkey/turnkey.sh"
prefix = TYPE_TO_PATH_PREFIX.get(type_name)
if not prefix or not slug:
return ""
return f"{prefix}/{slug}.sh"
def main() -> int:
try:
directory = fetch_directory_json(API_URL)
scripts = fetch_all_records(SCRIPT_COLLECTION_URL, expand="type,categories")
categories = fetch_all_records(CATEGORY_COLLECTION_URL)
except Exception as e:
print(f"ERROR: No se pudo leer el índice de JSONs: {e}", file=sys.stderr)
print(f"ERROR: Unable to fetch PocketBase data: {e}", file=sys.stderr)
return 1
cache: list[dict] = []
seen: set[tuple[str, str]] = set() # (slug, script) para evitar duplicados
category_map: dict[str, dict[str, Any]] = {}
for category in categories:
category_id = category.get("id")
if isinstance(category_id, str) and category_id:
category_map[category_id] = category
total_items = len(directory)
processed = 0
kept = 0
cache: list[dict[str, Any]] = []
for item in directory:
url = item.get("download_url")
name_in_dir = item.get("name", "")
if not url or not url.endswith(".json"):
print(f"Fetched {len(scripts)} scripts and {len(category_map)} categories")
for idx, raw in enumerate(scripts, start=1):
if not isinstance(raw, dict):
continue
try:
raw = requests.get(url, timeout=30).json()
if not isinstance(raw, dict):
continue
except Exception:
print(f"❌ Error al obtener/parsing {name_in_dir}", file=sys.stderr)
continue
processed += 1
name = raw.get("name", "")
slug = raw.get("slug")
type_ = raw.get("type", "")
name = raw.get("name", "")
desc = raw.get("description", "")
categories = raw.get("categories", [])
notes = [n.get("text", "") for n in raw.get("notes", []) if isinstance(n, dict)]
# Credenciales (si existen, se copian tal cual)
credentials = raw.get("default_credentials", {})
cred_username = credentials.get("username") if isinstance(credentials, dict) else None
cred_password = credentials.get("password") if isinstance(credentials, dict) else None
add_credentials = any([
cred_username not in (None, ""),
cred_password not in (None, "")
])
install_methods = raw.get("install_methods", [])
if not isinstance(install_methods, list) or not install_methods:
# Sin install_methods válidos -> continuamos
if not isinstance(slug, str) or not slug.strip():
continue
for im in install_methods:
if not isinstance(im, dict):
continue
script = im.get("script", "")
if not script:
continue
expand = raw.get("expand", {}) if isinstance(raw.get("expand"), dict) else {}
type_expanded = expand.get("type", {}) if isinstance(expand.get("type"), dict) else {}
type_name = type_expanded.get("type", "") if isinstance(type_expanded.get("type"), str) else ""
# OS desde resources u heurística
resources = im.get("resources", {}) if isinstance(im, dict) else {}
os_name = resources.get("os") if isinstance(resources, dict) else None
if not os_name:
os_name = guess_os_from_script_path(script)
if isinstance(os_name, str):
os_name = os_name.strip().lower()
script_path = build_script_path(type_name, slug)
if not script_path:
print(f"[{idx:03d}] WARNING: Unable to build script path for slug={slug} type={type_name!r}", file=sys.stderr)
continue
full_script_url = f"{SCRIPT_BASE}/{script}"
script_url_mirror = to_mirror_url(full_script_url)
full_script_url = f"{SCRIPT_BASE}/{script_path}"
script_url_mirror = to_mirror_url(full_script_url)
key = (slug or "", script)
if key in seen:
continue
seen.add(key)
install_methods_json = raw.get("install_methods_json", [])
if not isinstance(install_methods_json, list):
install_methods_json = []
entry = {
"name": name,
"slug": slug,
"desc": desc,
"script": script,
"script_url": full_script_url,
"script_url_mirror": script_url_mirror, # nuevo
"os": os_name, # nuevo
"categories": categories,
"notes": notes,
"type": type_,
notes_json = raw.get("notes_json", [])
if not isinstance(notes_json, list):
notes_json = []
notes = [
note.get("text", "")
for note in notes_json
if isinstance(note, dict) and isinstance(note.get("text"), str) and note.get("text", "").strip()
]
category_ids = raw.get("categories", [])
if not isinstance(category_ids, list):
category_ids = []
expanded_categories = expand.get("categories", []) if isinstance(expand.get("categories"), list) else []
category_names: list[str] = []
for cat in expanded_categories:
if isinstance(cat, dict):
cat_name = cat.get("name")
if isinstance(cat_name, str) and cat_name.strip():
category_names.append(cat_name.strip())
if not category_names:
for cat_id in category_ids:
cat = category_map.get(cat_id, {})
cat_name = cat.get("name")
if isinstance(cat_name, str) and cat_name.strip():
category_names.append(cat_name.strip())
# Shared fields across all install method entries
default_user = raw.get("default_user")
default_passwd = raw.get("default_passwd")
default_credentials: dict[str, str] | None = None
if (isinstance(default_user, str) and default_user.strip()) or (isinstance(default_passwd, str) and default_passwd.strip()):
default_credentials = {
"username": default_user if isinstance(default_user, str) else "",
"password": default_passwd if isinstance(default_passwd, str) else "",
}
if add_credentials:
entry["default_credentials"] = {
"username": cred_username,
"password": cred_password,
}
base_entry: dict[str, Any] = {
"name": name,
"slug": slug,
"desc": desc,
"script": script_path,
"script_url": full_script_url,
"script_url_mirror": script_url_mirror,
"type": type_name,
"type_id": raw.get("type", ""),
"categories": category_ids,
"category_names": category_names,
"notes": notes,
"port": raw.get("port", 0),
"website": raw.get("website", ""),
"documentation": raw.get("documentation", ""),
"logo": raw.get("logo", ""),
"updateable": bool(raw.get("updateable", False)),
"privileged": bool(raw.get("privileged", False)),
"has_arm": bool(raw.get("has_arm", False)),
"is_dev": bool(raw.get("is_dev", False)),
"execute_in": raw.get("execute_in", []),
"config_path": raw.get("config_path", ""),
}
if default_credentials:
base_entry["default_credentials"] = default_credentials
# Emit one entry per install method so the menu shell can offer an
# explicit OS choice. When there is only one method (or none), a
# single entry is emitted with os="" (script decides at runtime).
os_variants = normalize_os_variants(install_methods_json)
if len(os_variants) > 1:
for os_name in os_variants:
entry = {**base_entry, "os": os_name}
cache.append(entry)
print(f"[{len(cache):03d}] {slug:<24}{script_path:<28} type={type_name:<7} os={os_name}")
else:
os_name = os_variants[0] if os_variants else ""
entry = {**base_entry, "os": os_name}
cache.append(entry)
kept += 1
print(f"[{len(cache):03d}] {slug:<24}{script_path:<28} type={type_name:<7} os={os_name or 'n/a'}")
# Progreso ligero
print(f"[{kept:03d}] {slug or name:<24}{script:<28} os={os_name or 'n/a'} src={'GH+MR' if script_url_mirror else 'GH'}")
# Orden estable para commits reproducibles
cache.sort(key=lambda x: (x.get("slug") or "", x.get("script") or ""))
with OUTPUT_FILE.open("w", encoding="utf-8") as f:
json.dump(cache, f, ensure_ascii=False, indent=2)
print(f"\n✅ helpers_cache.json → {OUTPUT_FILE}")
print(f" Total JSON en índice: {total_items}")
print(f" Procesados: {processed} | Guardados: {kept} | Únicos (slug,script): {len(seen)}")
print(f" Guardados: {len(cache)}")
return 0
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
import json
import re
import sys
from pathlib import Path
import requests
# ---------- Config ----------
# API_URL = "https://api.github.com/repos/community-scripts/ProxmoxVE/contents/frontend/public/json"
API_URL = "https://api.github.com/repos/community-scripts/ProxmoxVE-Frontend-Archive/contents/public/json"
SCRIPT_BASE = "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main"
# Escribimos siempre en <raiz_repo>/json/helpers_cache.json, independientemente del cwd
REPO_ROOT = Path(__file__).resolve().parents[2]
OUTPUT_FILE = REPO_ROOT / "json" / "helpers_cache.json"
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
# ----------------------------
def to_mirror_url(raw_url: str) -> str:
"""
Convierte una URL raw de GitHub al raw del mirror.
GH : https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/docker.sh
MIR: https://git.community-scripts.org/community-scripts/ProxmoxVE/raw/branch/main/ct/docker.sh
"""
m = re.match(r"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)$", raw_url or "")
if not m:
return ""
org, repo, branch, path = m.groups()
if org.lower() != "community-scripts" or repo != "ProxmoxVE":
return ""
return f"https://git.community-scripts.org/community-scripts/ProxmoxVE/raw/branch/{branch}/{path}"
def guess_os_from_script_path(script_path: str) -> str | None:
"""
Heurística suave cuando el JSON no publica resources.os:
- tools/pve/* -> proxmox
- ct/alpine-* -> alpine
- tools/addon/* -> generic (suele ejecutarse sobre LXC existente)
- ct/* -> debian (por defecto para CTs)
"""
if not script_path:
return None
if script_path.startswith("tools/pve/") or script_path == "tools/pve/host-backup.sh" or script_path.startswith("vm/"):
return "proxmox"
if "/alpine-" in script_path or script_path.startswith("ct/alpine-"):
return "alpine"
if script_path.startswith("tools/addon/"):
return "generic"
if script_path.startswith("ct/"):
return "debian"
return None
def fetch_directory_json(api_url: str) -> list[dict]:
r = requests.get(api_url, timeout=30)
r.raise_for_status()
data = r.json()
if not isinstance(data, list):
raise RuntimeError("GitHub API no devolvió una lista.")
return data
def main() -> int:
try:
directory = fetch_directory_json(API_URL)
except Exception as e:
print(f"ERROR: No se pudo leer el índice de JSONs: {e}", file=sys.stderr)
return 1
cache: list[dict] = []
seen: set[tuple[str, str]] = set() # (slug, script) para evitar duplicados
total_items = len(directory)
processed = 0
kept = 0
for item in directory:
url = item.get("download_url")
name_in_dir = item.get("name", "")
if not url or not url.endswith(".json"):
continue
try:
raw = requests.get(url, timeout=30).json()
if not isinstance(raw, dict):
continue
except Exception:
print(f"❌ Error al obtener/parsing {name_in_dir}", file=sys.stderr)
continue
processed += 1
name = raw.get("name", "")
slug = raw.get("slug")
type_ = raw.get("type", "")
desc = raw.get("description", "")
categories = raw.get("categories", [])
notes = [n.get("text", "") for n in raw.get("notes", []) if isinstance(n, dict)]
# Credenciales (si existen, se copian tal cual)
credentials = raw.get("default_credentials", {})
cred_username = credentials.get("username") if isinstance(credentials, dict) else None
cred_password = credentials.get("password") if isinstance(credentials, dict) else None
add_credentials = any([
cred_username not in (None, ""),
cred_password not in (None, "")
])
install_methods = raw.get("install_methods", [])
if not isinstance(install_methods, list) or not install_methods:
# Sin install_methods válidos -> continuamos
continue
for im in install_methods:
if not isinstance(im, dict):
continue
script = im.get("script", "")
if not script:
continue
# OS desde resources u heurística
resources = im.get("resources", {}) if isinstance(im, dict) else {}
os_name = resources.get("os") if isinstance(resources, dict) else None
if not os_name:
os_name = guess_os_from_script_path(script)
if isinstance(os_name, str):
os_name = os_name.strip().lower()
full_script_url = f"{SCRIPT_BASE}/{script}"
script_url_mirror = to_mirror_url(full_script_url)
key = (slug or "", script)
if key in seen:
continue
seen.add(key)
entry = {
"name": name,
"slug": slug,
"desc": desc,
"script": script,
"script_url": full_script_url,
"script_url_mirror": script_url_mirror, # nuevo
"os": os_name, # nuevo
"categories": categories,
"notes": notes,
"type": type_,
}
if add_credentials:
entry["default_credentials"] = {
"username": cred_username,
"password": cred_password,
}
cache.append(entry)
kept += 1
# Progreso ligero
print(f"[{kept:03d}] {slug or name:<24}{script:<28} os={os_name or 'n/a'} src={'GH+MR' if script_url_mirror else 'GH'}")
# Orden estable para commits reproducibles
cache.sort(key=lambda x: (x.get("slug") or "", x.get("script") or ""))
with OUTPUT_FILE.open("w", encoding="utf-8") as f:
json.dump(cache, f, ensure_ascii=False, indent=2)
print(f"\n✅ helpers_cache.json → {OUTPUT_FILE}")
print(f" Total JSON en índice: {total_items}")
print(f" Procesados: {processed} | Guardados: {kept} | Únicos (slug,script): {len(seen)}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,83 @@
name: Build AppImage Release
on:
workflow_dispatch:
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build:
runs-on: ubuntu-22.04
steps:
- name: Checkout main
uses: actions/checkout@v6
with:
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install dependencies
working-directory: AppImage
run: npm install --legacy-peer-deps
- name: Build Next.js app
working-directory: AppImage
run: npm run build
- name: Install Python dependencies
run: |
sudo apt-get update
sudo apt-get install -y python3 python3-pip python3-venv
- name: Make build script executable
working-directory: AppImage
run: chmod +x scripts/build_appimage.sh
- name: Build AppImage
working-directory: AppImage
run: ./scripts/build_appimage.sh
- name: Get version from package.json
id: version
working-directory: AppImage
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Generate SHA256 checksum
run: |
cd AppImage/dist
sha256sum *.AppImage > ProxMenux-Monitor.AppImage.sha256
echo "Generated SHA256:"
cat ProxMenux-Monitor.AppImage.sha256
- name: Upload AppImage artifact
uses: actions/upload-artifact@v6
with:
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
path: |
AppImage/dist/*.AppImage
AppImage/dist/*.sha256
retention-days: 30
- name: Commit AppImage to main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
rm -f AppImage/*.AppImage AppImage/*.sha256 || true
cp AppImage/dist/*.AppImage AppImage/
cp AppImage/dist/ProxMenux-Monitor.AppImage.sha256 AppImage/
git add AppImage/*.AppImage AppImage/*.sha256
git commit -m "Update AppImage release build ($(date +'%Y-%m-%d %H:%M:%S'))" || echo "No changes to commit"
git push origin main
+3 -3
View File
@@ -15,13 +15,13 @@ jobs:
steps:
- name: Checkout develop
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: develop
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '22'
@@ -59,7 +59,7 @@ jobs:
cat ProxMenux-Monitor.AppImage.sha256
- name: Upload AppImage artifact
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: ProxMenux-${{ steps.version.outputs.VERSION }}-beta-AppImage
path: |
+3 -3
View File
@@ -18,10 +18,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '22'
@@ -52,7 +52,7 @@ jobs:
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Upload AppImage artifact
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
path: AppImage/dist/*.AppImage
+1 -1
View File
@@ -1 +1 @@
cd04577b4860ad1b66a7b906c381fa4c9ad384ce6e0cf0769ee7aa358399bc41 ProxMenux-1.0.2-beta.AppImage
0024ebd5201dc3b504aaa760b18ff6651338e3cae21e26e3cb8f4cc8b613b04a ProxMenux-1.2.0.AppImage
+12
View File
@@ -163,3 +163,15 @@
.xterm-rows {
margin: 0 !important;
}
/* ===================== */
/* Progress Animations */
/* ===================== */
@keyframes indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}
+13 -2
View File
@@ -29,6 +29,17 @@ export default function Home() {
const response = await fetch(getApiUrl("/api/auth/status"), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
// Check if response is valid JSON before parsing
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const contentType = response.headers.get("content-type")
if (!contentType || !contentType.includes("application/json")) {
throw new Error("Response is not JSON")
}
const data = await response.json()
const authenticated = data.auth_enabled ? data.authenticated : true
@@ -39,8 +50,8 @@ export default function Home() {
authConfigured: data.auth_configured,
authenticated,
})
} catch (error) {
console.error("Failed to check auth status:", error)
} catch {
// API not available - assume no auth configured (silent fail, no console error)
setAuthStatus({
loading: false,
authEnabled: false,
+14 -6
View File
@@ -27,18 +27,26 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
const checkOnboardingStatus = async () => {
try {
const response = await fetch(getApiUrl("/api/auth/status"))
// Check if response is valid JSON before parsing
if (!response.ok) {
// API not available - don't show modal in preview
return
}
const contentType = response.headers.get("content-type")
if (!contentType || !contentType.includes("application/json")) {
return
}
const data = await response.json()
console.log("[v0] Auth status for modal check:", data)
// Show modal if auth is not configured and not declined
if (!data.auth_configured) {
setTimeout(() => setOpen(true), 500)
}
} catch (error) {
console.error("[v0] Failed to check auth status:", error)
// Fail-safe: show modal if we can't check status
setTimeout(() => setOpen(true), 500)
} catch {
// API not available (preview environment) - don't show modal
}
}
@@ -0,0 +1,395 @@
"use client"
import { cn } from "@/lib/utils"
interface SriovInfo {
role: "vf" | "pf-active" | "pf-idle"
physfn?: string // VF only: parent PF BDF
vfCount?: number // PF only: active VF count
totalvfs?: number // PF only: maximum VFs
}
interface GpuSwitchModeIndicatorProps {
mode: "lxc" | "vm" | "sriov" | "unknown"
isEditing?: boolean
pendingMode?: "lxc" | "vm" | null
onToggle?: (e: React.MouseEvent) => void
className?: string
sriovInfo?: SriovInfo
}
export function GpuSwitchModeIndicator({
mode,
isEditing = false,
pendingMode = null,
onToggle,
className,
sriovInfo,
}: GpuSwitchModeIndicatorProps) {
// SR-IOV is a non-editable hardware state. Pending toggles don't apply here.
const displayMode = mode === "sriov" ? "sriov" : (pendingMode ?? mode)
const isLxcActive = displayMode === "lxc"
const isVmActive = displayMode === "vm"
const isSriovActive = displayMode === "sriov"
const hasChanged =
mode !== "sriov" && pendingMode !== null && pendingMode !== mode
// Colors
const sriovColor = "#14b8a6" // teal-500
const activeColor = isSriovActive
? sriovColor
: isLxcActive
? "#3b82f6"
: isVmActive
? "#a855f7"
: "#6b7280"
const inactiveColor = "#374151" // gray-700 for dark theme
const dimmedColor = "#4b5563" // gray-600 for dashed SR-IOV branches
const lxcColor = isLxcActive ? "#3b82f6" : inactiveColor
const vmColor = isVmActive ? "#a855f7" : inactiveColor
const handleClick = (e: React.MouseEvent) => {
// SR-IOV state can't be toggled — swallow the click so it doesn't reach
// the card (which would open the detail modal unexpectedly from this
// area). For lxc/vm, preserve the original behavior.
if (isSriovActive) {
e.stopPropagation()
return
}
if (isEditing) {
e.stopPropagation()
if (onToggle) {
onToggle(e)
}
}
// When not editing, let the click propagate to the card to open the modal
}
// Build the VF count label shown in the SR-IOV badge. For PFs we know
// exactly how many VFs are active; for a VF we show its parent PF.
const sriovBadgeText = (() => {
if (!isSriovActive) return ""
if (sriovInfo?.role === "vf") return "SR-IOV VF"
if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) return `SR-IOV ×${sriovInfo.vfCount}`
return "SR-IOV"
})()
return (
<div
className={cn(
"flex items-center gap-6",
isEditing && !isSriovActive && "cursor-pointer",
className
)}
onClick={handleClick}
>
{/* Large SVG Diagram */}
<svg
viewBox="0 0 220 100"
className="h-24 w-56 flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
>
{/* GPU Chip - Large with "GPU" text */}
<g transform="translate(0, 22)">
{/* Main chip body */}
<rect
x="4"
y="8"
width="44"
height="36"
rx="6"
fill={`${activeColor}20`}
stroke={activeColor}
strokeWidth="2.5"
className="transition-all duration-300"
/>
{/* Chip pins - top */}
<line x1="14" y1="2" x2="14" y2="8" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
<line x1="26" y1="2" x2="26" y2="8" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
<line x1="38" y1="2" x2="38" y2="8" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
{/* Chip pins - bottom */}
<line x1="14" y1="44" x2="14" y2="50" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
<line x1="26" y1="44" x2="26" y2="50" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
<line x1="38" y1="44" x2="38" y2="50" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
{/* GPU text */}
<text
x="26"
y="32"
textAnchor="middle"
fill={activeColor}
className="text-[14px] font-bold transition-all duration-300"
style={{ fontFamily: 'system-ui, sans-serif' }}
>
GPU
</text>
</g>
{/* Connection line from GPU to switch */}
<line
x1="52"
y1="50"
x2="78"
y2="50"
stroke={activeColor}
strokeWidth="3"
strokeLinecap="round"
className="transition-all duration-300"
/>
{/* Central Switch Node - Large circle with inner dot */}
<circle
cx="95"
cy="50"
r="14"
fill={isEditing && !isSriovActive ? "#f59e0b20" : `${activeColor}20`}
stroke={isEditing && !isSriovActive ? "#f59e0b" : activeColor}
strokeWidth="3"
className="transition-all duration-300"
/>
<circle
cx="95"
cy="50"
r="6"
fill={isEditing && !isSriovActive ? "#f59e0b" : activeColor}
className="transition-all duration-300"
/>
{/* LXC Branch Line - going up-right.
In SR-IOV mode the branch is dashed + dimmed to show that the
target is theoretically reachable via a VF but not controlled
by ProxMenux. */}
<path
d="M 109 42 L 135 20"
fill="none"
stroke={isSriovActive ? dimmedColor : lxcColor}
strokeWidth={isLxcActive ? "3.5" : "2"}
strokeLinecap="round"
strokeDasharray={isSriovActive ? "3 3" : undefined}
className="transition-all duration-300"
/>
{/* VM Branch Line - going down-right (dashed/dimmed in SR-IOV). */}
<path
d="M 109 58 L 135 80"
fill="none"
stroke={isSriovActive ? dimmedColor : vmColor}
strokeWidth={isVmActive ? "3.5" : "2"}
strokeLinecap="round"
strokeDasharray={isSriovActive ? "3 3" : undefined}
className="transition-all duration-300"
/>
{/* SR-IOV in-line connector + badge (only when mode === 'sriov').
A horizontal line from the switch node leads to a pill-shaped
badge carrying the "SR-IOV ×N" label. Placed on the GPU's
baseline to visually read as an in-line extension, not as a
third branch. */}
{isSriovActive && (
<>
<line
x1="109"
y1="50"
x2="130"
y2="50"
stroke={sriovColor}
strokeWidth="3"
strokeLinecap="round"
className="transition-all duration-300"
/>
<rect
x="132"
y="40"
width="60"
height="20"
rx="10"
fill={`${sriovColor}25`}
stroke={sriovColor}
strokeWidth="2"
className="transition-all duration-300"
/>
<text
x="162"
y="54"
textAnchor="middle"
fill={sriovColor}
className="text-[11px] font-bold transition-all duration-300"
style={{ fontFamily: 'system-ui, sans-serif' }}
>
{sriovBadgeText}
</text>
</>
)}
{/* LXC Container Icon - dimmed/smaller in SR-IOV mode. */}
{!isSriovActive && (
<g transform="translate(138, 2)">
<rect
x="0"
y="0"
width="32"
height="28"
rx="4"
fill={isLxcActive ? `${lxcColor}25` : "transparent"}
stroke={lxcColor}
strokeWidth={isLxcActive ? "2.5" : "1.5"}
className="transition-all duration-300"
/>
<line x1="0" y1="10" x2="32" y2="10" stroke={lxcColor} strokeWidth={isLxcActive ? "1.5" : "1"} className="transition-all duration-300" />
<line x1="0" y1="19" x2="32" y2="19" stroke={lxcColor} strokeWidth={isLxcActive ? "1.5" : "1"} className="transition-all duration-300" />
<circle cx="7" cy="5" r="2" fill={lxcColor} className="transition-all duration-300" />
<circle cx="7" cy="14.5" r="2" fill={lxcColor} className="transition-all duration-300" />
<circle cx="7" cy="23.5" r="2" fill={lxcColor} className="transition-all duration-300" />
</g>
)}
{/* SR-IOV: compact dimmed LXC glyph so the geometry stays recognizable
but it's clearly not the active target. */}
{isSriovActive && (
<g transform="translate(138, 6)" opacity="0.35">
<rect x="0" y="0" width="20" height="18" rx="3" fill="transparent" stroke={dimmedColor} strokeWidth="1.5" />
<line x1="0" y1="6" x2="20" y2="6" stroke={dimmedColor} strokeWidth="1" />
<line x1="0" y1="12" x2="20" y2="12" stroke={dimmedColor} strokeWidth="1" />
</g>
)}
{/* LXC Label */}
{!isSriovActive && (
<text
x="188"
y="22"
textAnchor="start"
fill={lxcColor}
className={cn(
"transition-all duration-300",
isLxcActive ? "text-[14px] font-bold" : "text-[12px] font-medium"
)}
style={{ fontFamily: 'system-ui, sans-serif' }}
>
LXC
</text>
)}
{isSriovActive && (
<text
x="162"
y="16"
fill={dimmedColor}
className="text-[9px] font-medium"
style={{ fontFamily: 'system-ui, sans-serif' }}
>
LXC
</text>
)}
{/* VM Monitor Icon - active view */}
{!isSriovActive && (
<g transform="translate(138, 65)">
<rect
x="2"
y="0"
width="28"
height="18"
rx="3"
fill={isVmActive ? `${vmColor}25` : "transparent"}
stroke={vmColor}
strokeWidth={isVmActive ? "2.5" : "1.5"}
className="transition-all duration-300"
/>
<rect
x="5"
y="3"
width="22"
height="12"
rx="1"
fill={isVmActive ? `${vmColor}30` : `${vmColor}10`}
className="transition-all duration-300"
/>
<line x1="16" y1="18" x2="16" y2="24" stroke={vmColor} strokeWidth={isVmActive ? "2.5" : "1.5"} strokeLinecap="round" className="transition-all duration-300" />
<line x1="8" y1="24" x2="24" y2="24" stroke={vmColor} strokeWidth={isVmActive ? "2.5" : "1.5"} strokeLinecap="round" className="transition-all duration-300" />
</g>
)}
{/* SR-IOV: compact dimmed VM monitor glyph, mirror of the LXC glyph. */}
{isSriovActive && (
<g transform="translate(138, 72)" opacity="0.35">
<rect x="0" y="0" width="20" height="13" rx="2" fill="transparent" stroke={dimmedColor} strokeWidth="1.5" />
<line x1="10" y1="13" x2="10" y2="17" stroke={dimmedColor} strokeWidth="1.5" strokeLinecap="round" />
<line x1="5" y1="17" x2="15" y2="17" stroke={dimmedColor} strokeWidth="1.5" strokeLinecap="round" />
</g>
)}
{/* VM Label */}
{!isSriovActive && (
<text
x="188"
y="84"
textAnchor="start"
fill={vmColor}
className={cn(
"transition-all duration-300",
isVmActive ? "text-[14px] font-bold" : "text-[12px] font-medium"
)}
style={{ fontFamily: 'system-ui, sans-serif' }}
>
VM
</text>
)}
{isSriovActive && (
<text
x="162"
y="82"
fill={dimmedColor}
className="text-[9px] font-medium"
style={{ fontFamily: 'system-ui, sans-serif' }}
>
VM
</text>
)}
</svg>
{/* Status Text - Large like GPU name */}
<div className="flex flex-col gap-1 min-w-0 flex-1">
<span
className={cn(
"text-base font-semibold transition-all duration-300",
isSriovActive
? "text-teal-500"
: isLxcActive
? "text-blue-500"
: isVmActive
? "text-purple-500"
: "text-muted-foreground"
)}
>
{isSriovActive
? "SR-IOV active"
: isLxcActive
? "Ready for LXC containers"
: isVmActive
? "Ready for VM passthrough"
: "Mode unknown"}
</span>
<span className="text-sm text-muted-foreground">
{isSriovActive
? "Virtual Functions managed externally"
: isLxcActive
? "Native driver active"
: isVmActive
? "VFIO-PCI driver active"
: "No driver detected"}
</span>
{isSriovActive && sriovInfo && (
<span className="text-xs font-mono text-teal-600/80 dark:text-teal-400/80">
{sriovInfo.role === "vf"
? `Virtual Function${sriovInfo.physfn ? ` · parent PF ${sriovInfo.physfn}` : ""}`
: sriovInfo.vfCount !== undefined
? `1 PF + ${sriovInfo.vfCount} VF${sriovInfo.vfCount === 1 ? "" : "s"}${sriovInfo.totalvfs ? ` / ${sriovInfo.totalvfs} max` : ""}`
: null}
</span>
)}
{hasChanged && (
<span className="text-sm text-amber-500 font-medium animate-pulse">
Change pending...
</span>
)}
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+45 -5
View File
@@ -375,12 +375,28 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
body: JSON.stringify({ error_key: errorKey }),
})
const responseData = await response.json().catch(() => ({}))
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || `Failed to dismiss error (${response.status})`)
throw new Error(responseData.error || `Failed to dismiss error (${response.status})`)
}
await fetchHealthDetails()
// Optimistically update local state to avoid slow re-fetch
// Add the dismissed item to the local list immediately
if (responseData.result || responseData.success) {
const dismissedItem = {
error_key: errorKey,
category: responseData.result?.category || responseData.category || '',
severity: responseData.result?.original_severity || 'WARNING',
reason: 'Dismissed by user',
dismissed: true,
acknowledged_at: new Date().toISOString()
}
setDismissedItems(prev => [...prev, dismissedItem])
}
// Fetch fresh data in background (non-blocking)
fetchHealthDetails().catch(() => {})
} catch (err) {
console.error("Error dismissing:", err)
} finally {
@@ -501,7 +517,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
)}
</div>
<div className="flex items-center gap-1 sm:gap-1.5 shrink-0">
{(checkStatus === "WARNING" || checkStatus === "CRITICAL") && isDismissable && !checkData.dismissed && (
{(checkStatus === "WARNING" || checkStatus === "CRITICAL" || checkStatus === "UNKNOWN") && isDismissable && !checkData.dismissed && (
<Button
size="sm"
variant="outline"
@@ -661,7 +677,31 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{isExpanded && (
<div className="border-t border-border/50 bg-muted/5 px-1.5 sm:px-2 py-1.5 overflow-hidden">
{reason && (
<p className="text-xs text-muted-foreground px-3 py-1.5 mb-1 break-words whitespace-pre-wrap">{reason}</p>
<div className="flex items-center justify-between gap-2 px-3 py-1.5 mb-1">
<p className="text-xs text-muted-foreground break-words whitespace-pre-wrap flex-1">{reason}</p>
{/* Show dismiss button for UNKNOWN status at category level when dismissable */}
{status === "UNKNOWN" && categoryData?.dismissable && !hasChecks && (
<Button
size="sm"
variant="outline"
className="h-5 px-1.5 shrink-0 hover:bg-red-500/10 hover:border-red-500/50 bg-transparent text-[10px]"
disabled={dismissingKey === `category_${key}`}
onClick={(e) => {
e.stopPropagation()
handleAcknowledge(`category_${key}_unknown`, e)
}}
>
{dismissingKey === `category_${key}` ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<>
<X className="h-3 w-3 sm:mr-0.5" />
<span className="hidden sm:inline">Dismiss</span>
</>
)}
</Button>
)}
</div>
)}
{hasChecks ? (
renderChecks(checks, key)
+18 -4
View File
@@ -7,7 +7,7 @@ import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Checkbox } from "./ui/checkbox"
import { Lock, User, AlertCircle, Server, Shield } from "lucide-react"
import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
import Image from "next/image"
@@ -21,6 +21,7 @@ export function Login({ onLogin }: LoginProps) {
const [totpCode, setTotpCode] = useState("")
const [requiresTotp, setRequiresTotp] = useState(false)
const [rememberMe, setRememberMe] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
@@ -161,14 +162,27 @@ export function Login({ onLogin }: LoginProps) {
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="login-password"
type="password"
type={showPassword ? "text" : "password"}
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10 text-base"
className="pl-10 pr-10 text-base"
disabled={loading}
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
disabled={loading}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
@@ -237,7 +251,7 @@ export function Login({ onLogin }: LoginProps) {
</form>
</div>
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.0.2-beta</p>
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.0</p>
</div>
</div>
)
+2 -2
View File
@@ -142,8 +142,8 @@ export function NetworkMetrics() {
error,
isLoading,
} = useSWR<NetworkData>("/api/network", fetcher, {
refreshInterval: 53000,
revalidateOnFocus: false,
refreshInterval: 15000,
revalidateOnFocus: true,
revalidateOnReconnect: true,
})
+547 -131
View File
@@ -9,13 +9,14 @@ import { Label } from "./ui/label"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
import { fetchApi } from "../lib/api-config"
import {
Bell, BellOff, Send, CheckCircle2, XCircle, Loader2,
AlertTriangle, Info, Settings2, Zap, Eye, EyeOff,
Trash2, ChevronDown, ChevronUp, ChevronRight, TestTube2, Mail, Webhook,
Copy, Server, Shield, ExternalLink, RefreshCw
Copy, Server, Shield, ExternalLink, RefreshCw, Download, Upload,
Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb
} from "lucide-react"
interface ChannelConfig {
@@ -23,6 +24,7 @@ interface ChannelConfig {
rich_format?: boolean
bot_token?: string
chat_id?: string
topic_id?: string // Telegram topic ID for supergroups with topics
url?: string
token?: string
webhook_url?: string
@@ -63,6 +65,9 @@ interface NotificationConfig {
ai_language: string
ai_ollama_url: string
ai_openai_base_url: string
ai_prompt_mode: string // 'default' or 'custom'
ai_custom_prompt: string // User's custom prompt
ai_allow_suggestions: string | boolean // Enable AI suggestions (experimental)
channel_ai_detail: Record<string, string>
hostname: string
webhook_secret: string
@@ -180,6 +185,30 @@ const AI_DETAIL_LEVELS = [
{ value: "detailed", label: "Detailed", desc: "Complete technical details" },
]
// Example custom prompt for users to adapt
const EXAMPLE_CUSTOM_PROMPT = `You are a notification formatter for ProxMenux Monitor.
Your task is to translate and format server notifications.
RULES:
1. Translate to the user's preferred language
2. Use plain text only (no markdown, no bold, no italic)
3. Be concise and factual
4. Do not add recommendations or suggestions
5. Present only the facts from the input
6. Keep hostname prefix in titles (e.g., "pve01: ")
OUTPUT FORMAT:
[TITLE]
your translated title here
[BODY]
your translated message here
Detail levels:
- brief: 2-3 lines, essential only
- standard: short paragraph with key details
- detailed: full technical breakdown`
const DEFAULT_CONFIG: NotificationConfig = {
enabled: false,
channels: {
@@ -222,6 +251,9 @@ const DEFAULT_CONFIG: NotificationConfig = {
ai_language: "en",
ai_ollama_url: "http://localhost:11434",
ai_openai_base_url: "",
ai_prompt_mode: "default",
ai_custom_prompt: "",
ai_allow_suggestions: "false",
channel_ai_detail: {
telegram: "brief",
gotify: "brief",
@@ -259,17 +291,33 @@ export function NotificationSettings() {
const [aiTestResult, setAiTestResult] = useState<{ success: boolean; message: string; model?: string } | null>(null)
const [providerModels, setProviderModels] = useState<string[]>([])
const [loadingProviderModels, setLoadingProviderModels] = useState(false)
const [showCustomPromptInfo, setShowCustomPromptInfo] = useState(false)
const [editingCustomPrompt, setEditingCustomPrompt] = useState(false)
const [customPromptDraft, setCustomPromptDraft] = useState("")
const [webhookSetup, setWebhookSetup] = useState<{
status: "idle" | "running" | "success" | "failed"
fallback_commands: string[]
error: string
}>({ status: "idle", fallback_commands: [], error: "" })
const [systemHostname, setSystemHostname] = useState<string>("")
// Load system hostname for display name placeholder
const loadSystemHostname = useCallback(async () => {
try {
const data = await fetchApi<{ hostname?: string }>("/api/system")
if (data.hostname) {
setSystemHostname(data.hostname)
}
} catch {
// Ignore - will show generic placeholder
}
}, [])
const loadConfig = useCallback(async () => {
try {
const data = await fetchApi<{ success: boolean; config: NotificationConfig }>("/api/notifications/settings")
if (data.success && data.config) {
// Ensure ai_api_keys and ai_models objects exist (fallback for older configs)
// Ensure ai_api_keys, ai_models, and prompt settings exist (fallback for older configs)
const configWithDefaults = {
...data.config,
ai_api_keys: data.config.ai_api_keys || {
@@ -287,7 +335,10 @@ export function NotificationSettings() {
anthropic: "",
openai: "",
openrouter: "",
}
},
ai_prompt_mode: data.config.ai_prompt_mode || "default",
ai_custom_prompt: data.config.ai_custom_prompt || "",
ai_allow_suggestions: data.config.ai_allow_suggestions || "false",
}
// If ai_model exists but ai_models doesn't have it, save it
if (configWithDefaults.ai_model && !configWithDefaults.ai_models[configWithDefaults.ai_provider]) {
@@ -328,12 +379,20 @@ export function NotificationSettings() {
useEffect(() => {
loadConfig()
loadStatus()
}, [loadConfig, loadStatus])
loadSystemHostname()
}, [loadConfig, loadStatus, loadSystemHostname])
useEffect(() => {
if (showHistory) loadHistory()
}, [showHistory, loadHistory])
// Auto-expand AI section when AI is enabled
useEffect(() => {
if (config.ai_enabled) {
setShowAdvanced(true)
}
}, [config.ai_enabled])
const updateConfig = (updater: (prev: NotificationConfig) => NotificationConfig) => {
setConfig(prev => {
const next = updater(prev)
@@ -495,21 +554,24 @@ export function NotificationSettings() {
/** Flatten the nested NotificationConfig into the flat key-value map the backend expects. */
const flattenConfig = (cfg: NotificationConfig): Record<string, string> => {
const flat: Record<string, string> = {
enabled: String(cfg.enabled),
ai_enabled: String(cfg.ai_enabled),
ai_provider: cfg.ai_provider,
ai_model: cfg.ai_model,
ai_language: cfg.ai_language,
ai_ollama_url: cfg.ai_ollama_url,
ai_openai_base_url: cfg.ai_openai_base_url,
hostname: cfg.hostname,
webhook_secret: cfg.webhook_secret,
webhook_allowed_ips: cfg.webhook_allowed_ips,
pbs_host: cfg.pbs_host,
pve_host: cfg.pve_host,
pbs_trusted_sources: cfg.pbs_trusted_sources,
}
const flat: Record<string, string> = {
enabled: String(cfg.enabled),
ai_enabled: String(cfg.ai_enabled),
ai_provider: cfg.ai_provider,
ai_model: cfg.ai_model,
ai_language: cfg.ai_language,
ai_ollama_url: cfg.ai_ollama_url,
ai_openai_base_url: cfg.ai_openai_base_url,
ai_prompt_mode: cfg.ai_prompt_mode || "default",
ai_custom_prompt: cfg.ai_custom_prompt || "",
ai_allow_suggestions: String(cfg.ai_allow_suggestions === "true" || cfg.ai_allow_suggestions === true),
hostname: cfg.hostname,
webhook_secret: cfg.webhook_secret,
webhook_allowed_ips: cfg.webhook_allowed_ips,
pbs_host: cfg.pbs_host,
pve_host: cfg.pve_host,
pbs_trusted_sources: cfg.pbs_trusted_sources,
}
// Flatten per-provider API keys
if (cfg.ai_api_keys) {
for (const [provider, key] of Object.entries(cfg.ai_api_keys)) {
@@ -1061,10 +1123,11 @@ export function NotificationSettings() {
<div className="flex items-center gap-1.5">
<Input
type={showSecrets["tg_token"] ? "text" : "password"}
className="h-7 text-xs font-mono"
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="7595377878:AAGE6Fb2cy... (with or without 'bot' prefix)"
value={config.channels.telegram?.bot_token || ""}
onChange={e => updateChannel("telegram", "bot_token", e.target.value)}
disabled={!editMode}
/>
<button
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
@@ -1077,12 +1140,24 @@ export function NotificationSettings() {
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Chat ID</Label>
<Input
className="h-7 text-xs font-mono"
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="-1001234567890"
value={config.channels.telegram?.chat_id || ""}
onChange={e => updateChannel("telegram", "chat_id", e.target.value)}
disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Topic ID <span className="text-muted-foreground/60">(optional)</span></Label>
<Input
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="123456"
value={config.channels.telegram?.topic_id || ""}
onChange={e => updateChannel("telegram", "topic_id", e.target.value)}
disabled={!editMode}
/>
<p className="text-[10px] text-muted-foreground">For supergroups with topics enabled. Leave empty for regular chats.</p>
</div>
{/* Message format */}
<div className="flex items-center justify-between py-1">
<div>
@@ -1143,10 +1218,11 @@ export function NotificationSettings() {
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Server URL</Label>
<Input
className="h-7 text-xs font-mono"
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="https://gotify.example.com"
value={config.channels.gotify?.url || ""}
onChange={e => updateChannel("gotify", "url", e.target.value)}
disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
@@ -1154,10 +1230,11 @@ export function NotificationSettings() {
<div className="flex items-center gap-1.5">
<Input
type={showSecrets["gt_token"] ? "text" : "password"}
className="h-7 text-xs font-mono"
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="A_valid_gotify_token"
value={config.channels.gotify?.token || ""}
onChange={e => updateChannel("gotify", "token", e.target.value)}
disabled={!editMode}
/>
<button
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
@@ -1229,10 +1306,11 @@ export function NotificationSettings() {
<div className="flex items-center gap-1.5">
<Input
type={showSecrets["dc_hook"] ? "text" : "password"}
className="h-7 text-xs font-mono"
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="https://discord.com/api/webhooks/..."
value={config.channels.discord?.webhook_url || ""}
onChange={e => updateChannel("discord", "webhook_url", e.target.value)}
disabled={!editMode}
/>
<button
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
@@ -1303,19 +1381,21 @@ export function NotificationSettings() {
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">SMTP Host</Label>
<Input
className="h-7 text-xs font-mono"
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="smtp.gmail.com"
value={config.channels.email?.host || ""}
onChange={e => updateChannel("email", "host", e.target.value)}
disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Port</Label>
<Input
className="h-7 text-xs font-mono"
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="587"
value={config.channels.email?.port || ""}
onChange={e => updateChannel("email", "port", e.target.value)}
disabled={!editMode}
/>
</div>
</div>
@@ -1324,8 +1404,9 @@ export function NotificationSettings() {
<Select
value={config.channels.email?.tls_mode || "starttls"}
onValueChange={v => updateChannel("email", "tls_mode", v)}
disabled={!editMode}
>
<SelectTrigger className="h-7 text-xs">
<SelectTrigger className={`h-7 text-xs ${!editMode ? "opacity-50" : ""}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -1339,10 +1420,11 @@ export function NotificationSettings() {
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Username</Label>
<Input
className="h-7 text-xs font-mono"
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="user@example.com"
value={config.channels.email?.username || ""}
onChange={e => updateChannel("email", "username", e.target.value)}
disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
@@ -1350,10 +1432,11 @@ export function NotificationSettings() {
<div className="flex items-center gap-1.5">
<Input
type={showSecrets["em_pass"] ? "text" : "password"}
className="h-7 text-xs font-mono"
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="App password"
value={config.channels.email?.password || ""}
onChange={e => updateChannel("email", "password", e.target.value)}
disabled={!editMode}
/>
<button
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
@@ -1367,28 +1450,31 @@ export function NotificationSettings() {
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">From Address</Label>
<Input
className="h-7 text-xs font-mono"
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="proxmenux@yourdomain.com"
value={config.channels.email?.from_address || ""}
onChange={e => updateChannel("email", "from_address", e.target.value)}
disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">To Addresses (comma-separated)</Label>
<Input
className="h-7 text-xs font-mono"
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="admin@example.com, ops@example.com"
value={config.channels.email?.to_addresses || ""}
onChange={e => updateChannel("email", "to_addresses", e.target.value)}
disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Subject Prefix</Label>
<Input
className="h-7 text-xs font-mono"
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
placeholder="[ProxMenux]"
value={config.channels.email?.subject_prefix || "[ProxMenux]"}
onChange={e => updateChannel("email", "subject_prefix", e.target.value)}
disabled={!editMode}
/>
</div>
<div className="flex items-start gap-2 p-2 rounded-md bg-amber-500/10 border border-amber-500/20">
@@ -1433,29 +1519,84 @@ export function NotificationSettings() {
</div>{/* close bordered channel container */}
</div>
{/* ── Display Name ── */}
<div className="space-y-2 pb-3 border-b border-border/50">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-blue-400" />
<Label className="text-xs sm:text-sm text-foreground/80">Display Name</Label>
</div>
<Input
className={`h-9 text-sm ${!editMode ? "opacity-50 cursor-not-allowed" : ""}`}
placeholder={systemHostname || "System hostname"}
value={config.hostname || (editMode ? "" : systemHostname)}
onChange={e => updateConfig(p => ({ ...p, hostname: e.target.value }))}
disabled={!editMode}
readOnly={!editMode}
/>
<p className="text-xs text-muted-foreground">
Name shown in notifications. Edit to customize, or leave empty to use the system hostname.
</p>
</div>
{/* ── Advanced: AI Enhancement ── */}
<div>
<button
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors w-full py-1"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
<span className="font-medium uppercase tracking-wider">Advanced: AI Enhancement</span>
{config.ai_enabled && (
<Badge variant="outline" className="text-[9px] border-purple-500/30 text-purple-400 ml-1">
ON
</Badge>
<div className="flex items-center justify-between py-1">
<button
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
<span className="font-medium uppercase tracking-wider">Advanced: AI Enhancement</span>
{config.ai_enabled && (
<Badge variant="outline" className="text-[9px] border-purple-500/30 text-purple-400 ml-1">
ON
</Badge>
)}
</button>
{showAdvanced && (
<div className="flex items-center gap-2">
{editMode ? (
<>
<button
className="h-6 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
onClick={handleCancel}
disabled={saving}
>
Cancel
</button>
<button
className="h-6 px-2 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1"
onClick={handleSave}
disabled={saving || !hasChanges}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
Save
</button>
</>
) : (
<button
className="h-6 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1"
onClick={() => setEditMode(true)}
>
<Settings2 className="h-3 w-3" />
Edit
</button>
)}
</div>
)}
</button>
</div>
{showAdvanced && (
<div className="space-y-4 mt-3 p-4 rounded-lg bg-muted/30 border border-border/50">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium">AI-Enhanced Messages</span>
<p className="text-xs sm:text-sm text-muted-foreground">Use AI to generate contextual notification messages</p>
</div>
<button
{showAdvanced && (
<div className="space-y-4 mt-3 p-4 rounded-lg bg-muted/30 border border-border/50">
<div className="flex items-center justify-between">
<div className="flex items-start gap-3">
<Sparkles className="h-5 w-5 text-purple-400 mt-0.5 shrink-0" />
<div>
<span className="text-sm font-medium">AI-Enhanced Messages</span>
<p className="text-xs sm:text-sm text-muted-foreground">Use AI to generate contextual notification messages</p>
</div>
</div>
<button
className={`relative w-9 h-[18px] rounded-full transition-colors ${
config.ai_enabled ? "bg-purple-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
@@ -1475,6 +1616,7 @@ export function NotificationSettings() {
{/* Provider + Info button */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Cloud className="h-4 w-4 text-purple-400" />
<Label className="text-xs sm:text-sm text-foreground/80">Provider</Label>
<button
onClick={() => setShowProviderInfo(true)}
@@ -1594,7 +1736,10 @@ export function NotificationSettings() {
{/* Model - selector with Load button for all providers */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm text-foreground/80">Model</Label>
<div className="flex items-center gap-2">
<Brain className="h-4 w-4 text-blue-400" />
<Label className="text-xs sm:text-sm text-foreground/80">Model</Label>
</div>
<div className="flex items-center gap-2">
<Select
value={config.ai_model || ""}
@@ -1648,101 +1793,291 @@ export function NotificationSettings() {
)}
</div>
{/* Language selector */}
{/* Prompt Mode section */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm text-foreground/80">Language</Label>
<div className="flex items-center gap-2">
<MessageSquareText className="h-4 w-4 text-amber-400" />
<Label className="text-xs sm:text-sm text-foreground/80">Prompt Mode</Label>
</div>
<Select
value={config.ai_language || "en"}
onValueChange={v => updateConfig(p => ({ ...p, ai_language: v }))}
value={config.ai_prompt_mode || "default"}
onValueChange={v => {
updateConfig(p => ({ ...p, ai_prompt_mode: v }))
// Show info modal when switching to custom for the first time
if (v === "custom" && !config.ai_custom_prompt) {
setShowCustomPromptInfo(true)
}
}}
disabled={!editMode}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select language">
{AI_LANGUAGES.find(l => l.value === (config.ai_language || "en"))?.label || "English"}
</SelectValue>
<SelectValue />
</SelectTrigger>
<SelectContent>
{AI_LANGUAGES.map(l => (
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
))}
<SelectItem value="default">Default Prompt</SelectItem>
<SelectItem value="custom">Custom Prompt</SelectItem>
</SelectContent>
</Select>
</div>
{/* Test Connection button */}
<button
onClick={handleTestAI}
disabled={
!editMode ||
testingAI ||
!config.ai_model ||
(config.ai_provider !== "ollama" && !config.ai_api_keys?.[config.ai_provider])
}
className="w-full h-9 flex items-center justify-center gap-2 rounded-md text-sm font-medium bg-purple-600 hover:bg-purple-700 text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{testingAI ? (
<><Loader2 className="h-4 w-4 animate-spin" /> Testing...</>
) : (
<><Zap className="h-4 w-4" /> Test Connection</>
)}
</button>
{/* Test result */}
{aiTestResult && (
<div className={`flex items-start gap-2 p-3 rounded-md ${
aiTestResult.success
? "bg-green-500/10 border border-green-500/20"
: "bg-red-500/10 border border-red-500/20"
}`}>
{aiTestResult.success
? <CheckCircle2 className="h-4 w-4 text-green-400 shrink-0 mt-0.5" />
: <XCircle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
}
<p className={`text-xs sm:text-sm leading-relaxed ${
aiTestResult.success ? "text-green-400/90" : "text-red-400/90"
}`}>
{aiTestResult.message}
{aiTestResult.model && ` (${aiTestResult.model})`}
</p>
{/* Default mode options: Language and Detail Level per Channel */}
{(config.ai_prompt_mode || "default") === "default" && (
<div className="space-y-3 pt-3 border-t border-border/50">
{/* Language selector - only for default mode */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-green-400" />
<Label className="text-xs sm:text-sm text-foreground/80">Language</Label>
</div>
<Select
value={config.ai_language || "en"}
onValueChange={v => updateConfig(p => ({ ...p, ai_language: v }))}
disabled={!editMode}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select language">
{AI_LANGUAGES.find(l => l.value === (config.ai_language || "en"))?.label || "English"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{AI_LANGUAGES.map(l => (
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Detail Level per Channel */}
<div className="space-y-3">
<Label className="text-xs sm:text-sm text-foreground/80">Detail Level per Channel</Label>
<div className="grid grid-cols-2 gap-3">
{CHANNEL_TYPES.map(ch => (
<div key={ch} className="flex items-center justify-between gap-2 px-3 py-2 rounded bg-muted/30">
<span className="text-xs sm:text-sm text-foreground/70 capitalize">{ch}</span>
<Select
value={config.channel_ai_detail?.[ch] || "standard"}
onValueChange={v => updateConfig(p => ({
...p,
channel_ai_detail: { ...p.channel_ai_detail, [ch]: v }
}))}
disabled={!editMode}
>
<SelectTrigger className="h-7 w-[90px] text-xs px-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AI_DETAIL_LEVELS.map(l => (
<SelectItem key={l.value} value={l.value} className="text-xs">
{l.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
<div className="flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
<Info className="h-4 w-4 text-purple-400 shrink-0 mt-0.5" />
<p className="text-xs sm:text-sm text-purple-400/90 leading-relaxed">
AI translates and formats notifications to your selected language. Each channel can have different detail levels.
</p>
</div>
</div>
{/* Experimental: AI Suggestions toggle */}
<div className="flex items-center justify-between pt-3 border-t border-border/50">
<div className="flex items-start gap-3">
<Lightbulb className="h-5 w-5 text-purple-400 mt-0.5 shrink-0" />
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">AI Suggestions</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-400 font-medium">BETA</span>
</div>
<p className="text-xs sm:text-sm text-muted-foreground">
Allow AI to add brief troubleshooting tips based on log context
</p>
</div>
</div>
<button
className={`relative w-9 h-[18px] rounded-full transition-colors ${
config.ai_allow_suggestions === "true" || config.ai_allow_suggestions === true
? "bg-purple-600"
: "bg-muted-foreground/20 border border-muted-foreground/40"
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
onClick={() => {
if (editMode) {
const newValue = config.ai_allow_suggestions === "true" || config.ai_allow_suggestions === true ? "false" : "true"
updateConfig(p => ({ ...p, ai_allow_suggestions: newValue }))
}
}}
disabled={!editMode}
role="switch"
aria-checked={config.ai_allow_suggestions === "true" || config.ai_allow_suggestions === true}
>
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
config.ai_allow_suggestions === "true" || config.ai_allow_suggestions === true ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
</div>
)}
{/* Per-channel detail level */}
<div className="space-y-3 pt-3 border-t border-border/50">
<Label className="text-xs sm:text-sm text-foreground/80">Detail Level per Channel</Label>
<div className="grid grid-cols-2 gap-3">
{CHANNEL_TYPES.map(ch => (
<div key={ch} className="flex items-center justify-between gap-2 px-3 py-2 rounded bg-muted/30">
<span className="text-xs sm:text-sm text-foreground/70 capitalize">{ch}</span>
<Select
value={config.channel_ai_detail?.[ch] || "standard"}
onValueChange={v => updateConfig(p => ({
...p,
channel_ai_detail: { ...p.channel_ai_detail, [ch]: v }
}))}
disabled={!editMode}
>
<SelectTrigger className="h-7 w-[90px] text-xs px-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AI_DETAIL_LEVELS.map(l => (
<SelectItem key={l.value} value={l.value} className="text-xs">
{l.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Custom mode: Editable prompt textarea */}
{config.ai_prompt_mode === "custom" && (
<div className="space-y-3 pt-3 border-t border-border/50">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm text-foreground/80">Custom Prompt</Label>
<div className="flex gap-1">
{!editingCustomPrompt ? (
<Button
variant="outline"
size="sm"
onClick={() => {
setCustomPromptDraft(config.ai_custom_prompt || "")
setEditingCustomPrompt(true)
}}
className="h-7 px-2 text-xs flex items-center gap-1"
>
<Pencil className="h-3 w-3" />
Edit
</Button>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingCustomPrompt(false)
setCustomPromptDraft("")
}}
className="h-7 px-2 text-xs"
>
Cancel
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
updateConfig(p => ({ ...p, ai_custom_prompt: customPromptDraft }))
setEditingCustomPrompt(false)
handleSave()
}}
className="h-7 px-2 text-xs flex items-center gap-1 bg-blue-600 hover:bg-blue-700 text-white border-blue-600"
>
<Save className="h-3 w-3" />
Save
</Button>
</>
)}
</div>
</div>
<textarea
value={editingCustomPrompt ? customPromptDraft : (config.ai_custom_prompt || "")}
onChange={e => setCustomPromptDraft(e.target.value)}
disabled={!editingCustomPrompt}
placeholder="Enter your custom prompt instructions for the AI..."
className="w-full h-48 px-3 py-2 text-sm rounded-md border border-border bg-background resize-y focus:outline-none focus:ring-2 focus:ring-purple-500/50 disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
))}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={editingCustomPrompt}
onClick={() => {
const blob = new Blob([config.ai_custom_prompt || ""], { type: "text/plain" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "proxmenux_custom_prompt.txt"
a.click()
URL.revokeObjectURL(url)
}}
className="flex items-center gap-1"
>
<Download className="h-4 w-4" />
Export
</Button>
<Button
variant="outline"
size="sm"
disabled={editingCustomPrompt}
onClick={() => {
const input = document.createElement("input")
input.type = "file"
input.accept = ".txt,.md"
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) {
const text = await file.text()
updateConfig(p => ({ ...p, ai_custom_prompt: text }))
handleSave()
}
}
input.click()
}}
className="flex items-center gap-1"
>
<Upload className="h-4 w-4" />
Import
</Button>
<a
href="https://github.com/MacRimi/ProxMenux/discussions/categories/share-custom-prompts-for-ai-notifications"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-purple-400 hover:text-purple-300 transition-colors flex items-center gap-1"
>
Community prompts <ExternalLink className="h-3 w-3" />
</a>
</div>
<div className="flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
<Info className="h-4 w-4 text-purple-400 shrink-0 mt-0.5" />
<p className="text-xs sm:text-sm text-purple-400/90 leading-relaxed">
Define your own prompt rules and format. You control the detail level and style of all notifications. Export to share with others or import prompts from the community.
</p>
</div>
</div>
</div>
)}
<div className="flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
<Info className="h-4 w-4 text-purple-400 shrink-0 mt-0.5" />
<p className="text-xs sm:text-sm text-purple-400/90 leading-relaxed">
AI enhancement translates and formats notifications to your selected language. Each channel can have different detail levels. If the AI service is unavailable, standard templates are used as fallback.
</p>
{/* Test Connection button - moved to end */}
<div className="space-y-3 pt-3 border-t border-border/50">
<button
onClick={handleTestAI}
disabled={
!editMode ||
testingAI ||
!config.ai_model ||
(config.ai_provider !== "ollama" && !config.ai_api_keys?.[config.ai_provider])
}
className="w-full h-9 flex items-center justify-center gap-2 rounded-md text-sm font-medium bg-purple-600 hover:bg-purple-700 text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{testingAI ? (
<><Loader2 className="h-4 w-4 animate-spin" /> Testing...</>
) : (
<><Zap className="h-4 w-4" /> Test Connection</>
)}
</button>
{/* Test result */}
{aiTestResult && (
<div className={`flex items-start gap-2 p-3 rounded-md ${
aiTestResult.success
? "bg-green-500/10 border border-green-500/20"
: "bg-red-500/10 border border-red-500/20"
}`}>
{aiTestResult.success
? <CheckCircle2 className="h-4 w-4 text-green-400 shrink-0 mt-0.5" />
: <XCircle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
}
<p className={`text-xs sm:text-sm leading-relaxed ${
aiTestResult.success ? "text-green-400/90" : "text-red-400/90"
}`}>
{aiTestResult.message}
{aiTestResult.model && ` (${aiTestResult.model})`}
</p>
</div>
)}
</div>
</>
)}
@@ -1903,6 +2238,87 @@ export function NotificationSettings() {
</div>
</DialogContent>
</Dialog>
{/* Custom Prompt Info Modal */}
<Dialog open={showCustomPromptInfo} onOpenChange={setShowCustomPromptInfo}>
<DialogContent className="max-w-[90vw] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg">
<Settings2 className="h-5 w-5 text-purple-400" />
Custom Prompt Mode
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Create your own AI prompt for ProxMenux Monitor notifications
</DialogDescription>
</DialogHeader>
<div className="space-y-4 text-sm">
<div className="space-y-2">
<h4 className="font-medium text-foreground/90">What is a custom prompt?</h4>
<p className="text-muted-foreground text-xs leading-relaxed">
The prompt defines how the AI formats your notifications. With a custom prompt, you control the style, detail level, and format of all messages.
</p>
</div>
<div className="space-y-2">
<h4 className="font-medium text-foreground/90">Important requirements</h4>
<ul className="text-muted-foreground text-xs space-y-1.5">
<li className="flex items-start gap-2">
<span className="text-purple-400 mt-0.5">1.</span>
<span>Your prompt must output in this format:<br/>
<code className="bg-muted px-1.5 py-0.5 rounded text-[11px]">[TITLE]</code> followed by the title, then <code className="bg-muted px-1.5 py-0.5 rounded text-[11px]">[BODY]</code> followed by the message
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-purple-400 mt-0.5">2.</span>
<span>Use plain text only (no markdown) for compatibility with all channels</span>
</li>
<li className="flex items-start gap-2">
<span className="text-purple-400 mt-0.5">3.</span>
<span>The prompt receives raw Proxmox event data as input</span>
</li>
<li className="flex items-start gap-2">
<span className="text-purple-400 mt-0.5">4.</span>
<span>Define the output language in your prompt (the Language selector only applies to Default mode)</span>
</li>
</ul>
</div>
<div className="space-y-2">
<h4 className="font-medium text-foreground/90">Getting started</h4>
<p className="text-muted-foreground text-xs leading-relaxed">
We have added an example prompt to get you started. You can adapt it, export it to share with others, or import prompts from the community.
</p>
</div>
<div className="flex gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={() => {
updateConfig(p => ({ ...p, ai_custom_prompt: EXAMPLE_CUSTOM_PROMPT }))
setCustomPromptDraft(EXAMPLE_CUSTOM_PROMPT)
setEditingCustomPrompt(true)
setShowCustomPromptInfo(false)
}}
className="flex-1"
>
Load Example
</Button>
<Button
size="sm"
onClick={() => {
setCustomPromptDraft("")
setEditingCustomPrompt(true)
setShowCustomPromptInfo(false)
}}
className="flex-1 bg-purple-600 hover:bg-purple-700 text-white"
>
Start from Scratch
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}
+43 -11
View File
@@ -80,6 +80,7 @@ export function ProxmoxDashboard() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [activeTab, setActiveTab] = useState("overview")
const [infoCount, setInfoCount] = useState(0)
const [updateAvailable, setUpdateAvailable] = useState(false)
const [showNavigation, setShowNavigation] = useState(true)
const [lastScrollY, setLastScrollY] = useState(0)
const [showHealthModal, setShowHealthModal] = useState(false)
@@ -99,6 +100,19 @@ export function ProxmoxDashboard() {
{ key: "security", category: "security" },
]
// Fetch ProxMenux update status
const fetchUpdateStatus = useCallback(async () => {
try {
const response = await fetchApi("/api/proxmenux/update-status")
if (response?.success && response?.update_available) {
const { stable, beta } = response.update_available
setUpdateAvailable(stable || beta)
}
} catch (error) {
// Silently fail - updateAvailable will remain false
}
}, [])
// Fetch health info count independently (for initial load and refresh)
const fetchHealthInfoCount = useCallback(async () => {
try {
@@ -178,9 +192,10 @@ export function ProxmoxDashboard() {
}, [])
useEffect(() => {
// Siempre fetch inicial
fetchSystemData()
fetchHealthInfoCount() // Fetch info count on initial load
// Siempre fetch inicial
fetchSystemData()
fetchHealthInfoCount()
fetchUpdateStatus()
// En overview: cada 30 segundos para actualización frecuente del estado de salud
// En otras tabs: cada 60 segundos para reducir carga
@@ -198,7 +213,7 @@ export function ProxmoxDashboard() {
if (interval) clearInterval(interval)
if (healthInterval) clearInterval(healthInterval)
}
}, [fetchSystemData, fetchHealthInfoCount, activeTab])
}, [fetchSystemData, fetchHealthInfoCount, fetchUpdateStatus, activeTab])
useEffect(() => {
const handleChangeTab = (event: CustomEvent) => {
@@ -213,6 +228,24 @@ export function ProxmoxDashboard() {
window.removeEventListener("changeTab", handleChangeTab as EventListener)
}
}, [])
// Auto-refresh terminal on mobile devices
// This fixes the issue where terminal doesn't connect properly on mobile/VPN
useEffect(() => {
if (activeTab === "terminal") {
const isMobileDevice = window.innerWidth < 768 ||
('ontouchstart' in window && navigator.maxTouchPoints > 0)
if (isMobileDevice) {
// Delay to allow initial connection attempt, then refresh to ensure proper connection
const timeoutId = setTimeout(() => {
setComponentKey(prev => prev + 1)
}, 500)
return () => clearTimeout(timeoutId)
}
}
}, [activeTab])
useEffect(() => {
const handleHealthStatusUpdate = (event: CustomEvent) => {
@@ -376,14 +409,13 @@ export function ProxmoxDashboard() {
<div className="flex items-center space-x-2 md:space-x-3 min-w-0">
<div className="w-16 h-16 md:w-10 md:h-10 relative flex items-center justify-center bg-primary/10 flex-shrink-0">
<Image
src="/images/proxmenux-logo.png"
src={updateAvailable ? "/images/proxmenux_update-logo.png" : "/images/proxmenux-logo.png"}
alt="ProxMenux Logo"
width={64}
height={64}
className="object-contain md:w-10 md:h-10"
priority
onError={(e) => {
console.log("[v0] Logo failed to load, using fallback icon")
const target = e.target as HTMLImageElement
target.style.display = "none"
const fallback = target.parentElement?.querySelector(".fallback-icon")
@@ -491,14 +523,14 @@ export function ProxmoxDashboard() {
<div
className={`sticky z-40 bg-background
top-[120px] md:top-[76px]
top-[120px] lg:top-[76px]
transition-all duration-700 ease-in-out
${showNavigation ? "translate-y-0 opacity-100" : "-translate-y-[120%] opacity-0 pointer-events-none"}
`}
>
<div className="container mx-auto px-4 md:px-6 pt-4 md:pt-6">
<div className="container mx-auto px-4 lg:px-6 pt-4 lg:pt-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-0">
<TabsList className="hidden md:grid w-full grid-cols-9 bg-card border border-border">
<TabsList className="hidden lg:grid w-full grid-cols-9 bg-card border border-border">
<TabsTrigger
value="overview"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
@@ -556,7 +588,7 @@ export function ProxmoxDashboard() {
</TabsList>
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<div className="md:hidden">
<div className="lg:hidden">
<SheetTrigger asChild>
<Button
variant="outline"
@@ -753,7 +785,7 @@ export function ProxmoxDashboard() {
</Tabs>
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
<p className="font-medium mb-2">ProxMenux Monitor v1.0.2-beta</p>
<p className="font-medium mb-2">ProxMenux Monitor v1.2.0</p>
<p>
<a
href="https://ko-fi.com/macrimi"
+1 -1
View File
@@ -6,7 +6,7 @@ import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
import { X, Sparkles, Thermometer, Terminal, Activity, HardDrive, Bell, Shield, Globe, Cpu, Zap } from "lucide-react"
import { Checkbox } from "./ui/checkbox"
const APP_VERSION = "1.0.2-beta" // Sync with AppImage/package.json
const APP_VERSION = "1.2.0" // Sync with AppImage/package.json
interface ReleaseNote {
date: string
+25 -11
View File
@@ -43,6 +43,8 @@ interface ScriptTerminalModalProps {
scriptPath: string
title: string
description: string
scriptName?: string
params?: Record<string, string>
}
export function ScriptTerminalModal({
@@ -51,6 +53,7 @@ export function ScriptTerminalModal({
scriptPath,
title,
description,
params = { EXECUTION_MODE: "web" },
}: ScriptTerminalModalProps) {
const termRef = useRef<any>(null)
const wsRef = useRef<WebSocket | null>(null)
@@ -77,6 +80,12 @@ export function ScriptTerminalModal({
const modalHeightRef = useRef(600)
const terminalContainerRef = useRef<HTMLDivElement>(null)
const paramsRef = useRef(params)
// Keep paramsRef updated with latest params
useEffect(() => {
paramsRef.current = params
}, [params])
const attemptReconnect = useCallback(() => {
if (!isOpen || isComplete || reconnectAttemptsRef.current >= 3) {
@@ -113,13 +122,11 @@ export function ScriptTerminalModal({
}
}, 30000)
const initMessage = {
script_path: scriptPath,
params: {
EXECUTION_MODE: "web",
},
}
ws.send(JSON.stringify(initMessage))
const initMessage = {
script_path: scriptPath,
params: paramsRef.current,
}
ws.send(JSON.stringify(initMessage))
setTimeout(() => {
if (fitAddonRef.current && termRef.current && ws.readyState === WebSocket.OPEN) {
@@ -131,6 +138,11 @@ export function ScriptTerminalModal({
}
ws.onmessage = (event) => {
// Filter out pong responses from heartbeat
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
return
}
try {
const msg = JSON.parse(event.data)
if (msg.type === "web_interaction" && msg.interaction) {
@@ -277,11 +289,8 @@ export function ScriptTerminalModal({
const initMessage = {
script_path: scriptPath,
params: {
EXECUTION_MODE: "web",
},
params: paramsRef.current,
}
ws.send(JSON.stringify(initMessage))
setTimeout(() => {
@@ -300,6 +309,11 @@ export function ScriptTerminalModal({
}
ws.onmessage = (event) => {
// Filter out pong responses from heartbeat - don't display in terminal
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
return
}
try {
const msg = JSON.parse(event.data)
+37 -31
View File
@@ -1204,41 +1204,47 @@ export function SecureGatewaySetup() {
}
}
}}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-cyan-500" />
Secure Gateway Setup
</DialogTitle>
</DialogHeader>
<DialogContent className="max-w-lg max-h-[90vh] sm:max-h-[85vh] flex flex-col p-0 gap-0">
{/* Fixed Header */}
<div className="shrink-0 px-6 pt-6 pb-4 border-b border-border">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-cyan-500" />
Secure Gateway Setup
</DialogTitle>
</DialogHeader>
{/* Progress indicator - filter out "options" step if using Proxmox Only */}
<div className="flex items-center gap-1 mb-4">
{wizardSteps
.filter((step) => !(config.access_mode === "host_only" && step.id === "options"))
.map((step, idx) => {
// Recalculate the actual step index accounting for skipped steps
const actualIdx = wizardSteps.findIndex((s) => s.id === step.id)
const adjustedCurrentStep = config.access_mode === "host_only"
? (currentStep > wizardSteps.findIndex((s) => s.id === "options") ? currentStep - 1 : currentStep)
: currentStep
return (
<div
key={step.id}
className={`flex-1 h-1 rounded-full transition-colors ${
idx < adjustedCurrentStep ? "bg-cyan-500" :
idx === adjustedCurrentStep ? "bg-cyan-500" :
"bg-muted"
}`}
/>
)
})}
{/* Progress indicator - filter out "options" step if using Proxmox Only */}
<div className="flex items-center gap-1 mt-4">
{wizardSteps
.filter((step) => !(config.access_mode === "host_only" && step.id === "options"))
.map((step, idx) => {
// Recalculate the actual step index accounting for skipped steps
const actualIdx = wizardSteps.findIndex((s) => s.id === step.id)
const adjustedCurrentStep = config.access_mode === "host_only"
? (currentStep > wizardSteps.findIndex((s) => s.id === "options") ? currentStep - 1 : currentStep)
: currentStep
return (
<div
key={step.id}
className={`flex-1 h-1 rounded-full transition-colors ${
idx < adjustedCurrentStep ? "bg-cyan-500" :
idx === adjustedCurrentStep ? "bg-cyan-500" :
"bg-muted"
}`}
/>
)
})}
</div>
</div>
{renderWizardContent()}
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
{renderWizardContent()}
</div>
{/* Navigation */}
<div className="flex justify-between pt-4 border-t border-border">
{/* Fixed Footer with Navigation */}
<div className="shrink-0 flex justify-between px-6 py-4 border-t border-border bg-background">
<Button
variant="outline"
onClick={() => {
+272 -70
View File
@@ -109,6 +109,10 @@ export function Security() {
} | null>(null)
const [showFail2banInstaller, setShowFail2banInstaller] = useState(false)
const [showLynisInstaller, setShowLynisInstaller] = useState(false)
const [uninstallingFail2ban, setUninstallingFail2ban] = useState(false)
const [uninstallingLynis, setUninstallingLynis] = useState(false)
const [showFail2banUninstallConfirm, setShowFail2banUninstallConfirm] = useState(false)
const [showLynisUninstallConfirm, setShowLynisUninstallConfirm] = useState(false)
// Lynis audit state
interface LynisWarning { test_id: string; severity: string; description: string; solution: string; proxmox_context?: string; proxmox_expected?: boolean; proxmox_severity?: string }
@@ -251,6 +255,52 @@ export function Security() {
}
}
const handleUninstallFail2ban = async () => {
setUninstallingFail2ban(true)
setError("")
setSuccess("")
setShowFail2banUninstallConfirm(false)
try {
const data = await fetchApi("/api/security/fail2ban/uninstall", {
method: "POST",
})
if (data.success) {
setSuccess(data.message || "Fail2Ban has been uninstalled")
loadSecurityTools()
setF2bDetails(null)
} else {
setError(data.message || "Failed to uninstall Fail2Ban")
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to uninstall Fail2Ban")
} finally {
setUninstallingFail2ban(false)
}
}
const handleUninstallLynis = async () => {
setUninstallingLynis(true)
setError("")
setSuccess("")
setShowLynisUninstallConfirm(false)
try {
const data = await fetchApi("/api/security/lynis/uninstall", {
method: "POST",
})
if (data.success) {
setSuccess(data.message || "Lynis has been uninstalled")
loadSecurityTools()
setLynisReport(null)
} else {
setError(data.message || "Failed to uninstall Lynis")
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to uninstall Lynis")
} finally {
setUninstallingLynis(false)
}
}
const loadFail2banDetails = async () => {
try {
setF2bDetailsLoading(true)
@@ -591,11 +641,18 @@ export function Security() {
const checkAuthStatus = async () => {
try {
const response = await fetch(getApiUrl("/api/auth/status"))
// Check if response is valid JSON before parsing
if (!response.ok) return
const contentType = response.headers.get("content-type")
if (!contentType || !contentType.includes("application/json")) return
const data = await response.json()
setAuthEnabled(data.auth_enabled || false)
setTotpEnabled(data.totp_enabled || false)
} catch (err) {
console.error("Failed to check auth status:", err)
} catch {
// API not available (preview environment)
}
}
@@ -891,23 +948,31 @@ export function Security() {
}
const copyToClipboard = async (text: string) => {
// Preferred path (HTTPS / localhost). On plain HTTP the Promise rejects,
// so we catch and fall through to the textarea fallback.
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
} else {
const textarea = document.createElement("textarea")
textarea.value = text
textarea.style.position = "fixed"
textarea.style.left = "-9999px"
textarea.style.top = "-9999px"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
document.execCommand("copy")
document.body.removeChild(textarea)
return true
}
return true
} catch {
// fall through to execCommand fallback
}
try {
const textarea = document.createElement("textarea")
textarea.value = text
textarea.style.position = "fixed"
textarea.style.left = "-9999px"
textarea.style.top = "-9999px"
textarea.style.opacity = "0"
textarea.readOnly = true
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
const ok = document.execCommand("copy")
document.body.removeChild(textarea)
return ok
} catch {
return false
}
@@ -2956,16 +3021,34 @@ ${(report.sections && report.sections.length > 0) ? `
<Bug className="h-5 w-5 text-red-500" />
<CardTitle>Fail2Ban</CardTitle>
</div>
{fail2banInfo?.installed && fail2banInfo?.active && (
<Button
variant="ghost"
size="sm"
onClick={() => { loadFail2banDetails(); loadSecurityTools(); }}
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<RefreshCw className="h-3 w-3 mr-1" />
Refresh
</Button>
{fail2banInfo?.installed && (
<div className="flex items-center gap-1">
{fail2banInfo?.active && (
<Button
variant="ghost"
size="sm"
onClick={() => { loadFail2banDetails(); loadSecurityTools(); }}
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<RefreshCw className="h-3 w-3 mr-1" />
Refresh
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShowFail2banUninstallConfirm(true)}
disabled={uninstallingFail2ban}
className="h-8 px-3 text-xs border-red-500/30 text-red-500 hover:bg-red-500/10 hover:text-red-400 hover:border-red-500/50"
>
{uninstallingFail2ban ? (
<div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full mr-2" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
Uninstall
</Button>
</div>
)}
</div>
<CardDescription>
@@ -2980,20 +3063,15 @@ ${(report.sections && report.sections.length > 0) ? `
) : !fail2banInfo?.installed ? (
/* --- NOT INSTALLED --- */
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gray-500/10 flex items-center justify-center">
<Bug className="h-5 w-5 text-gray-500" />
</div>
<div>
<p className="font-medium">Fail2Ban Not Installed</p>
<p className="text-sm text-muted-foreground">Protect SSH, Proxmox web interface, and ProxMenux Monitor from brute force attacks</p>
</div>
</div>
<div className="px-3 py-1 rounded-full text-sm font-medium bg-gray-500/10 text-gray-500">
Not Installed
</div>
</div>
<div className="flex items-center gap-3 p-4 bg-muted/50 rounded-lg">
<div className="w-10 h-10 rounded-full bg-gray-500/10 flex items-center justify-center shrink-0">
<Bug className="h-5 w-5 text-gray-500" />
</div>
<div>
<p className="font-medium">Fail2Ban Not Installed</p>
<p className="text-sm text-muted-foreground">Protect SSH, Proxmox web interface, and ProxMenux Monitor from brute force attacks</p>
</div>
</div>
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<div className="flex items-start gap-3">
@@ -3417,9 +3495,27 @@ ${(report.sections && report.sections.length > 0) ? `
{/* Lynis */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Search className="h-5 w-5 text-cyan-500" />
<CardTitle>Lynis Security Audit</CardTitle>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Search className="h-5 w-5 text-cyan-500" />
<CardTitle>Lynis Security Audit</CardTitle>
</div>
{lynisInfo?.installed && (
<Button
variant="outline"
size="sm"
onClick={() => setShowLynisUninstallConfirm(true)}
disabled={uninstallingLynis}
className="h-8 px-3 text-xs border-red-500/30 text-red-500 hover:bg-red-500/10 hover:text-red-400 hover:border-red-500/50"
>
{uninstallingLynis ? (
<div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full mr-2" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
Uninstall
</Button>
)}
</div>
<CardDescription>
System security auditing tool that performs comprehensive security scans
@@ -3432,20 +3528,15 @@ ${(report.sections && report.sections.length > 0) ? `
</div>
) : !lynisInfo?.installed ? (
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gray-500/10 flex items-center justify-center">
<Search className="h-5 w-5 text-gray-500" />
</div>
<div>
<p className="font-medium">Lynis Not Installed</p>
<p className="text-sm text-muted-foreground">Comprehensive security auditing and hardening tool</p>
</div>
</div>
<div className="px-3 py-1 rounded-full text-sm font-medium bg-gray-500/10 text-gray-500">
Not Installed
</div>
</div>
<div className="flex items-center gap-3 p-4 bg-muted/50 rounded-lg">
<div className="w-10 h-10 rounded-full bg-gray-500/10 flex items-center justify-center shrink-0">
<Search className="h-5 w-5 text-gray-500" />
</div>
<div>
<p className="font-medium">Lynis Not Installed</p>
<p className="text-sm text-muted-foreground">Comprehensive security auditing and hardening tool</p>
</div>
</div>
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<div className="flex items-start gap-3">
@@ -3678,6 +3769,9 @@ ${(report.sections && report.sections.length > 0) ? `
<Printer className="h-3.5 w-3.5" />
<span className="hidden sm:inline">PDF</span>
</Button>
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${lynisShowReport ? "rotate-180" : ""}`} />
{/* Delete button separated with divider to prevent accidental clicks */}
<div className="hidden sm:block w-px h-5 bg-border mx-1" />
<Button
variant="ghost"
size="sm"
@@ -3694,12 +3788,11 @@ ${(report.sections && report.sections.length > 0) ? `
.catch(() => setError("Failed to delete report"))
}
}}
className="h-7 px-2 text-xs text-red-500 hover:text-red-400 hover:bg-red-500/10"
className="h-7 px-2 text-xs text-red-500 hover:text-red-400 hover:bg-red-500/10 ml-2 sm:ml-0"
title="Delete report"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${lynisShowReport ? "rotate-180" : ""}`} />
</div>
</button>
@@ -3726,26 +3819,34 @@ ${(report.sections && report.sections.length > 0) ? `
</div>
</div>
{/* Report tabs */}
<div className="flex gap-0 border-t border-border">
{/* Report tabs - responsive with shorter labels on mobile */}
<div className="flex gap-0 border-t border-border overflow-x-auto">
{(["overview", "checks", "warnings", "suggestions"] as const).map((tab) => (
<button
key={tab}
onClick={() => setLynisActiveTab(tab)}
className={`flex-1 px-3 py-2 text-xs font-medium transition-all flex items-center justify-center gap-1.5 border-r last:border-r-0 border-border ${
className={`flex-1 min-w-0 px-2 sm:px-3 py-2 text-xs font-medium transition-all flex items-center justify-center gap-1 sm:gap-1.5 border-r last:border-r-0 border-border ${
lynisActiveTab === tab
? "bg-cyan-500 text-white"
: "bg-muted/20 text-muted-foreground hover:text-foreground hover:bg-muted/40"
}`}
>
{tab === "overview" && <BarChart3 className="h-3 w-3" />}
{tab === "checks" && <Search className="h-3 w-3" />}
{tab === "warnings" && <TriangleAlert className="h-3 w-3" />}
{tab === "suggestions" && <Info className="h-3 w-3" />}
{tab === "overview" ? "Overview"
: tab === "checks" ? `Checks (${lynisReport.sections?.length || 0})`
: tab === "warnings" ? `Warnings (${lynisReport.warnings.length})`
: `Suggestions (${lynisReport.suggestions.length})`}
{tab === "overview" && <BarChart3 className="h-3 w-3 shrink-0" />}
{tab === "checks" && <Search className="h-3 w-3 shrink-0" />}
{tab === "warnings" && <TriangleAlert className="h-3 w-3 shrink-0" />}
{tab === "suggestions" && <Info className="h-3 w-3 shrink-0" />}
<span className="hidden sm:inline">
{tab === "overview" ? "Overview"
: tab === "checks" ? `Checks (${lynisReport.sections?.length || 0})`
: tab === "warnings" ? `Warnings (${lynisReport.warnings.length})`
: `Suggestions (${lynisReport.suggestions.length})`}
</span>
<span className="sm:hidden">
{tab === "overview" ? ""
: tab === "checks" ? `(${lynisReport.sections?.length || 0})`
: tab === "warnings" ? `(${lynisReport.warnings.length})`
: `(${lynisReport.suggestions.length})`}
</span>
</button>
))}
</div>
@@ -4019,6 +4120,107 @@ ${(report.sections && report.sections.length > 0) ? `
description="Installing Lynis security auditing tool from GitHub..."
/>
{/* Uninstall Confirmation Dialogs */}
{showFail2banUninstallConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background border border-border rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-red-500/10 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-red-500" />
</div>
<div>
<h3 className="font-semibold text-lg">Uninstall Fail2Ban?</h3>
<p className="text-sm text-muted-foreground">This action cannot be undone</p>
</div>
</div>
<p className="text-sm text-muted-foreground mb-6">
This will completely remove Fail2Ban and all its configuration, including:
</p>
<ul className="text-sm text-muted-foreground mb-6 list-disc list-inside space-y-1">
<li>SSH protection jail</li>
<li>Proxmox web interface protection</li>
<li>ProxMenux Monitor protection</li>
<li>All custom jail configurations</li>
<li>Auth logger services</li>
</ul>
<div className="flex justify-end gap-3">
<Button
variant="outline"
onClick={() => setShowFail2banUninstallConfirm(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleUninstallFail2ban}
disabled={uninstallingFail2ban}
>
{uninstallingFail2ban ? (
<>
<div className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full mr-2" />
Uninstalling...
</>
) : (
<>
<Trash2 className="h-4 w-4 mr-2" />
Uninstall
</>
)}
</Button>
</div>
</div>
</div>
)}
{showLynisUninstallConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background border border-border rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-red-500/10 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-red-500" />
</div>
<div>
<h3 className="font-semibold text-lg">Uninstall Lynis?</h3>
<p className="text-sm text-muted-foreground">This action cannot be undone</p>
</div>
</div>
<p className="text-sm text-muted-foreground mb-6">
This will completely remove Lynis and all audit data, including:
</p>
<ul className="text-sm text-muted-foreground mb-6 list-disc list-inside space-y-1">
<li>Lynis installation (/opt/lynis)</li>
<li>Wrapper script (/usr/local/bin/lynis)</li>
<li>All audit reports and logs</li>
</ul>
<div className="flex justify-end gap-3">
<Button
variant="outline"
onClick={() => setShowLynisUninstallConfirm(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleUninstallLynis}
disabled={uninstallingLynis}
>
{uninstallingLynis ? (
<>
<div className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full mr-2" />
Uninstalling...
</>
) : (
<>
<Trash2 className="h-4 w-4 mr-2" />
Uninstall
</>
)}
</Button>
</div>
</div>
</div>
)}
<TwoFactorSetup
open={show2FASetup}
onClose={() => setShow2FASetup(false)}
+506 -20
View File
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff } from "lucide-react"
import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy } from "lucide-react"
import { NotificationSettings } from "./notification-settings"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Switch } from "./ui/switch"
@@ -11,6 +11,149 @@ import { Badge } from "./ui/badge"
import { getNetworkUnit } from "../lib/format-network"
import { fetchApi } from "../lib/api-config"
// GitHub Dark color palette for bash syntax highlighting
const BASH_KEYWORDS = new Set([
'if','then','else','elif','fi','for','while','until','do','done','case','esac',
'function','return','local','readonly','export','declare','typeset','unset',
'source','alias','exit','break','continue','in','select','time','trap',
])
const BASH_BUILTINS = new Set([
'echo','printf','read','cd','pwd','ls','cat','grep','sed','awk','cut','sort','uniq','tee','wc',
'head','tail','find','xargs','chmod','chown','chgrp','mkdir','rmdir','rm','cp','mv','ln','touch',
'ps','kill','killall','pkill','pgrep','top','htop','df','du','free','uptime','uname','hostname',
'systemctl','journalctl','service','apt','apt-get','dpkg','dnf','yum','zypper','pacman',
'curl','wget','ssh','scp','rsync','tar','gzip','gunzip','bzip2','zip','unzip',
'mount','umount','lsblk','blkid','fdisk','parted','mkfs','fsck','swapon','swapoff',
'ip','ifconfig','iptables','netstat','ss','ping','traceroute','dig','nslookup','nc',
'sudo','su','whoami','id','groups','passwd','useradd','userdel','usermod','groupadd',
'test','true','false','sleep','wait','eval','exec','command','type','which','hash',
'set','getopts','shift','let','expr','jq','sed','grep','awk','tr',
'modprobe','lsmod','rmmod','insmod','dmesg','sysctl','ulimit','nohup','disown','bg','fg',
'zpool','zfs','qm','pct','pvesh','pvesm','pvenode','pveam','pveversion','vzdump',
'smartctl','nvme','ipmitool','sensors','upsc','dkms','modinfo','lspci','lsusb','lscpu',
])
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function highlightBash(code: string): string {
// Token-based highlighter — processes line by line to avoid cross-line state issues
const lines = code.split('\n')
const out: string[] = []
for (const line of lines) {
let i = 0
let result = ''
while (i < line.length) {
const ch = line[i]
// Comments (# to end of line, but not inside strings — simple heuristic)
if (ch === '#' && (i === 0 || /\s/.test(line[i - 1]))) {
result += `<span style="color:#8b949e">${escapeHtml(line.slice(i))}</span>`
i = line.length
continue
}
// Strings: double-quoted (may contain $variables)
if (ch === '"') {
let j = i + 1
let content = ''
while (j < line.length && line[j] !== '"') {
if (line[j] === '\\' && j + 1 < line.length) {
content += line[j] + line[j + 1]
j += 2
} else {
content += line[j]
j++
}
}
const str = '"' + content + (line[j] === '"' ? '"' : '')
// Highlight $vars inside strings
const strHtml = escapeHtml(str).replace(
/(\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*|\$[0-9@#?*$!-])/g,
'<span style="color:#79c0ff">$1</span>'
)
result += `<span style="color:#a5d6ff">${strHtml}</span>`
i = j + 1
continue
}
// Strings: single-quoted (literal, no interpolation)
if (ch === "'") {
let j = i + 1
while (j < line.length && line[j] !== "'") j++
const str = line.slice(i, j + 1)
result += `<span style="color:#a5d6ff">${escapeHtml(str)}</span>`
i = j + 1
continue
}
// Variables outside strings
if (ch === '$') {
const rest = line.slice(i)
let m = rest.match(/^\$\{[^}]+\}/)
if (!m) m = rest.match(/^\$[A-Za-z_][A-Za-z0-9_]*/)
if (!m) m = rest.match(/^\$[0-9@#?*$!-]/)
if (m) {
result += `<span style="color:#79c0ff">${escapeHtml(m[0])}</span>`
i += m[0].length
continue
}
}
// Numbers
if (/[0-9]/.test(ch) && (i === 0 || /[\s=(\[,:;+\-*/]/.test(line[i - 1]))) {
const rest = line.slice(i)
const m = rest.match(/^[0-9]+/)
if (m) {
result += `<span style="color:#79c0ff">${m[0]}</span>`
i += m[0].length
continue
}
}
// Identifiers — check if keyword, builtin, or function definition
if (/[A-Za-z_]/.test(ch)) {
const rest = line.slice(i)
const m = rest.match(/^[A-Za-z_][A-Za-z0-9_-]*/)
if (m) {
const word = m[0]
const after = line.slice(i + word.length)
if (BASH_KEYWORDS.has(word)) {
result += `<span style="color:#ff7b72">${word}</span>`
} else if (/^\s*\(\)\s*\{?/.test(after)) {
// function definition: name() { ... }
result += `<span style="color:#d2a8ff">${word}</span>`
} else if (BASH_BUILTINS.has(word) && (i === 0 || /[\s|;&(]/.test(line[i - 1]))) {
result += `<span style="color:#ffa657">${word}</span>`
} else {
result += escapeHtml(word)
}
i += word.length
continue
}
}
// Operators and special chars
if (/[|&;<>(){}[\]=!+*\/%~^]/.test(ch)) {
result += `<span style="color:#ff7b72">${escapeHtml(ch)}</span>`
i++
continue
}
// Default: escape and append
result += escapeHtml(ch)
i++
}
out.push(result)
}
return out.join('\n')
}
interface SuppressionCategory {
key: string
label: string
@@ -46,6 +189,9 @@ interface ProxMenuxTool {
key: string
name: string
enabled: boolean
version?: string
has_source?: boolean
deprecated?: boolean
}
interface RemoteStorage {
@@ -62,11 +208,36 @@ interface RemoteStorage {
reason?: string
}
interface NetworkInterface {
name: string
type: string
is_up: boolean
speed: number
ip_address: string | null
exclude_health: boolean
exclude_notifications: boolean
excluded_at?: string
reason?: string
}
export function Settings() {
const [proxmenuxTools, setProxmenuxTools] = useState<ProxMenuxTool[]>([])
const [loadingTools, setLoadingTools] = useState(true)
const [networkUnitSettings, setNetworkUnitSettings] = useState<"Bytes" | "Bits">("Bytes")
const [loadingUnitSettings, setLoadingUnitSettings] = useState(true)
// Code viewer modal state
const [codeModal, setCodeModal] = useState<{
open: boolean
loading: boolean
toolName: string
version: string
functionName: string
source: string
script: string
error: string
deprecated: boolean
}>({ open: false, loading: false, toolName: '', version: '', functionName: '', source: '', script: '', error: '', deprecated: false })
const [codeCopied, setCodeCopied] = useState(false)
// Health Monitor suppression settings
const [suppressionCategories, setSuppressionCategories] = useState<SuppressionCategory[]>([])
@@ -81,12 +252,18 @@ export function Settings() {
const [remoteStorages, setRemoteStorages] = useState<RemoteStorage[]>([])
const [loadingStorages, setLoadingStorages] = useState(true)
const [savingStorage, setSavingStorage] = useState<string | null>(null)
// Network Interface Exclusions
const [networkInterfaces, setNetworkInterfaces] = useState<NetworkInterface[]>([])
const [loadingInterfaces, setLoadingInterfaces] = useState(true)
const [savingInterface, setSavingInterface] = useState<string | null>(null)
useEffect(() => {
loadProxmenuxTools()
getUnitsSettings()
loadHealthSettings()
loadRemoteStorages()
loadProxmenuxTools()
getUnitsSettings()
loadHealthSettings()
loadRemoteStorages()
loadNetworkInterfaces()
}, [])
const loadProxmenuxTools = async () => {
@@ -102,6 +279,60 @@ export function Settings() {
}
}
const viewToolSource = async (tool: ProxMenuxTool) => {
setCodeModal({ open: true, loading: true, toolName: tool.name, version: tool.version || '1.0', functionName: '', source: '', script: '', error: '', deprecated: !!tool.deprecated })
try {
const data = await fetchApi(`/api/proxmenux/tool-source/${tool.key}`)
if (data.success) {
setCodeModal(prev => ({ ...prev, loading: false, functionName: data.function, source: data.source, script: data.script, deprecated: !!data.deprecated }))
} else {
setCodeModal(prev => ({ ...prev, loading: false, error: data.error || 'Source code not available' }))
}
} catch {
setCodeModal(prev => ({ ...prev, loading: false, error: 'Failed to load source code' }))
}
}
const copySourceCode = async () => {
const text = codeModal.source
let ok = false
// Preferred path (HTTPS / localhost). On plain HTTP the Promise rejects,
// so we catch and fall through to the textarea fallback.
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
ok = true
}
} catch {
// fall through
}
if (!ok) {
try {
const ta = document.createElement("textarea")
ta.value = text
ta.style.position = "fixed"
ta.style.left = "-9999px"
ta.style.top = "-9999px"
ta.style.opacity = "0"
ta.readOnly = true
document.body.appendChild(ta)
ta.focus()
ta.select()
ok = document.execCommand("copy")
document.body.removeChild(ta)
} catch {
ok = false
}
}
if (ok) {
setCodeCopied(true)
setTimeout(() => setCodeCopied(false), 2000)
}
}
const changeNetworkUnit = (unit: string) => {
const networkUnit = unit as "Bytes" | "Bits"
localStorage.setItem("proxmenux-network-unit", networkUnit)
@@ -177,11 +408,53 @@ export function Settings() {
))
} catch (err) {
console.error("Failed to update storage exclusion:", err)
} finally {
setSavingStorage(null)
}
} finally {
setSavingStorage(null)
}
}
const loadNetworkInterfaces = async () => {
try {
const data = await fetchApi("/api/health/interfaces")
if (data.interfaces) {
setNetworkInterfaces(data.interfaces)
}
} catch (err) {
console.error("Failed to load network interfaces:", err)
} finally {
setLoadingInterfaces(false)
}
}
const handleInterfaceExclusionChange = async (interfaceName: string, interfaceType: string, excludeHealth: boolean, excludeNotifications: boolean) => {
setSavingInterface(interfaceName)
try {
// If both are false, remove the exclusion
if (!excludeHealth && !excludeNotifications) {
await fetchApi(`/api/health/interface-exclusions/${encodeURIComponent(interfaceName)}`, {
method: "DELETE"
})
} else {
await fetchApi("/api/health/interface-exclusions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
interface_name: interfaceName,
interface_type: interfaceType,
exclude_health: excludeHealth,
exclude_notifications: excludeNotifications
})
})
}
// Reload interfaces to get updated state
await loadNetworkInterfaces()
} catch (err) {
console.error("Failed to update interface exclusion:", err)
} finally {
setSavingInterface(null)
}
}
const getSelectValue = (hours: number, key: string): string => {
if (hours === -1) return "-1"
const preset = SUPPRESSION_OPTIONS.find(o => o.value === String(hours))
@@ -541,8 +814,8 @@ export function Settings() {
<span className="text-xs font-medium text-muted-foreground text-center w-20">Alerts</span>
</div>
{/* Storage rows */}
<div className="divide-y divide-border/50">
{/* Storage rows - scrollable container */}
<div className="max-h-[320px] overflow-y-auto divide-y divide-border/50">
{remoteStorages.map((storage) => {
const isExcluded = storage.exclude_health || storage.exclude_notifications
const isSaving = savingStorage === storage.name
@@ -581,6 +854,7 @@ export function Settings() {
storage.exclude_notifications
)
}}
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border"
/>
)}
</div>
@@ -599,6 +873,7 @@ export function Settings() {
!checked
)
}}
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border"
/>
)}
</div>
@@ -621,6 +896,133 @@ export function Settings() {
</CardContent>
</Card>
{/* Network Interface Exclusions */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Network className="h-5 w-5 text-blue-500" />
<CardTitle>Network Interface Exclusions</CardTitle>
</div>
<CardDescription>
Exclude network interfaces (bridges, bonds, physical NICs) from health monitoring and notifications.
Use this for interfaces that are intentionally disabled or unused.
</CardDescription>
</CardHeader>
<CardContent>
{loadingInterfaces ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full" />
</div>
) : networkInterfaces.length === 0 ? (
<div className="text-center py-8">
<Network className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" />
<p className="text-muted-foreground">No network interfaces detected</p>
</div>
) : (
<div className="space-y-0">
{/* Header */}
<div className="grid grid-cols-[1fr_auto_auto] gap-4 pb-2 mb-1 border-b border-border">
<span className="text-xs font-medium text-muted-foreground">Interface</span>
<span className="text-xs font-medium text-muted-foreground text-center w-20">Health</span>
<span className="text-xs font-medium text-muted-foreground text-center w-20">Alerts</span>
</div>
{/* Interface rows - scrollable container */}
<div className="max-h-[320px] overflow-y-auto divide-y divide-border/50">
{networkInterfaces.map((iface) => {
const isExcluded = iface.exclude_health || iface.exclude_notifications
const isSaving = savingInterface === iface.name
const isDown = !iface.is_up
return (
<div key={iface.name} className="grid grid-cols-[1fr_auto_auto] gap-4 py-3 items-center">
<div className="flex items-center gap-3 min-w-0">
<div className={`w-2 h-2 rounded-full shrink-0 ${
isDown ? 'bg-red-500' : 'bg-green-500'
}`} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={`font-medium truncate ${isExcluded ? 'text-muted-foreground' : ''}`}>
{iface.name}
</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{iface.type}
</Badge>
{isDown && !isExcluded && (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">
DOWN
</Badge>
)}
{isExcluded && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-blue-500/10 text-blue-400">
Excluded
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">
{iface.ip_address || 'No IP'} {iface.speed > 0 ? `- ${iface.speed} Mbps` : ''}
</span>
</div>
</div>
{/* Health toggle */}
<div className="flex justify-center w-20">
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Switch
checked={!iface.exclude_health}
onCheckedChange={(checked) => {
handleInterfaceExclusionChange(
iface.name,
iface.type,
!checked,
iface.exclude_notifications
)
}}
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border"
/>
)}
</div>
{/* Notifications toggle */}
<div className="flex justify-center w-20">
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Switch
checked={!iface.exclude_notifications}
onCheckedChange={(checked) => {
handleInterfaceExclusionChange(
iface.name,
iface.type,
iface.exclude_health,
!checked
)
}}
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border"
/>
)}
</div>
</div>
)
})}
</div>
{/* Info footer */}
<div className="flex items-start gap-2 mt-3 pt-3 border-t border-border">
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-muted-foreground leading-relaxed">
<strong>Health:</strong> When OFF, the interface won't trigger warnings/critical alerts in the Health Monitor.
<br />
<strong>Alerts:</strong> When OFF, no notifications will be sent for this interface.
</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Notification Settings */}
<NotificationSettings />
@@ -651,20 +1053,104 @@ export function Settings() {
<span className="text-sm font-semibold text-orange-500">{proxmenuxTools.length} active</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{proxmenuxTools.map((tool) => (
<div
key={tool.key}
className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg border border-border hover:bg-muted transition-colors"
>
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
<span className="text-sm font-medium">{tool.name}</span>
</div>
))}
{proxmenuxTools.map((tool) => {
const clickable = !!tool.has_source
const isDeprecated = !!tool.deprecated
return (
<div
key={tool.key}
onClick={clickable ? () => viewToolSource(tool) : undefined}
className={`flex items-center justify-between gap-2 p-3 bg-muted/50 rounded-lg border border-border transition-colors ${clickable ? 'hover:bg-muted hover:border-orange-500/40 cursor-pointer' : ''}`}
title={clickable ? (isDeprecated ? 'Legacy optimization — click to view source' : 'Click to view source code') : undefined}
>
<div className="flex items-center gap-2 min-w-0">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${isDeprecated ? 'bg-amber-500' : 'bg-green-500'}`} />
<span className="text-sm font-medium truncate">{tool.name}</span>
{isDeprecated && (
<span className="text-[9px] uppercase tracking-wider text-amber-500 bg-amber-500/10 border border-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">
legacy
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded font-mono flex-shrink-0">v{tool.version || '1.0'}</span>
</div>
)
})}
</div>
</div>
)}
</CardContent>
</Card>
{/* Code Viewer Modal */}
{codeModal.open && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setCodeModal(prev => ({ ...prev, open: false }))}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="relative bg-card border border-border rounded-xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-3 min-w-0">
<Code className={`h-5 w-5 flex-shrink-0 ${codeModal.deprecated ? 'text-amber-500' : 'text-orange-500'}`} />
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-sm font-semibold truncate">{codeModal.toolName}</h3>
{codeModal.deprecated && (
<span className="text-[9px] uppercase tracking-wider text-amber-500 bg-amber-500/10 border border-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">
legacy
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{codeModal.functionName && <span className="font-mono">{codeModal.functionName}()</span>}
{codeModal.script && <span> {codeModal.script}</span>}
{codeModal.version && <span className="ml-2 bg-muted px-1.5 py-0.5 rounded font-mono">v{codeModal.version}</span>}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{codeModal.source && (
<button
onClick={copySourceCode}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-muted hover:bg-muted/80 transition-colors"
title="Copy to clipboard"
>
{codeCopied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
{codeCopied ? 'Copied' : 'Copy'}
</button>
)}
<button
onClick={() => setCodeModal(prev => ({ ...prev, open: false }))}
className="p-1.5 rounded-md hover:bg-muted transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-auto p-0">
{codeModal.loading ? (
<div className="flex items-center justify-center py-16">
<div className="animate-spin h-8 w-8 border-4 border-orange-500 border-t-transparent rounded-full" />
</div>
) : codeModal.error ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Code className="h-10 w-10 mb-3 opacity-40" />
<p className="text-sm">{codeModal.error}</p>
</div>
) : (
<pre
className="text-xs leading-relaxed font-mono p-4 overflow-x-auto whitespace-pre bg-[#0d1117] text-[#e6edf3]"
style={{ tabSize: 4 }}
dangerouslySetInnerHTML={{ __html: `<code>${highlightBash(codeModal.source)}</code>` }}
/>
)}
</div>
</div>
</div>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -553,7 +553,7 @@ export function SystemLogs() {
}
return (
<div className="space-y-6">
<div className="space-y-6 w-full max-w-full overflow-hidden">
{loading && (logs.length > 0 || events.length > 0) && (
<div className="fixed inset-0 bg-background/60 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-card border border-border shadow-xl">
@@ -616,7 +616,7 @@ export function SystemLogs() {
</div>
{/* Main Content with Tabs */}
<Card className="bg-card border-border">
<Card className="bg-card border-border w-full max-w-full overflow-hidden">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-foreground flex items-center">
@@ -630,7 +630,7 @@ export function SystemLogs() {
</div>
</CardHeader>
<CardContent className="max-w-full overflow-hidden">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full max-w-full">
<TabsList className="hidden md:grid w-full grid-cols-3">
<TabsTrigger value="logs" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white">
<Terminal className="h-4 w-4 mr-2" />
@@ -794,8 +794,8 @@ export function SystemLogs() {
</Button>
</div>
<ScrollArea className="h-[600px] w-full rounded-md border border-border overflow-x-hidden">
<div className="space-y-2 p-4 w-full box-border">
<ScrollArea className="h-[600px] w-full rounded-md border border-border overflow-hidden [&>div]:!max-w-full [&>div>div]:!max-w-full">
<div className="space-y-2 p-4 w-full min-w-0">
{displayedLogs.map((log, index) => {
// Generate a more stable unique key
const timestampMs = new Date(log.timestamp).getTime()
@@ -806,7 +806,7 @@ export function SystemLogs() {
return (
<div
key={uniqueKey}
className="flex flex-col md:flex-row md:items-start space-y-2 md:space-y-0 md:space-x-4 p-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 transition-colors cursor-pointer overflow-hidden box-border"
className="flex flex-col md:flex-row md:items-start space-y-2 md:space-y-0 md:space-x-4 p-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 transition-colors cursor-pointer overflow-hidden w-full max-w-full min-w-0"
onClick={() => {
if (log.eventData) {
setSelectedEvent(log.eventData)
@@ -830,17 +830,17 @@ export function SystemLogs() {
)}
</div>
<div className="flex-1 min-w-0 overflow-hidden box-border">
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-1 gap-1">
<div className="text-sm font-medium text-foreground truncate min-w-0">{log.service}</div>
<div className="text-xs text-muted-foreground font-mono truncate sm:ml-2 sm:flex-shrink-0">
{log.timestamp}
</div>
</div>
<div className="text-sm text-foreground mb-1 line-clamp-2 break-all overflow-hidden">
<div className="text-sm text-foreground mb-1 line-clamp-2 break-words overflow-hidden">
{log.message}
</div>
<div className="text-xs text-muted-foreground truncate break-all overflow-hidden">
<div className="text-xs text-muted-foreground truncate overflow-hidden">
{log.source}
{log.unit && log.unit !== log.service && ` • Unit: ${log.unit}`}
{log.pid && ` • PID: ${log.pid}`}
@@ -859,7 +859,7 @@ export function SystemLogs() {
)}
{hasMoreLogs && (
<div className="flex justify-center pt-4">
<div className="flex justify-center pt-4 w-full">
<Button
variant="outline"
onClick={() => setDisplayedLogsCount((prev) => prev + 200)}
+16 -25
View File
@@ -111,9 +111,9 @@ const fetchSystemData = async (retries = 3, delayMs = 500): Promise<SystemData |
try {
const data = await fetchApi<SystemData>("/api/system")
return data
} catch (error) {
} catch {
if (attempt === retries - 1) {
console.error("[v0] Failed to fetch system data after retries:", error)
// Silent fail - API not available (expected in preview environment)
return null
}
// Wait before retry
@@ -127,8 +127,8 @@ const fetchVMData = async (): Promise<VMData[]> => {
try {
const data = await fetchApi<any>("/api/vms")
return Array.isArray(data) ? data : data.vms || []
} catch (error) {
console.error("[v0] Failed to fetch VM data:", error)
} catch {
// Silent fail - API not available
return []
}
}
@@ -137,8 +137,7 @@ const fetchStorageData = async (): Promise<StorageData | null> => {
try {
const data = await fetchApi<StorageData>("/api/storage/summary")
return data
} catch (error) {
console.log("[v0] Storage API not available (this is normal if not configured)")
} catch {
return null
}
}
@@ -147,18 +146,16 @@ const fetchNetworkData = async (): Promise<NetworkData | null> => {
try {
const data = await fetchApi<NetworkData>("/api/network/summary")
return data
} catch (error) {
console.log("[v0] Network API not available (this is normal if not configured)")
} catch {
return null
}
}
const fetchProxmoxStorageData = async (): Promise<ProxmoxStorageData | null> => {
const fetchProxmoxStorageData = async (): Promise<ProxmoxStorage[] | null> => {
try {
const data = await fetchApi<ProxmoxStorageData>("/api/proxmox-storage")
const data = await fetchApi<ProxmoxStorage[]>("/api/proxmox-storage")
return data
} catch (error) {
console.log("[v0] Proxmox storage API not available")
} catch {
return null
}
}
@@ -225,7 +222,7 @@ export function SystemOverview() {
const systemInterval = setInterval(async () => {
const data = await fetchSystemData()
if (data) setSystemData(data)
}, 9000)
}, 5000)
const vmInterval = setInterval(async () => {
const data = await fetchVMData()
@@ -262,19 +259,13 @@ export function SystemOverview() {
if (!hasAttemptedLoad || loadingStates.system) {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
{[...Array(4)].map((_, i) => (
<Card key={i} className="bg-card border-border animate-pulse">
<CardContent className="p-6">
<div className="h-4 bg-muted rounded w-1/2 mb-4"></div>
<div className="h-8 bg-muted rounded w-3/4 mb-2"></div>
<div className="h-2 bg-muted rounded w-full mb-2"></div>
<div className="h-3 bg-muted rounded w-2/3"></div>
</CardContent>
</Card>
))}
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<div className="relative">
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
</div>
<div className="text-sm font-medium text-foreground">Loading system overview...</div>
<p className="text-xs text-muted-foreground">Fetching system status and metrics</p>
</div>
)
}
+2 -2
View File
@@ -724,13 +724,13 @@ const handleClose = () => {
e.preventDefault()
e.stopPropagation()
}
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
if (activeTerminal?.ws && activeTerminal.ws.readyState === WebSocket.OPEN) {
activeTerminal.ws.send(seq)
}
}
const getLayoutClass = () => {
const count = terminals.length
if (isMobile || count === 1) return "grid grid-cols-1"
+28 -12
View File
@@ -90,33 +90,49 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
}
const copyToClipboard = async (text: string, type: "secret" | "codes") => {
let ok = false
// Preferred path (HTTPS / localhost). On plain HTTP the Promise rejects,
// so we catch and fall through to the textarea fallback.
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
} else {
// Fallback for non-secure contexts (HTTP)
ok = true
}
} catch {
// fall through to execCommand fallback
}
if (!ok) {
try {
const textarea = document.createElement("textarea")
textarea.value = text
textarea.style.position = "fixed"
textarea.style.left = "-9999px"
textarea.style.top = "-9999px"
textarea.style.opacity = "0"
textarea.readOnly = true
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
document.execCommand("copy")
ok = document.execCommand("copy")
document.body.removeChild(textarea)
} catch {
ok = false
}
}
if (type === "secret") {
setCopiedSecret(true)
setTimeout(() => setCopiedSecret(false), 2000)
} else {
setCopiedCodes(true)
setTimeout(() => setCopiedCodes(false), 2000)
}
} catch {
if (!ok) {
console.error("Failed to copy to clipboard")
return
}
if (type === "secret") {
setCopiedSecret(true)
setTimeout(() => setCopiedSecret(false), 2000)
} else {
setCopiedCodes(true)
setTimeout(() => setCopiedCodes(false), 2000)
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
+216 -100
View File
@@ -8,7 +8,7 @@ import { Badge } from "./ui/badge"
import { Progress } from "./ui/progress"
import { Button } from "./ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog"
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2 } from 'lucide-react'
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity } from 'lucide-react'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Checkbox } from "./ui/checkbox"
import { Textarea } from "./ui/textarea"
@@ -295,10 +295,10 @@ export function VirtualMachines() {
isLoading,
mutate,
} = useSWR<VMData[]>("/api/vms", fetcher, {
refreshInterval: 23000,
revalidateOnFocus: false,
refreshInterval: 2500,
revalidateOnFocus: true,
revalidateOnReconnect: true,
dedupingInterval: 10000,
dedupingInterval: 1000,
errorRetryCount: 2,
})
@@ -335,6 +335,25 @@ export function VirtualMachines() {
const [backupNotification, setBackupNotification] = useState<string>("auto")
const [backupNotes, setBackupNotes] = useState<string>("{{guestname}}")
const [backupPbsChangeMode, setBackupPbsChangeMode] = useState<string>("default")
// Tab state for modal
const [activeModalTab, setActiveModalTab] = useState<"status" | "backups">("status")
// Detect standalone mode (webapp vs browser)
const [isStandalone, setIsStandalone] = useState(false)
useEffect(() => {
const checkStandalone = () => {
const standalone = window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
setIsStandalone(standalone)
}
checkStandalone()
const mediaQuery = window.matchMedia('(display-mode: standalone)')
mediaQuery.addEventListener('change', checkStandalone)
return () => mediaQuery.removeEventListener('change', checkStandalone)
}, [])
useEffect(() => {
const fetchLXCIPs = async () => {
@@ -404,6 +423,16 @@ export function VirtualMachines() {
}
}, [])
// Keep the open modal's VM in sync with the /api/vms poll so CPU/RAM/I-O values
// don't stay frozen at click-time. Single data source (/cluster/resources) shared
// with the list — no source mismatch, no flicker.
useEffect(() => {
if (!selectedVM || !vmData) return
const updated = vmData.find((v) => v.vmid === selectedVM.vmid)
if (!updated || updated === selectedVM) return
setSelectedVM(updated)
}, [vmData])
const handleVMClick = async (vm: VMData) => {
setSelectedVM(vm)
setCurrentView("main")
@@ -602,7 +631,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
}
}
const safeVMData = vmData || []
// Ensure vmData is always an array (backend may return object on error)
const safeVMData = Array.isArray(vmData) ? vmData : []
// Total allocated RAM for ALL VMs/LXCs (running + stopped)
const totalAllocatedMemoryGB = useMemo(() => {
@@ -1226,10 +1256,15 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
setShowNotes(false)
setIsEditingNotes(false)
setEditedNotes("")
setActiveModalTab("status")
}}
>
<DialogContent
className="max-w-4xl h-[95vh] sm:h-[90vh] flex flex-col p-0 overflow-hidden"
className={`max-w-4xl flex flex-col p-0 overflow-hidden ${
isStandalone
? "h-[95vh] sm:h-[90vh]"
: "h-[85vh] sm:h-[85vh] max-h-[calc(100dvh-env(safe-area-inset-top)-env(safe-area-inset-bottom)-40px)]"
}`}
key={selectedVM?.vmid || "no-vm"}
>
{currentView === "main" ? (
@@ -1289,7 +1324,38 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-4" style={{ maxHeight: 'calc(100vh - 280px)' }}>
{/* Tab Navigation */}
<div className="flex border-b border-border px-6 shrink-0">
<button
onClick={() => setActiveModalTab("status")}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeModalTab === "status"
? "border-cyan-500 text-cyan-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Activity className="h-4 w-4" />
Status
</button>
<button
onClick={() => setActiveModalTab("backups")}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeModalTab === "backups"
? "border-amber-500 text-amber-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Archive className="h-4 w-4" />
Backups
{vmBackups.length > 0 && (
<Badge variant="secondary" className="text-xs h-5 ml-1">{vmBackups.length}</Badge>
)}
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
{/* Status Tab */}
{activeModalTab === "status" && (
<div className="space-y-4">
{selectedVM && (
<>
@@ -1302,7 +1368,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
{/* CPU Usage */}
<div>
<div className="text-xs text-muted-foreground mb-2">CPU Usage</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
<Cpu className="h-3.5 w-3.5" />
<span>CPU Usage</span>
{vmDetails?.config?.cores && (
<span className="text-muted-foreground/60">({vmDetails.config.cores} cores)</span>
)}
</div>
<div className={`text-base font-semibold mb-2 ${getUsageColor(selectedVM.cpu * 100)}`}>
{(selectedVM.cpu * 100).toFixed(1)}%
</div>
@@ -1314,7 +1386,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{/* Memory */}
<div>
<div className="text-xs text-muted-foreground mb-2">Memory</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
<MemoryStick className="h-3.5 w-3.5" />
<span>Memory</span>
</div>
<div
className={`text-base font-semibold mb-2 ${getUsageColor((selectedVM.mem / selectedVM.maxmem) * 100)}`}
>
@@ -1329,7 +1404,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{/* Disk */}
<div>
<div className="text-xs text-muted-foreground mb-2">Disk</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
<HardDrive className="h-3.5 w-3.5" />
<span>Disk</span>
</div>
<div
className={`text-base font-semibold mb-2 ${getUsageColor((selectedVM.disk / selectedVM.maxdisk) * 100)}`}
>
@@ -1344,7 +1422,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{/* Disk I/O */}
<div>
<div className="text-xs text-muted-foreground mb-2">Disk I/O</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
<HardDrive className="h-3.5 w-3.5" />
<span>Disk I/O</span>
</div>
<div className="space-y-1">
<div className="text-sm text-green-500 flex items-center gap-1">
<span></span>
@@ -1359,7 +1440,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{/* Network I/O */}
<div>
<div className="text-xs text-muted-foreground mb-2">Network I/O</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
<Network className="h-3.5 w-3.5" />
<span>Network I/O</span>
</div>
<div className="space-y-1">
<div className="text-sm text-green-500 flex items-center gap-1">
<span></span>
@@ -1380,78 +1464,6 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</Card>
</div>
{/* Backups Section */}
<Card className="border border-border bg-card/50">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-amber-500/10">
<Archive className="h-4 w-4 text-amber-500" />
</div>
<h3 className="text-sm font-semibold text-foreground">Backups</h3>
</div>
<Button
size="sm"
className="h-7 text-xs bg-amber-600/20 border border-amber-600/50 text-amber-400 hover:bg-amber-600/30 gap-1"
onClick={openBackupModal}
disabled={creatingBackup}
>
{creatingBackup ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Plus className="h-3 w-3" />
)}
<span>Create Backup</span>
</Button>
</div>
{/* Divider */}
<div className="border-t border-border/50 mb-4" />
{/* Backup List */}
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-muted-foreground">Available backups</span>
<Badge variant="secondary" className="text-xs h-5">{vmBackups.length}</Badge>
</div>
{loadingBackups ? (
<div className="flex items-center justify-center py-6 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm">Loading backups...</span>
</div>
) : vmBackups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-muted-foreground">
<Archive className="h-8 w-8 mb-2 opacity-30" />
<span className="text-sm">No backups found</span>
</div>
) : (
<div className="space-y-1.5 max-h-[216px] overflow-y-auto">
{vmBackups.map((backup, index) => (
<div
key={`backup-${backup.volid}-${index}`}
className="flex items-center justify-between p-2.5 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 flex-shrink-0" />
<Clock className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-foreground">{backup.date}</span>
<Badge
variant="outline"
className={`text-xs ml-auto flex-shrink-0 ${getStorageColor(backup.storage).bg} ${getStorageColor(backup.storage).text} ${getStorageColor(backup.storage).border}`}
>
{backup.storage}
</Badge>
</div>
<Badge variant="outline" className="text-xs font-mono ml-2 flex-shrink-0">
{backup.size_human}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
{detailsLoading ? (
<div className="text-center py-8 text-muted-foreground">Loading configuration...</div>
) : vmDetails?.config ? (
@@ -1508,19 +1520,28 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="grid grid-cols-3 lg:grid-cols-4 gap-3 lg:gap-4">
{vmDetails.config.cores && (
<div>
<div className="text-xs text-muted-foreground mb-1">CPU Cores</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<Cpu className="h-3.5 w-3.5" />
<span>CPU Cores</span>
</div>
<div className="font-semibold text-blue-500">{vmDetails.config.cores}</div>
</div>
)}
{vmDetails.config.memory && (
<div>
<div className="text-xs text-muted-foreground mb-1">Memory</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<MemoryStick className="h-3.5 w-3.5" />
<span>Memory</span>
</div>
<div className="font-semibold text-blue-500">{vmDetails.config.memory} MB</div>
</div>
)}
{vmDetails.config.swap && (
{vmDetails.config.swap !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-1">Swap</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<RotateCcw className="h-3.5 w-3.5" />
<span>Swap</span>
</div>
<div className="font-semibold text-foreground">{vmDetails.config.swap} MB</div>
</div>
)}
@@ -1529,7 +1550,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{/* IP Addresses with proper keys */}
{selectedVM?.type === "lxc" && vmDetails?.lxc_ip_info && (
<div className="mt-4 lg:mt-6 pt-4 lg:pt-6 border-t border-border">
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<Network className="h-4 w-4" />
IP Addresses
</h4>
<div className="flex flex-wrap gap-2">
@@ -1632,7 +1654,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="mt-6 pt-6 border-t border-border space-y-6">
{selectedVM?.type === "lxc" && vmDetails?.hardware_info && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<Container className="h-4 w-4" />
Container Configuration
</h4>
<div className="space-y-4">
@@ -1640,7 +1663,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{vmDetails.hardware_info.privileged !== null &&
vmDetails.hardware_info.privileged !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-2">Privilege Level</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
<Shield className="h-3.5 w-3.5" />
<span>Privilege Level</span>
</div>
<Badge
variant="outline"
className={
@@ -1658,7 +1684,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{vmDetails.hardware_info.gpu_passthrough &&
vmDetails.hardware_info.gpu_passthrough.length > 0 && (
<div>
<div className="text-xs text-muted-foreground mb-2">GPU Passthrough</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
<Cpu className="h-3.5 w-3.5" />
<span>GPU Passthrough</span>
</div>
<div className="flex flex-wrap gap-2">
{vmDetails.hardware_info.gpu_passthrough.map((gpu, index) => (
<Badge
@@ -1681,7 +1710,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{vmDetails.hardware_info.devices &&
vmDetails.hardware_info.devices.length > 0 && (
<div>
<div className="text-xs text-muted-foreground mb-2">Hardware Devices</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
<Server className="h-3.5 w-3.5" />
<span>Hardware Devices</span>
</div>
<div className="flex flex-wrap gap-2">
{vmDetails.hardware_info.devices.map((device, index) => (
<Badge
@@ -1701,7 +1733,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{/* Hardware Section */}
<div>
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<Settings2 className="h-4 w-4" />
Hardware
</h4>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
@@ -1802,7 +1835,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{/* Storage Section */}
<div>
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<HardDrive className="h-4 w-4" />
Storage
</h4>
<div className="space-y-3">
@@ -1867,7 +1901,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{/* Network Section */}
<div>
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<Network className="h-4 w-4" />
Network
</h4>
<div className="space-y-3">
@@ -1916,7 +1951,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{/* PCI Devices with proper keys */}
{Object.keys(vmDetails.config).some((key) => key.match(/^hostpci\d+$/)) && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<Cpu className="h-4 w-4" />
PCI Passthrough
</h4>
<div className="space-y-3">
@@ -1939,7 +1975,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{/* USB Devices with proper keys */}
{Object.keys(vmDetails.config).some((key) => key.match(/^usb\d+$/)) && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<Server className="h-4 w-4" />
USB Devices
</h4>
<div className="space-y-3">
@@ -1962,7 +1999,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{/* Serial Ports with proper keys */}
{Object.keys(vmDetails.config).some((key) => key.match(/^serial\d+$/)) && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<Terminal className="h-4 w-4" />
Serial Ports
</h4>
<div className="space-y-3">
@@ -1990,9 +2028,87 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</>
)}
</div>
)}
{/* Backups Tab */}
{activeModalTab === "backups" && (
<div className="space-y-4">
<Card className="border border-border bg-card/50">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-amber-500/10">
<Archive className="h-4 w-4 text-amber-500" />
</div>
<h3 className="text-sm font-semibold text-foreground">Backups</h3>
</div>
<Button
size="sm"
className="h-7 text-xs bg-amber-600/20 border border-amber-600/50 text-amber-400 hover:bg-amber-600/30 gap-1"
onClick={openBackupModal}
disabled={creatingBackup}
>
{creatingBackup ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Plus className="h-3 w-3" />
)}
<span>Create Backup</span>
</Button>
</div>
{/* Divider */}
<div className="border-t border-border/50 mb-4" />
{/* Backup List */}
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-muted-foreground">Available backups</span>
<Badge variant="secondary" className="text-xs h-5">{vmBackups.length}</Badge>
</div>
{loadingBackups ? (
<div className="flex items-center justify-center py-6 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm">Loading backups...</span>
</div>
) : vmBackups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Archive className="h-12 w-12 mb-3 opacity-30" />
<span className="text-sm">No backups found</span>
<span className="text-xs mt-1">Create your first backup using the button above</span>
</div>
) : (
<div className="space-y-2">
{vmBackups.map((backup, index) => (
<div
key={`backup-${backup.volid}-${index}`}
className="flex items-center justify-between p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
<Clock className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-foreground">{backup.date}</span>
<Badge
variant="outline"
className={`text-xs ml-auto flex-shrink-0 ${getStorageColor(backup.storage).bg} ${getStorageColor(backup.storage).text} ${getStorageColor(backup.storage).border}`}
>
{backup.storage}
</Badge>
</div>
<Badge variant="outline" className="text-xs font-mono ml-2 flex-shrink-0">
{backup.size_human}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)}
</div>
<div className="border-t border-border bg-background px-6 py-4 mt-auto">
<div className="border-t border-border bg-background px-6 py-4 mt-auto shrink-0">
{/* Terminal button for LXC containers - only when running */}
{selectedVM?.type === "lxc" && selectedVM?.status === "running" && (
<div className="mb-3">
+29 -15
View File
@@ -1,7 +1,8 @@
{
"_description": "Verified AI models for ProxMenux notifications. Only models listed here will be shown to users. Models are tested to work with the chat/completions API format.",
"_updated": "2026-03-20",
"_updated": "2026-04-19",
"_verifier": "Refreshed with tools/ai-models-verifier (private). Re-run before each ProxMenux release to keep the list current. The verifier and ProxMenux share the same reasoning/thinking-model handlers so their verdicts stay aligned with runtime behaviour.",
"groq": {
"models": [
"llama-3.3-70b-versatile",
@@ -12,34 +13,46 @@
"mixtral-8x7b-32768",
"gemma2-9b-it"
],
"recommended": "llama-3.3-70b-versatile"
"recommended": "llama-3.3-70b-versatile",
"_note": "Not yet re-verified in 2026-04 refresh — kept from previous curation. Run the verifier with a Groq key to prune deprecated entries."
},
"gemini": {
"models": [
"gemini-2.5-flash-lite",
"gemini-flash-lite-latest"
"gemini-2.5-flash",
"gemini-3-flash-preview"
],
"recommended": "gemini-2.5-flash-lite"
"recommended": "gemini-2.5-flash-lite",
"_note": "flash-lite / flash pass the verifier consistently; pro variants reject thinkingBudget=0 and are overkill for notification translation anyway. 'latest' aliases (gemini-flash-latest, gemini-flash-lite-latest) are intentionally omitted because they resolved to different models across runs and produced timeouts in some regions.",
"_deprecated": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-flash", "gemini-1.0-pro", "gemini-pro"]
},
"openai": {
"models": [
"gpt-4.1-nano",
"gpt-4.1-mini",
"gpt-4o-mini"
"gpt-4o-mini",
"gpt-4.1",
"gpt-4o",
"gpt-5-chat-latest",
"gpt-5.4-nano",
"gpt-5.4-mini"
],
"recommended": "gpt-4o-mini"
"recommended": "gpt-4.1-nano",
"_note": "Reasoning models (o-series, gpt-5/5.1/5.2 non-chat variants) are supported by openai_provider.py via max_completion_tokens + reasoning_effort=minimal, but not listed here by default: their latency is higher than the chat models and they do not improve translation quality for notifications. Add specific reasoning IDs to this list only if a user explicitly wants them."
},
"anthropic": {
"models": [
"claude-3-5-haiku-latest",
"claude-3-5-sonnet-latest",
"claude-3-opus-latest"
],
"recommended": "claude-3-5-haiku-latest"
"recommended": "claude-3-5-haiku-latest",
"_note": "Not re-verified in 2026-04 refresh — kept from previous curation. Add claude-4.x / claude-4.5 / claude-4.6 / claude-4.7 variants after running the verifier with an Anthropic key."
},
"openrouter": {
"models": [
"meta-llama/llama-3.3-70b-instruct",
@@ -47,14 +60,15 @@
"meta-llama/llama-3.1-8b-instruct",
"anthropic/claude-3.5-haiku",
"anthropic/claude-3.5-sonnet",
"google/gemini-flash-2.5-flash-lite",
"google/gemini-flash-1.5",
"openai/gpt-4o-mini",
"mistralai/mistral-7b-instruct",
"mistralai/mixtral-8x7b-instruct"
],
"recommended": "meta-llama/llama-3.3-70b-instruct"
"recommended": "meta-llama/llama-3.3-70b-instruct",
"_note": "Not re-verified in 2026-04 refresh. google/gemini-flash-2.5-flash-lite was malformed in the previous entry and has been replaced with google/gemini-flash-1.5."
},
"ollama": {
"_note": "Ollama models are local, we don't filter them. User manages their own models.",
"models": [],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ProxMenux-Monitor",
"version": "1.0.2-beta",
"version": "1.2.0",
"description": "Proxmox System Monitoring Dashboard",
"private": true,
"scripts": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+390
View File
@@ -0,0 +1,390 @@
#!/usr/bin/env python3
"""
AI Context Enrichment Module
Enriches notification context with additional information to help AI provide
more accurate and helpful responses:
1. Event frequency - how often this error has occurred
2. System uptime - helps distinguish startup issues from runtime failures
3. SMART disk data - for disk-related errors
4. Known error matching - from proxmox_known_errors database
Author: MacRimi
"""
import os
import re
import subprocess
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import sqlite3
from pathlib import Path
# Import known errors database
try:
from proxmox_known_errors import get_error_context, find_matching_error
except ImportError:
def get_error_context(*args, **kwargs):
return None
def find_matching_error(*args, **kwargs):
return None
DB_PATH = Path('/usr/local/share/proxmenux/health_monitor.db')
def get_system_uptime() -> str:
"""Get system uptime in human-readable format.
Returns:
String like "2 minutes (recently booted)" or "89 days, 4 hours (stable system)"
"""
try:
with open('/proc/uptime', 'r') as f:
uptime_seconds = float(f.readline().split()[0])
days = int(uptime_seconds // 86400)
hours = int((uptime_seconds % 86400) // 3600)
minutes = int((uptime_seconds % 3600) // 60)
# Build human-readable string
parts = []
if days > 0:
parts.append(f"{days} day{'s' if days != 1 else ''}")
if hours > 0:
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
if not parts: # Less than an hour
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
uptime_str = ", ".join(parts)
# Add context hint
if uptime_seconds < 600: # Less than 10 minutes
return f"{uptime_str} (just booted - likely startup issue)"
elif uptime_seconds < 3600: # Less than 1 hour
return f"{uptime_str} (recently booted)"
elif days >= 30:
return f"{uptime_str} (stable system)"
else:
return uptime_str
except Exception:
return "unknown"
def get_event_frequency(error_id: str = None, error_key: str = None,
category: str = None, hours: int = 24) -> Optional[Dict[str, Any]]:
"""Get frequency information for an error from the database.
Args:
error_id: Specific error ID to look up
error_key: Alternative error key
category: Error category
hours: Time window to check (default 24h)
Returns:
Dict with frequency info or None
"""
if not DB_PATH.exists():
return None
try:
conn = sqlite3.connect(str(DB_PATH), timeout=5)
cursor = conn.cursor()
# Try to find the error
if error_id:
cursor.execute('''
SELECT first_seen, last_seen, occurrences, category
FROM errors WHERE error_key = ? OR error_id = ?
ORDER BY last_seen DESC LIMIT 1
''', (error_id, error_id))
elif error_key:
cursor.execute('''
SELECT first_seen, last_seen, occurrences, category
FROM errors WHERE error_key = ?
ORDER BY last_seen DESC LIMIT 1
''', (error_key,))
elif category:
cursor.execute('''
SELECT first_seen, last_seen, occurrences, category
FROM errors WHERE category = ? AND resolved_at IS NULL
ORDER BY last_seen DESC LIMIT 1
''', (category,))
else:
conn.close()
return None
row = cursor.fetchone()
conn.close()
if not row:
return None
first_seen, last_seen, occurrences, cat = row
# Calculate age
try:
first_dt = datetime.fromisoformat(first_seen) if first_seen else None
last_dt = datetime.fromisoformat(last_seen) if last_seen else None
now = datetime.now()
result = {
'occurrences': occurrences or 1,
'category': cat
}
if first_dt:
age = now - first_dt
if age.total_seconds() < 3600:
result['first_seen_ago'] = f"{int(age.total_seconds() / 60)} minutes ago"
elif age.total_seconds() < 86400:
result['first_seen_ago'] = f"{int(age.total_seconds() / 3600)} hours ago"
else:
result['first_seen_ago'] = f"{age.days} days ago"
if last_dt and first_dt and occurrences and occurrences > 1:
# Calculate average interval
span = (last_dt - first_dt).total_seconds()
if span > 0 and occurrences > 1:
avg_interval = span / (occurrences - 1)
if avg_interval < 60:
result['pattern'] = f"recurring every ~{int(avg_interval)} seconds"
elif avg_interval < 3600:
result['pattern'] = f"recurring every ~{int(avg_interval / 60)} minutes"
else:
result['pattern'] = f"recurring every ~{int(avg_interval / 3600)} hours"
return result
except (ValueError, TypeError):
return {'occurrences': occurrences or 1, 'category': cat}
except Exception as e:
print(f"[AIContext] Error getting frequency: {e}")
return None
def get_smart_data(disk_device: str) -> Optional[str]:
"""Get SMART health data for a disk.
Args:
disk_device: Device path like /dev/sda or just sda
Returns:
Formatted SMART summary or None
"""
if not disk_device:
return None
# Normalize device path
if not disk_device.startswith('/dev/'):
disk_device = f'/dev/{disk_device}'
# Check device exists
if not os.path.exists(disk_device):
return None
try:
# Get health status
result = subprocess.run(
['smartctl', '-H', disk_device],
capture_output=True, text=True, timeout=10
)
health_status = "UNKNOWN"
if "PASSED" in result.stdout:
health_status = "PASSED"
elif "FAILED" in result.stdout:
health_status = "FAILED"
# Get key attributes
result = subprocess.run(
['smartctl', '-A', disk_device],
capture_output=True, text=True, timeout=10
)
attributes = {}
critical_attrs = [
'Reallocated_Sector_Ct', 'Current_Pending_Sector',
'Offline_Uncorrectable', 'UDMA_CRC_Error_Count',
'Reallocated_Event_Count', 'Reported_Uncorrect'
]
for line in result.stdout.split('\n'):
for attr in critical_attrs:
if attr in line:
parts = line.split()
# Typical format: ID ATTRIBUTE_NAME FLAGS VALUE WORST THRESH TYPE UPDATED RAW_VALUE
if len(parts) >= 10:
raw_value = parts[-1]
attributes[attr] = raw_value
# Build summary
lines = [f"SMART Health: {health_status}"]
# Add critical attributes if non-zero
for attr, value in attributes.items():
try:
if int(value) > 0:
lines.append(f" {attr}: {value}")
except ValueError:
pass
return "\n".join(lines) if len(lines) > 1 or health_status == "FAILED" else f"SMART Health: {health_status}"
except subprocess.TimeoutExpired:
return None
except FileNotFoundError:
# smartctl not installed
return None
except Exception:
return None
def extract_disk_device(text: str) -> Optional[str]:
"""Extract disk device name from error text.
Args:
text: Error message or log content
Returns:
Device name like 'sda' or None
"""
if not text:
return None
# Common patterns for disk devices in errors
patterns = [
r'/dev/(sd[a-z]\d*)',
r'/dev/(nvme\d+n\d+(?:p\d+)?)',
r'/dev/(hd[a-z]\d*)',
r'/dev/(vd[a-z]\d*)',
r'\b(sd[a-z])\b',
r'disk[_\s]+(sd[a-z])',
r'ata\d+\.\d+: (sd[a-z])',
]
for pattern in patterns:
match = re.search(pattern, text, re.IGNORECASE)
if match:
return match.group(1)
return None
def enrich_context_for_ai(
title: str,
body: str,
event_type: str,
data: Dict[str, Any],
journal_context: str = '',
detail_level: str = 'standard'
) -> str:
"""Build enriched context string for AI processing.
Combines:
- Original journal context
- Event frequency information
- System uptime
- SMART data (for disk errors)
- Known error matching
Args:
title: Notification title
body: Notification body
event_type: Type of event
data: Event data dict
journal_context: Original journal log context
detail_level: Level of detail (minimal, standard, detailed)
Returns:
Enriched context string
"""
context_parts = []
combined_text = f"{title} {body} {journal_context}"
# 1. System uptime - ONLY for critical system-level failures
# Uptime helps distinguish startup issues from runtime failures
# BUT it's noise for disk errors, warnings, or routine operations
# Only include for: system crash, kernel panic, OOM, cluster failures
uptime_critical_types = [
'crash', 'panic', 'oom', 'kernel',
'split_brain', 'quorum_lost', 'node_offline', 'node_fail',
'system_fail', 'boot_fail'
]
# Check if this is a critical system-level event (not disk/service/hardware)
event_lower = event_type.lower()
is_critical_system_event = any(t in event_lower for t in uptime_critical_types)
# Only add uptime for critical system failures, nothing else
if is_critical_system_event:
uptime = get_system_uptime()
if uptime and uptime != "unknown":
context_parts.append(f"System uptime: {uptime}")
# 2. Event frequency
error_key = data.get('error_key') or data.get('error_id')
category = data.get('category')
freq = get_event_frequency(error_id=error_key, category=category)
if freq:
freq_line = f"Event frequency: {freq.get('occurrences', 1)} occurrence(s)"
if freq.get('first_seen_ago'):
freq_line += f", first seen {freq['first_seen_ago']}"
if freq.get('pattern'):
freq_line += f", {freq['pattern']}"
context_parts.append(freq_line)
# 3. SMART data for disk-related events
disk_related = any(x in event_type.lower() for x in ['disk', 'smart', 'storage', 'io_error'])
if not disk_related:
disk_related = any(x in combined_text.lower() for x in ['disk', 'smart', '/dev/sd', 'ata', 'i/o error'])
if disk_related:
disk_device = extract_disk_device(combined_text)
if disk_device:
smart_data = get_smart_data(disk_device)
if smart_data:
context_parts.append(smart_data)
# 4. Known error matching
known_error_ctx = get_error_context(combined_text, category=category, detail_level=detail_level)
if known_error_ctx:
context_parts.append(known_error_ctx)
# 5. Add original journal context
if journal_context:
context_parts.append(f"Journal logs:\n{journal_context}")
# Combine all parts
if context_parts:
return "\n\n".join(context_parts)
return journal_context or ""
def get_enriched_context(
event: 'NotificationEvent',
detail_level: str = 'standard'
) -> str:
"""Convenience function to enrich context from a NotificationEvent.
Args:
event: NotificationEvent object
detail_level: Level of detail
Returns:
Enriched context string
"""
journal_context = event.data.get('_journal_context', '')
return enrich_context_for_ai(
title=event.data.get('title', ''),
body=event.data.get('body', event.data.get('message', '')),
event_type=event.event_type,
data=event.data,
journal_context=journal_context,
detail_level=detail_level
)
+5 -1
View File
@@ -65,7 +65,7 @@ class AIProvider(ABC):
response = self.generate(
system_prompt="You are a test assistant. Respond with exactly: CONNECTION_OK",
user_message="Test connection",
max_tokens=20
max_tokens=50 # Some providers (Gemini) need more tokens to return any content
)
if response:
# Check if response contains our expected text
@@ -152,6 +152,10 @@ class AIProvider(ABC):
import urllib.request
import urllib.error
# Ensure User-Agent is set (Cloudflare blocks requests without it - error 1010)
if 'User-Agent' not in headers:
headers['User-Agent'] = 'ProxMenux/1.0'
data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(url, data=data, headers=headers, method='POST')
@@ -24,6 +24,30 @@ class GeminiProvider(AIProvider):
'learnlm', 'imagen', 'veo'
]
# Deprecated models that may still appear in API but return 404
DEPRECATED_MODELS = [
'gemini-2.0-flash',
'gemini-1.0-pro',
'gemini-pro',
]
@staticmethod
def _has_thinking_mode(model: str) -> bool:
"""True for Gemini variants that enable "thinking" by default.
Gemini 2.5+ and 3.x Pro/Flash models spend output tokens on
internal reasoning before emitting the final answer. With a small
max_tokens budget (250) that consumes the whole allowance and
leaves an empty reply. For the short translate/explain use case
in ProxMenux we want direct output, so we disable thinking for
these. Lite variants (flash-lite) do NOT have thinking enabled
and are safe to leave alone.
"""
m = model.lower()
if 'lite' in m:
return False
return m.startswith('gemini-2.5') or m.startswith('gemini-3')
def list_models(self) -> List[str]:
"""List available Gemini models that support generateContent.
@@ -41,7 +65,7 @@ class GeminiProvider(AIProvider):
try:
url = f"{self.API_BASE}?key={self.api_key}"
req = urllib.request.Request(url, method='GET')
req = urllib.request.Request(url, method='GET', headers={'User-Agent': 'ProxMenux/1.0'})
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode('utf-8'))
@@ -65,6 +89,10 @@ class GeminiProvider(AIProvider):
if any(pattern in model_lower for pattern in self.EXCLUDED_PATTERNS):
continue
# Exclude deprecated models that return 404
if model_id in self.DEPRECATED_MODELS:
continue
models.append(model_id)
# Sort with recommended models first (flash-lite, flash, pro)
@@ -107,6 +135,18 @@ class GeminiProvider(AIProvider):
url = f"{self.API_BASE}/{self.model}:generateContent?key={self.api_key}"
# Gemini uses a specific format with contents array
gen_config = {
'maxOutputTokens': max_tokens,
'temperature': 0.3,
}
# Disable thinking on 2.5+ / 3.x pro & flash models so the limited
# output budget actually produces visible text. thinkingBudget=0
# is the official switch for this; lite variants and legacy
# models don't need (and ignore) the field.
if self._has_thinking_mode(self.model):
gen_config['thinkingConfig'] = {'thinkingBudget': 0}
payload = {
'systemInstruction': {
'parts': [{'text': system_prompt}]
@@ -117,10 +157,7 @@ class GeminiProvider(AIProvider):
'parts': [{'text': user_message}]
}
],
'generationConfig': {
'maxOutputTokens': max_tokens,
'temperature': 0.3,
}
'generationConfig': gen_config,
}
headers = {
@@ -132,11 +169,39 @@ class GeminiProvider(AIProvider):
try:
# Gemini returns candidates array with content parts
candidates = result.get('candidates', [])
if candidates:
content = candidates[0].get('content', {})
parts = content.get('parts', [])
if parts:
return parts[0].get('text', '').strip()
raise AIProviderError("No content in response")
if not candidates:
# Check for blocked content or other issues
prompt_feedback = result.get('promptFeedback', {})
block_reason = prompt_feedback.get('blockReason', '')
if block_reason:
raise AIProviderError(f"Content blocked by Gemini: {block_reason}")
raise AIProviderError("No candidates in response - model may be overloaded")
# Check if response was blocked
finish_reason = candidates[0].get('finishReason', '')
if finish_reason == 'SAFETY':
safety_ratings = candidates[0].get('safetyRatings', [])
blocked_categories = [r.get('category', 'UNKNOWN') for r in safety_ratings
if r.get('blocked', False)]
raise AIProviderError(f"Response blocked by safety filter: {blocked_categories}")
content = candidates[0].get('content', {})
parts = content.get('parts', [])
if parts:
text = parts[0].get('text', '').strip()
if text:
return text
# No text content - check if it's a known issue
if finish_reason == 'MAX_TOKENS':
# MAX_TOKENS with no content could mean prompt too long OR model overload
raise AIProviderError("No response generated (MAX_TOKENS). Model may be overloaded - try again.")
elif finish_reason == 'STOP':
# Normal stop but no content - unusual
raise AIProviderError("Model returned empty response")
else:
raise AIProviderError(f"No response from model (reason: {finish_reason}). Try again later.")
except AIProviderError:
raise
except (KeyError, IndexError) as e:
raise AIProviderError(f"Unexpected response format: {e}")
@@ -38,7 +38,10 @@ class GroqProvider(AIProvider):
try:
req = urllib.request.Request(
self.MODELS_URL,
headers={'Authorization': f'Bearer {self.api_key}'},
headers={
'Authorization': f'Bearer {self.api_key}',
'User-Agent': 'ProxMenux/1.0' # Cloudflare blocks requests without User-Agent
},
method='GET'
)
@@ -63,8 +63,10 @@ class OllamaProvider(AIProvider):
# Cloud models (e.g., kimi-k2.5:cloud, minimax-m2.7:cloud) need longer timeout
# because requests go through: ProxMenux -> Ollama -> Cloud Provider -> back
# Local models also need generous timeout for slower hardware (e.g., low-end CPUs,
# no GPU acceleration, larger models like 8B parameters)
is_cloud_model = ':cloud' in self.model.lower()
timeout = 120 if is_cloud_model else 30 # 2 minutes for cloud, 30s for local
timeout = 120 if is_cloud_model else 90 # 2 minutes for cloud, 90s for local
try:
result = self._make_request(url, payload, headers, timeout=timeout)
@@ -94,7 +96,7 @@ class OllamaProvider(AIProvider):
# First check if server is running
try:
url = f"{self.base_url.rstrip('/')}/api/tags"
req = urllib.request.Request(url, method='GET')
req = urllib.request.Request(url, method='GET', headers={'User-Agent': 'ProxMenux/1.0'})
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode('utf-8'))
@@ -37,23 +37,49 @@ class OpenAIProvider(AIProvider):
# Recommended models for chat (in priority order)
RECOMMENDED_PREFIXES = ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo']
@staticmethod
def _is_reasoning_model(model: str) -> bool:
"""True for OpenAI reasoning models (o-series + non-chat gpt-5+).
These use a stricter API contract than chat models:
- Must use ``max_completion_tokens`` instead of ``max_tokens``
- ``temperature`` is not accepted (only the default is supported)
Chat-optimized variants (``gpt-5-chat-latest``,
``gpt-5.1-chat-latest``, etc.) keep the classic contract and are
NOT flagged here.
"""
m = model.lower()
# o1, o3, o4, o5 ... (o<digit>...)
if len(m) >= 2 and m[0] == 'o' and m[1].isdigit():
return True
# gpt-5, gpt-5-mini, gpt-5.1, gpt-5.2-pro ... EXCEPT *-chat-latest
if m.startswith('gpt-5') and '-chat' not in m:
return True
return False
def list_models(self) -> List[str]:
"""List available OpenAI models for chat completions.
Filters to only chat-capable models, excluding:
- Embedding models
- Audio/speech models (whisper, tts)
- Image models (dall-e)
- Instruct models (different API)
- Legacy models (babbage, davinci, etc.)
"""List available models for chat completions.
Two modes:
- Official OpenAI (no custom base_url): restrict to GPT chat models,
excluding embedding/whisper/tts/dall-e/instruct/legacy variants.
- OpenAI-compatible endpoint (LiteLLM, MLX, LM Studio, vLLM,
LocalAI, Ollama-proxy, etc.): the "gpt" substring check is
dropped so user-served models (e.g. ``mlx-community/Llama-3.1-8B``,
``Qwen3-32B``, ``mistralai/...``) show up. EXCLUDED_PATTERNS
still applies embeddings/whisper/tts aren't chat-capable on
any backend.
Returns:
List of model IDs suitable for chat completions.
"""
if not self.api_key:
return []
is_custom_endpoint = bool(self.base_url)
try:
# Determine models URL from base_url if set
if self.base_url:
@@ -63,42 +89,46 @@ class OpenAIProvider(AIProvider):
models_url = f"{base}/models"
else:
models_url = self.DEFAULT_MODELS_URL
req = urllib.request.Request(
models_url,
headers={'Authorization': f'Bearer {self.api_key}'},
method='GET'
)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode('utf-8'))
models = []
for model in data.get('data', []):
model_id = model.get('id', '')
if not model_id:
continue
model_lower = model_id.lower()
# Must be a GPT model
if 'gpt' not in model_lower:
# Official OpenAI: restrict to GPT chat models. Custom
# endpoints serve arbitrarily named models, so this
# substring check would drop every valid result there.
if not is_custom_endpoint and 'gpt' not in model_lower:
continue
# Exclude non-chat models
# Exclude non-chat models on every backend.
if any(pattern in model_lower for pattern in self.EXCLUDED_PATTERNS):
continue
models.append(model_id)
# Sort with recommended models first
# Sort with recommended models first (only meaningful for OpenAI
# official; on custom endpoints the prefixes rarely match, so
# entries fall through to alphabetical order, which is fine).
def sort_key(m):
m_lower = m.lower()
for i, prefix in enumerate(self.RECOMMENDED_PREFIXES):
if m_lower.startswith(prefix):
return (i, m)
return (len(self.RECOMMENDED_PREFIXES), m)
return sorted(models, key=sort_key)
except Exception as e:
print(f"[OpenAIProvider] Failed to list models: {e}")
@@ -133,17 +163,35 @@ class OpenAIProvider(AIProvider):
"""
if not self.api_key:
raise AIProviderError("API key required for OpenAI")
payload = {
'model': self.model,
'messages': [
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': user_message},
],
'max_tokens': max_tokens,
'temperature': 0.3,
}
# Reasoning models (o1/o3/o4/gpt-5*, excluding *-chat-latest) use a
# different parameter contract: max_completion_tokens instead of
# max_tokens, and no temperature field. Sending the classic chat
# parameters to them produces HTTP 400 Bad Request.
#
# They also spend output budget on internal reasoning by default,
# which empties the user-visible reply when max_tokens is small
# (like the ~200 we use for notifications). reasoning_effort
# 'minimal' keeps that internal reasoning to a minimum so the
# entire budget is available for the translation, which is
# exactly what this pipeline wants. OpenAI documents 'minimal',
# 'low', 'medium', 'high' — 'minimal' is the right setting for a
# straightforward translate+explain task.
if self._is_reasoning_model(self.model):
payload['max_completion_tokens'] = max_tokens
payload['reasoning_effort'] = 'minimal'
else:
payload['max_tokens'] = max_tokens
payload['temperature'] = 0.3
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {self.api_key}',
+5 -1
View File
@@ -95,6 +95,9 @@ cp "$SCRIPT_DIR/notification_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo
cp "$SCRIPT_DIR/notification_channels.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ notification_channels.py not found"
cp "$SCRIPT_DIR/notification_templates.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ notification_templates.py not found"
cp "$SCRIPT_DIR/notification_events.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ notification_events.py not found"
cp "$SCRIPT_DIR/proxmox_known_errors.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_known_errors.py not found"
cp "$SCRIPT_DIR/ai_context_enrichment.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ ai_context_enrichment.py not found"
cp "$SCRIPT_DIR/startup_grace.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ startup_grace.py not found"
cp "$SCRIPT_DIR/flask_notification_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_notification_routes.py not found"
cp "$SCRIPT_DIR/oci_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_manager.py not found"
cp "$SCRIPT_DIR/flask_oci_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_oci_routes.py not found"
@@ -110,12 +113,13 @@ else
echo "⚠️ ai_providers directory not found"
fi
# Copy config files (verified AI models, etc.)
# Copy config files (verified AI models, prompts, etc.)
echo "📋 Copying config files..."
CONFIG_DIR="$APPIMAGE_ROOT/config"
if [ -d "$CONFIG_DIR" ]; then
mkdir -p "$APP_DIR/usr/bin/config"
cp "$CONFIG_DIR/"*.json "$APP_DIR/usr/bin/config/" 2>/dev/null || true
cp "$CONFIG_DIR/"*.txt "$APP_DIR/usr/bin/config/" 2>/dev/null || true
echo "✅ Config files copied"
else
echo "⚠️ config directory not found"
+142
View File
@@ -456,3 +456,145 @@ def delete_storage_exclusion(storage_name):
return jsonify({'error': 'Storage not found in exclusions'}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500
# ═══════════════════════════════════════════════════════════════════════════
# NETWORK INTERFACE EXCLUSION ROUTES
# ═══════════════════════════════════════════════════════════════════════════
@health_bp.route('/api/health/interfaces', methods=['GET'])
def get_network_interfaces():
"""Get all network interfaces with their exclusion status."""
try:
import psutil
# Get all interfaces
net_if_stats = psutil.net_if_stats()
net_if_addrs = psutil.net_if_addrs()
# Get current exclusions
exclusions = {e['interface_name']: e for e in health_persistence.get_excluded_interfaces()}
result = []
for iface, stats in net_if_stats.items():
if iface == 'lo':
continue
# Determine interface type
if iface.startswith('vmbr'):
iface_type = 'bridge'
elif iface.startswith('bond'):
iface_type = 'bond'
elif iface.startswith(('vlan', 'veth')):
iface_type = 'vlan'
elif iface.startswith(('eth', 'ens', 'enp', 'eno')):
iface_type = 'physical'
else:
iface_type = 'other'
# Get IP address if any
ip_addr = None
if iface in net_if_addrs:
for addr in net_if_addrs[iface]:
if addr.family == 2: # IPv4
ip_addr = addr.address
break
exclusion = exclusions.get(iface, {})
result.append({
'name': iface,
'type': iface_type,
'is_up': stats.isup,
'speed': stats.speed,
'ip_address': ip_addr,
'exclude_health': exclusion.get('exclude_health', 0) == 1,
'exclude_notifications': exclusion.get('exclude_notifications', 0) == 1,
'excluded_at': exclusion.get('excluded_at'),
'reason': exclusion.get('reason')
})
# Sort: bridges first, then physical, then others
type_order = {'bridge': 0, 'bond': 1, 'physical': 2, 'vlan': 3, 'other': 4}
result.sort(key=lambda x: (type_order.get(x['type'], 5), x['name']))
return jsonify({'interfaces': result})
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/interface-exclusions', methods=['GET'])
def get_interface_exclusions():
"""Get all interface exclusions."""
try:
exclusions = health_persistence.get_excluded_interfaces()
return jsonify({'exclusions': exclusions})
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/interface-exclusions', methods=['POST'])
def save_interface_exclusion():
"""
Add or update an interface exclusion.
Request body:
{
"interface_name": "vmbr0",
"interface_type": "bridge",
"exclude_health": true,
"exclude_notifications": true,
"reason": "Intentionally disabled bridge"
}
"""
try:
data = request.get_json()
if not data or 'interface_name' not in data:
return jsonify({'error': 'interface_name is required'}), 400
interface_name = data['interface_name']
interface_type = data.get('interface_type', 'unknown')
exclude_health = data.get('exclude_health', True)
exclude_notifications = data.get('exclude_notifications', True)
reason = data.get('reason')
# Check if already excluded
existing = health_persistence.get_excluded_interfaces()
exists = any(e['interface_name'] == interface_name for e in existing)
if exists:
# Update existing
success = health_persistence.update_interface_exclusion(
interface_name, exclude_health, exclude_notifications
)
else:
# Add new
success = health_persistence.exclude_interface(
interface_name, interface_type, exclude_health, exclude_notifications, reason
)
if success:
return jsonify({
'success': True,
'message': f'Interface {interface_name} exclusion saved',
'interface_name': interface_name
})
else:
return jsonify({'error': 'Failed to save exclusion'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/interface-exclusions/<interface_name>', methods=['DELETE'])
def delete_interface_exclusion(interface_name):
"""Remove an interface from the exclusion list."""
try:
success = health_persistence.remove_interface_exclusion(interface_name)
if success:
return jsonify({
'success': True,
'message': f'Interface {interface_name} removed from exclusions'
})
else:
return jsonify({'error': 'Interface not found in exclusions'}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500
+74 -10
View File
@@ -220,10 +220,20 @@ def get_provider_models():
# Get all models from provider API
api_models = ai_provider.list_models()
# OpenAI with a custom base URL means an OpenAI-compatible endpoint
# (LiteLLM, MLX, LM Studio, vLLM, LocalAI, Ollama-proxy...). The
# verified_ai_models.json list only contains official OpenAI IDs
# (gpt-4o-mini etc.), so intersecting against it would strip every
# model the user actually serves. Treat the custom-endpoint case
# like Ollama: return whatever the endpoint advertises, no filter.
is_openai_compat = (provider == 'openai' and bool(openai_base_url))
if not api_models:
# API failed, fall back to verified list only
if verified_models:
# API failed, fall back to verified list only (but not for
# custom endpoints — we don't know what the endpoint serves,
# so "gpt-4o-mini" as a fallback would be misleading).
if verified_models and not is_openai_compat:
models = sorted(verified_models)
return jsonify({
'success': True,
@@ -232,27 +242,38 @@ def get_provider_models():
'message': f'{len(models)} verified models (API unavailable)'
})
return jsonify({
'success': False,
'models': [],
'message': 'Could not retrieve models. Check your API key.'
'success': False,
'models': [],
'message': 'Could not retrieve models. Check your API key and endpoint URL.'
})
if is_openai_compat:
# Custom OpenAI-compatible endpoint: surface every model the
# endpoint reports. No verified-list intersection.
models = sorted(api_models)
return jsonify({
'success': True,
'models': models,
'recommended': models[0] if models else '',
'message': f'Found {len(models)} models on custom endpoint'
})
# Filter: only models that are BOTH in API and verified list
if verified_models:
api_models_set = set(api_models)
filtered_models = [m for m in verified_models if m in api_models_set]
if not filtered_models:
# No intersection - maybe verified list is outdated
# Return verified list anyway (will fail on use if truly unavailable)
filtered_models = list(verified_models)
# Sort with recommended first
def sort_key(m):
if m == recommended:
return (0, m)
return (1, m)
models = sorted(filtered_models, key=sort_key)
else:
# No verified list for this provider, return all from API
@@ -951,3 +972,46 @@ def proxmox_webhook():
except Exception as e:
# Still return 200 to avoid PVE flagging the webhook as broken
return jsonify({'accepted': False, 'error': 'internal_error', 'detail': str(e)}), 200
# ─── Internal Shutdown Event Endpoint ─────────────────────────────
@notification_bp.route('/api/internal/shutdown-event', methods=['POST'])
def internal_shutdown_event():
"""
Internal endpoint called by systemd ExecStop script to emit shutdown/reboot notification.
This allows the service to send a notification BEFORE it terminates.
Only accepts requests from localhost (127.0.0.1) for security.
"""
# Security: Only allow localhost
remote_addr = request.remote_addr
if remote_addr not in ('127.0.0.1', '::1', 'localhost'):
return jsonify({'error': 'forbidden', 'detail': 'localhost only'}), 403
try:
data = request.get_json(silent=True) or {}
event_type = data.get('event_type', 'system_shutdown')
hostname = data.get('hostname', 'unknown')
reason = data.get('reason', 'System is shutting down.')
# Validate event type
if event_type not in ('system_shutdown', 'system_reboot'):
return jsonify({'error': 'invalid_event_type'}), 400
# Emit the notification directly through notification_manager
notification_manager.emit_event(
event_type=event_type,
severity='INFO',
data={
'hostname': hostname,
'reason': reason,
},
source='systemd',
entity='node',
entity_id='',
)
return jsonify({'success': True, 'event_type': event_type}), 200
except Exception as e:
return jsonify({'error': 'internal_error', 'detail': str(e)}), 500
+247 -26
View File
@@ -1,33 +1,198 @@
from flask import Blueprint, jsonify
from flask import Blueprint, jsonify, request
import json
import os
import re
proxmenux_bp = Blueprint('proxmenux', __name__)
# Tool descriptions mapping
TOOL_DESCRIPTIONS = {
'lvm_repair': 'LVM PV Headers Repair',
'repo_cleanup': 'Repository Cleanup',
'subscription_banner': 'Subscription Banner Removal',
'time_sync': 'Time Synchronization',
'apt_languages': 'APT Language Skip',
'journald': 'Journald Optimization',
'logrotate': 'Logrotate Optimization',
'system_limits': 'System Limits Increase',
'entropy': 'Entropy Generation (haveged)',
'memory_settings': 'Memory Settings Optimization',
'kernel_panic': 'Kernel Panic Configuration',
'apt_ipv4': 'APT IPv4 Force',
'kexec': 'kexec for quick reboots',
'network_optimization': 'Network Optimizations',
'bashrc_custom': 'Bashrc Customization',
'figurine': 'Figurine',
'fastfetch': 'Fastfetch',
'log2ram': 'Log2ram (SSD Protection)',
'amd_fixes': 'AMD CPU (Ryzen/EPYC) fixes',
'persistent_network': 'Setting persistent network interfaces'
# Tool metadata: description, function name in bash script, and version
# version: current version of the optimization function
# function: the bash function name that implements this optimization
TOOL_METADATA = {
'subscription_banner': {'name': 'Subscription Banner Removal', 'function': 'remove_subscription_banner', 'version': '1.0'},
'time_sync': {'name': 'Time Synchronization', 'function': 'configure_time_sync', 'version': '1.0'},
'apt_languages': {'name': 'APT Language Skip', 'function': 'skip_apt_languages', 'version': '1.0'},
'journald': {'name': 'Journald Optimization', 'function': 'optimize_journald', 'version': '1.1'},
'logrotate': {'name': 'Logrotate Optimization', 'function': 'optimize_logrotate', 'version': '1.1'},
'system_limits': {'name': 'System Limits Increase', 'function': 'increase_system_limits', 'version': '1.1'},
# entropy removed — modern kernels 5.6+ have built-in entropy generation, haveged no longer needed
'memory_settings': {'name': 'Memory Settings Optimization', 'function': 'optimize_memory_settings', 'version': '1.1'},
'kernel_panic': {'name': 'Kernel Panic Configuration', 'function': 'configure_kernel_panic', 'version': '1.0'},
'apt_ipv4': {'name': 'APT IPv4 Force', 'function': 'force_apt_ipv4', 'version': '1.0'},
'kexec': {'name': 'kexec for quick reboots', 'function': 'enable_kexec', 'version': '1.0'},
'network_optimization': {'name': 'Network Optimizations', 'function': 'apply_network_optimizations', 'version': '1.0'},
'bashrc_custom': {'name': 'Bashrc Customization', 'function': 'customize_bashrc', 'version': '1.0'},
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.0'},
'fastfetch': {'name': 'Fastfetch', 'function': 'configure_fastfetch', 'version': '1.0'},
'log2ram': {'name': 'Log2ram (SSD Protection)', 'function': 'configure_log2ram', 'version': '1.0'},
'amd_fixes': {'name': 'AMD CPU (Ryzen/EPYC) fixes', 'function': 'apply_amd_fixes', 'version': '1.0'},
'persistent_network': {'name': 'Setting persistent network interfaces', 'function': 'setup_persistent_network', 'version': '1.0'},
'vfio_iommu': {'name': 'VFIO/IOMMU Passthrough', 'function': 'enable_vfio_iommu', 'version': '1.0'},
'lvm_repair': {'name': 'LVM PV Headers Repair', 'function': 'repair_lvm_headers', 'version': '1.0'},
'repo_cleanup': {'name': 'Repository Cleanup', 'function': 'cleanup_repos', 'version': '1.0'},
# ── Legacy / Deprecated entries ──
# These optimizations were applied by previous ProxMenux versions but are
# no longer needed or have been removed from the current scripts. We still
# expose their source code for transparency with existing users.
'entropy': {'name': 'Entropy Generation (haveged)', 'function': 'configure_entropy', 'version': '1.0', 'deprecated': True},
}
# Backward-compatible description mapping (used by get_installed_tools)
TOOL_DESCRIPTIONS = {k: v['name'] for k, v in TOOL_METADATA.items()}
# Source code preserved for deprecated/removed optimization functions.
# When a function is removed from the active bash scripts (because it's
# no longer needed, e.g. obsoleted by kernel improvements), keep its code
# here so users who installed it in the past can still inspect what ran.
DEPRECATED_SOURCES = {
'configure_entropy': {
'script': 'customizable_post_install.sh (legacy)',
'source': '''# ─────────────────────────────────────────────────────────────────
# NOTE: This optimization has been REMOVED from current ProxMenux versions.
# Modern Linux kernels (5.6+, shipped with Proxmox VE 7.x and 8.x) include
# built-in entropy generation via the Jitter RNG and CRNG, making haveged
# unnecessary. The function below is preserved here for transparency so
# users who applied it in the past can see exactly what was installed.
# New ProxMenux installations no longer include this optimization.
# ─────────────────────────────────────────────────────────────────
configure_entropy() {
msg_info2 "$(translate "Configuring entropy generation to prevent slowdowns...")"
# Install haveged
msg_info "$(translate "Installing haveged...")"
/usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' install haveged > /dev/null 2>&1
msg_ok "$(translate "haveged installed successfully")"
# Configure haveged
msg_info "$(translate "Configuring haveged...")"
cat <<EOF > /etc/default/haveged
# -w sets low entropy watermark (in bits)
DAEMON_ARGS="-w 1024"
EOF
# Reload systemd daemon
systemctl daemon-reload > /dev/null 2>&1
# Enable haveged service
systemctl enable haveged > /dev/null 2>&1
msg_ok "$(translate "haveged service enabled successfully")"
register_tool "entropy" true
msg_success "$(translate "Entropy generation configuration completed")"
}
''',
},
}
# Scripts to search for function source code (in order of preference)
_SCRIPT_PATHS = [
'/usr/local/share/proxmenux/scripts/post_install/customizable_post_install.sh',
'/usr/local/share/proxmenux/scripts/post_install/auto_post_install.sh',
]
def _extract_bash_function(function_name: str) -> dict:
"""Extract a bash function's source code.
Checks DEPRECATED_SOURCES first (for functions removed from active scripts),
then searches the live bash scripts for `function_name() {` and captures
everything until the matching closing `}`, respecting brace nesting.
Returns {'source': str, 'script': str, 'line_start': int, 'line_end': int}
or {'source': '', 'error': '...'} on failure.
"""
# Check preserved deprecated source code first
if function_name in DEPRECATED_SOURCES:
entry = DEPRECATED_SOURCES[function_name]
source = entry['source']
return {
'source': source,
'script': entry['script'],
'line_start': 1,
'line_end': len(source.split('\n')),
}
for script_path in _SCRIPT_PATHS:
if not os.path.isfile(script_path):
continue
try:
with open(script_path, 'r') as f:
lines = f.readlines()
# Find function start: "function_name() {" or "function_name () {"
pattern = re.compile(rf'^{re.escape(function_name)}\s*\(\)\s*\{{')
start_idx = None
for i, line in enumerate(lines):
if pattern.match(line):
start_idx = i
break
if start_idx is None:
continue # Try next script
# Capture until the closing } at indent level 0
brace_depth = 0
end_idx = start_idx
for i in range(start_idx, len(lines)):
brace_depth += lines[i].count('{') - lines[i].count('}')
if brace_depth <= 0:
end_idx = i
break
source = ''.join(lines[start_idx:end_idx + 1])
script_name = os.path.basename(script_path)
return {
'source': source,
'script': script_name,
'line_start': start_idx + 1,
'line_end': end_idx + 1,
}
except Exception:
continue
return {'source': '', 'error': 'Function not found in available scripts'}
@proxmenux_bp.route('/api/proxmenux/update-status', methods=['GET'])
def get_update_status():
"""Get ProxMenux update availability status from config.json"""
config_path = '/usr/local/share/proxmenux/config.json'
try:
if not os.path.exists(config_path):
return jsonify({
'success': True,
'update_available': {
'stable': False,
'stable_version': '',
'beta': False,
'beta_version': ''
}
})
with open(config_path, 'r') as f:
config = json.load(f)
update_status = config.get('update_available', {
'stable': False,
'stable_version': '',
'beta': False,
'beta_version': ''
})
return jsonify({
'success': True,
'update_available': update_status
})
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
@proxmenux_bp.route('/api/proxmenux/installed-tools', methods=['GET'])
def get_installed_tools():
"""Get list of installed ProxMenux tools/optimizations"""
@@ -44,14 +209,18 @@ def get_installed_tools():
with open(installed_tools_path, 'r') as f:
data = json.load(f)
# Convert to list format with descriptions
# Convert to list format with descriptions and version
tools = []
for tool_key, enabled in data.items():
if enabled: # Only include enabled tools
meta = TOOL_METADATA.get(tool_key, {})
tools.append({
'key': tool_key,
'name': TOOL_DESCRIPTIONS.get(tool_key, tool_key.replace('_', ' ').title()),
'enabled': enabled
'name': meta.get('name', tool_key.replace('_', ' ').title()),
'enabled': enabled,
'version': meta.get('version', '1.0'),
'has_source': bool(meta.get('function')),
'deprecated': bool(meta.get('deprecated', False)),
})
# Sort alphabetically by name
@@ -73,3 +242,55 @@ def get_installed_tools():
'success': False,
'error': str(e)
}), 500
@proxmenux_bp.route('/api/proxmenux/tool-source/<tool_key>', methods=['GET'])
def get_tool_source(tool_key):
"""Get the bash source code of a specific optimization function.
Returns the function body extracted from the post-install scripts,
so users can see exactly what code was executed on their server.
"""
try:
meta = TOOL_METADATA.get(tool_key)
if not meta:
return jsonify({
'success': False,
'error': f'Unknown tool: {tool_key}'
}), 404
func_name = meta.get('function')
if not func_name:
return jsonify({
'success': False,
'error': f'No function mapping for {tool_key}'
}), 404
result = _extract_bash_function(func_name)
if not result.get('source'):
return jsonify({
'success': False,
'error': result.get('error', 'Source code not available'),
'tool': tool_key,
'function': func_name,
}), 404
return jsonify({
'success': True,
'tool': tool_key,
'name': meta['name'],
'version': meta.get('version', '1.0'),
'deprecated': bool(meta.get('deprecated', False)),
'function': func_name,
'source': result['source'],
'script': result['script'],
'line_start': result['line_start'],
'line_end': result['line_end'],
})
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
+28
View File
@@ -308,6 +308,34 @@ def lynis_report_delete():
return jsonify({"success": False, "message": str(e)}), 500
# -------------------------------------------------------------------
# Security Tools Uninstall
# -------------------------------------------------------------------
@security_bp.route('/api/security/fail2ban/uninstall', methods=['POST'])
def fail2ban_uninstall():
"""Uninstall Fail2Ban and clean up configuration"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
success, message = security_manager.uninstall_fail2ban()
return jsonify({"success": success, "message": message})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/lynis/uninstall', methods=['POST'])
def lynis_uninstall():
"""Uninstall Lynis and clean up files"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
success, message = security_manager.uninstall_lynis()
return jsonify({"success": success, "message": message})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
# -------------------------------------------------------------------
# Security Tools Detection
# -------------------------------------------------------------------
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21 -5
View File
@@ -135,7 +135,7 @@ class TelegramChannel(NotificationChannel):
'UNKNOWN': '\u26AA', # white circle
}
def __init__(self, bot_token: str, chat_id: str):
def __init__(self, bot_token: str, chat_id: str, topic_id: str = ''):
super().__init__()
token = bot_token.strip()
# Strip 'bot' prefix if user included it (API_BASE already adds it)
@@ -143,6 +143,8 @@ class TelegramChannel(NotificationChannel):
token = token[3:]
self.bot_token = token
self.chat_id = chat_id.strip()
# Topic ID for supergroups with topics enabled (message_thread_id)
self.topic_id = topic_id.strip() if topic_id else ''
def validate_config(self) -> Tuple[bool, str]:
if not self.bot_token:
@@ -177,6 +179,12 @@ class TelegramChannel(NotificationChannel):
'chat_id': self.chat_id,
'photo': photo_url,
}
# Add topic ID for supergroups with topics enabled
if self.topic_id:
try:
payload['message_thread_id'] = int(self.topic_id)
except ValueError:
pass
if caption:
payload['caption'] = caption[:1024] # Telegram caption limit
payload['parse_mode'] = 'HTML'
@@ -204,13 +212,20 @@ class TelegramChannel(NotificationChannel):
def _post_message(self, text: str) -> Tuple[int, str]:
url = self.API_BASE.format(token=self.bot_token)
payload = json.dumps({
payload_dict = {
'chat_id': self.chat_id,
'text': text,
'parse_mode': 'HTML',
'disable_web_page_preview': True,
}).encode('utf-8')
}
# Add topic ID for supergroups with topics enabled
if self.topic_id:
try:
payload_dict['message_thread_id'] = int(self.topic_id)
except ValueError:
pass # Invalid topic_id, skip
payload = json.dumps(payload_dict).encode('utf-8')
return self._http_request(url, payload, {'Content-Type': 'application/json'})
def _split_message(self, text: str) -> list:
@@ -859,7 +874,7 @@ class EmailChannel(NotificationChannel):
CHANNEL_TYPES = {
'telegram': {
'name': 'Telegram',
'config_keys': ['bot_token', 'chat_id'],
'config_keys': ['bot_token', 'chat_id', 'topic_id'],
'class': TelegramChannel,
},
'gotify': {
@@ -895,7 +910,8 @@ def create_channel(channel_type: str, config: Dict[str, str]) -> Optional[Notifi
if channel_type == 'telegram':
return TelegramChannel(
bot_token=config.get('bot_token', ''),
chat_id=config.get('chat_id', '')
chat_id=config.get('chat_id', ''),
topic_id=config.get('topic_id', '')
)
elif channel_type == 'gotify':
return GotifyChannel(
+711 -41
View File
@@ -26,6 +26,59 @@ from typing import Optional, Dict, Any, Tuple
from pathlib import Path
# ─── Shared State for Cross-Watcher Coordination ──────────────────
# ─── Startup Grace Period ────────────────────────────────────────────────────
# Import centralized startup grace management
# This provides a single source of truth for all grace period logic
import startup_grace
class _SharedState:
"""Wrapper around centralized startup_grace module for backwards compatibility.
All grace period logic is now in startup_grace.py for consistency across:
- notification_events.py (this file)
- health_monitor.py
- flask_server.py
"""
def mark_shutdown(self):
"""Called when system_shutdown or system_reboot is detected."""
startup_grace.mark_shutdown()
def is_host_shutting_down(self) -> bool:
"""Check if we're within the shutdown grace period."""
return startup_grace.is_host_shutting_down()
def is_startup_period(self) -> bool:
"""Check if we're within the startup VM aggregation period (3 min)."""
return startup_grace.is_startup_vm_period()
def is_startup_health_grace(self) -> bool:
"""Check if we're within the startup health grace period (5 min)."""
return startup_grace.is_startup_health_grace()
def add_startup_vm(self, vmid: str, vmname: str, vm_type: str):
"""Record a VM/CT start during startup period for later aggregation."""
startup_grace.add_startup_vm(vmid, vmname, vm_type)
def get_and_clear_startup_vms(self) -> list:
"""Get all recorded startup VMs and clear the list."""
return startup_grace.get_and_clear_startup_vms()
def has_startup_vms(self) -> bool:
"""Check if there are any startup VMs recorded."""
return startup_grace.has_startup_vms()
def was_startup_aggregated(self) -> bool:
"""Check if startup aggregation already happened."""
return startup_grace.was_startup_aggregated()
# Global shared state instance
_shared_state = _SharedState()
# ─── Event Object ─────────────────────────────────────────────────
class NotificationEvent:
@@ -84,6 +137,30 @@ class NotificationEvent:
def _hostname() -> str:
"""Get display hostname for notifications.
Returns the custom display name from notification settings if configured,
otherwise falls back to the system hostname.
"""
# Try to read custom display name from notification settings
try:
db_path = Path('/usr/local/share/proxmenux/health_monitor.db')
if db_path.exists():
conn = sqlite3.connect(str(db_path), timeout=5)
conn.execute('PRAGMA busy_timeout=3000')
cursor = conn.cursor()
cursor.execute(
"SELECT setting_value FROM user_settings WHERE setting_key = ?",
('notification.hostname',)
)
row = cursor.fetchone()
conn.close()
if row and row[0] and row[0].strip():
return row[0].strip()
except Exception:
pass # Fall back to system hostname
# Fall back to system hostname
try:
return socket.gethostname().split('.')[0]
except Exception:
@@ -121,8 +198,9 @@ def capture_journal_context(keywords: list, lines: int = 30,
return ""
# Use journalctl with grep to filter relevant lines
# Use -b 0 to only include logs from the current boot (not previous boots)
cmd = (
f"journalctl --since='{since}' --no-pager -n 500 2>/dev/null | "
f"journalctl -b 0 --since='{since}' --no-pager -n 500 2>/dev/null | "
f"grep -iE '{pattern}' | tail -n {lines}"
)
@@ -241,6 +319,41 @@ class JournalWatcher:
except Exception as e:
print(f"[JournalWatcher] Failed to save disk_io_notified: {e}")
def _get_disk_io_cooldown_from_db(self, device: str) -> Optional[float]:
"""
Get disk I/O cooldown timestamp from DB for a device.
Used to re-check DB when user might have dismissed the error,
which clears the DB entry via health_persistence._clear_disk_io_cooldown().
Returns the timestamp if found and within 24h window, None otherwise.
"""
try:
db_path = Path('/usr/local/share/proxmenux/health_monitor.db')
if not db_path.exists():
return None
conn = sqlite3.connect(str(db_path), timeout=5)
conn.execute('PRAGMA busy_timeout=3000')
cursor = conn.cursor()
# Check for the device with various prefixes
# JournalWatcher uses direct device names as keys
cursor.execute(
"SELECT last_sent_ts FROM notification_last_sent WHERE fingerprint = ?",
(device,)
)
row = cursor.fetchone()
conn.close()
if row:
ts = float(row[0])
# Only return if within 24h window
if time.time() - ts < self._DISK_IO_COOLDOWN:
return ts
return None
except Exception:
return None
def stop(self):
"""Stop the journal watcher."""
self._running = False
@@ -350,13 +463,30 @@ class JournalWatcher:
def _check_fail2ban(self, msg: str, syslog_id: str):
"""Detect Fail2Ban IP bans."""
if 'fail2ban' not in msg.lower() and syslog_id != 'fail2ban-server':
return
# Only process actual fail2ban action messages, not systemd service events
if syslog_id not in ('fail2ban-server', 'fail2ban.actions', 'fail2ban'):
if 'fail2ban' not in msg.lower():
return
# Skip systemd service lifecycle messages (start/stop/restart/reload)
msg_lower = msg.lower()
if any(x in msg_lower for x in ['service', 'started', 'stopped', 'starting',
'stopping', 'reloading', 'reloaded', 'unit',
'deactivated', 'activated']):
return
# Ban detected
ban_match = re.search(r'Ban\s+(\S+)', msg)
# Ban detected - match only valid IPv4 or IPv6 addresses
# IPv4: 192.168.1.100, IPv6: 2001:db8::1 or ::ffff:192.168.1.1
ban_match = re.search(r'Ban\s+((?:\d{1,3}\.){3}\d{1,3}|[0-9a-fA-F:]{2,})', msg)
if ban_match:
ip = ban_match.group(1)
# Validate it's a real IP address format
# IPv4: must have 4 octets separated by dots
# IPv6: must contain at least one colon
is_ipv4 = re.match(r'^(\d{1,3}\.){3}\d{1,3}$', ip)
is_ipv6 = ':' in ip and re.match(r'^[0-9a-fA-F:]+$', ip)
if not is_ipv4 and not is_ipv6:
return # Not a valid IP (e.g., "Service.", "Ban", etc.)
jail_match = re.search(r'\[(\w+)\]', msg)
jail = jail_match.group(1) if jail_match else 'unknown'
@@ -494,7 +624,14 @@ class JournalWatcher:
fs_dedup_key = f'fs_{device}'
last_fs_notified = self._disk_io_notified.get(fs_dedup_key, 0)
if now_fs - last_fs_notified < self._DISK_IO_COOLDOWN:
return # Already notified for this device recently
# In-memory says cooldown active. Re-check DB in case
# user dismissed the error (which clears DB cooldowns).
db_ts = self._get_disk_io_cooldown_from_db(fs_dedup_key)
if db_ts is not None and now_fs - db_ts < self._DISK_IO_COOLDOWN:
return # DB confirms cooldown is still active
# DB says cooldown was cleared - proceed
if fs_dedup_key in self._disk_io_notified:
del self._disk_io_notified[fs_dedup_key]
# ── Device existence gating ──
device_exists = base_dev and _os.path.exists(f'/dev/{base_dev}')
@@ -539,12 +676,10 @@ class JournalWatcher:
if inode:
inode_hint = 'root directory' if inode == '2' else f'inode #{inode}'
parts.append(f'Affected: {inode_hint}')
if smart_health == 'FAILED':
parts.append(f'Action: Disk is failing. Run "fsck /dev/{device}" (unmount first) and plan replacement')
elif smart_health == 'PASSED':
# Note: Specific recommendations are provided by AI when AI Suggestions is enabled
# Only include SMART status note (not an action)
if smart_health == 'PASSED':
parts.append(f'Note: SMART reports disk is healthy. This may be a transient error.')
else:
parts.append(f'Action: Run "fsck /dev/{device}" (unmount first) and check "smartctl -a /dev/{base_dev}"')
enriched = '\n'.join(parts)
else:
@@ -747,12 +882,34 @@ class JournalWatcher:
smart_health = self._quick_smart_health(resolved)
if smart_health != 'FAILED':
return
# ── Persist observation (before the cooldown gate) ──
# The 24h cooldown below only suppresses RE-notification; the
# per-disk observations history must reflect every genuine
# detection. The DB UPSERT dedups same-signature events via
# occurrence_count, so calling this on every match is safe.
# Aligns with the parallel path in HealthMonitor._check_disks_optimized.
self._record_disk_io_observation(resolved, msg)
# ── Gate 2: 24-hour dedup per device ──
# Check both in-memory cache AND the DB (user dismiss clears DB cooldowns).
# If user dismissed the error, _clear_disk_io_cooldown() removed the DB
# entry, so we should refresh from DB to get the real state.
now = time.time()
# First check in-memory cache
last_notified = self._disk_io_notified.get(resolved, 0)
if now - last_notified < self._DISK_IO_COOLDOWN:
return # Already notified for this disk recently
# In-memory says we already notified. But user might have dismissed
# the error, which clears the DB. Re-check DB to be sure.
db_ts = self._get_disk_io_cooldown_from_db(resolved)
if db_ts is not None and now - db_ts < self._DISK_IO_COOLDOWN:
return # DB confirms cooldown is still active
# DB says cooldown was cleared (user dismissed) - proceed to notify
# Update in-memory cache
del self._disk_io_notified[resolved]
self._disk_io_notified[resolved] = now
self._save_disk_io_notified(resolved, now)
@@ -837,6 +994,55 @@ class JournalWatcher:
except Exception:
return 'UNKNOWN'
def _record_disk_io_observation(self, resolved: str, msg: str):
"""Persist a kernel-journal I/O error as a disk observation.
Signature classification mirrors HealthMonitor._make_io_obs_signature
so observations from the real-time journal watcher and the periodic
dmesg scan dedup into the same row (via the UPSERT on
disk_registry_id + error_type + error_signature).
"""
try:
from health_persistence import health_persistence
m = msg.lower()
if re.search(r'exception\s+emask|emask\s+0x|revalidation failed|'
r'hard resetting link|serror.*badcrc|comreset|'
r'link is slow|status.*drdy', m):
family = 'ata_connection_error'
elif re.search(r'i/o error|blk_update_request|medium error|sense key', m):
family = 'block_io_error'
elif re.search(r'failed command|fpdma queued', m):
family = 'ata_failed_command'
else:
family = 'generic'
# Best-effort serial lookup so the observation survives device
# renames (ata8 -> sdh, USB reconnects, etc.).
serial = None
try:
sm = subprocess.run(
['smartctl', '-i', f'/dev/{resolved}'],
capture_output=True, text=True, timeout=3)
if sm.returncode in (0, 4):
for line in sm.stdout.split('\n'):
if 'Serial Number' in line or 'Serial number' in line:
serial = line.split(':')[-1].strip()
break
except Exception:
pass
health_persistence.record_disk_observation(
device_name=resolved,
serial=serial,
error_type='io_error',
error_signature=f'io_{resolved}_{family}',
raw_message=f'/dev/{resolved}: {msg.strip()[:200]}',
severity='critical',
)
except Exception as e:
print(f"[JournalWatcher] Error recording disk io observation: {e}")
def _record_smartd_observation(self, title: str, message: str):
"""Extract device info from a smartd system-mail and record as disk observation."""
try:
@@ -896,10 +1102,7 @@ class JournalWatcher:
raw_message=raw_msg,
severity='warning',
)
# Update worst_health for permanent tracking (record_disk_observation
# already does this, but we ensure it here for safety)
health_persistence.update_disk_worst_health(base_dev, serial, 'warning')
# Observation recorded - worst_health no longer used (badge shows current SMART status)
except Exception as e:
print(f"[DiskIOEventProcessor] Error recording smartd observation: {e}")
@@ -1173,11 +1376,15 @@ class JournalWatcher:
break
if is_reboot:
# Mark shutdown state to suppress VM/CT stop events
_shared_state.mark_shutdown()
self._emit('system_reboot', 'INFO', {
'reason': 'The system is rebooting.',
'hostname': self._hostname,
}, entity='node', entity_id='')
elif is_shutdown:
# Mark shutdown state to suppress VM/CT stop events
_shared_state.mark_shutdown()
self._emit('system_shutdown', 'INFO', {
'reason': 'The system is shutting down.',
'hostname': self._hostname,
@@ -1250,6 +1457,74 @@ class TaskWatcher:
"""
TASK_LOG = '/var/log/pve/tasks/index'
TASK_DIR = '/var/log/pve/tasks'
def _get_task_log_reason(self, upid: str, status: str) -> str:
"""Read the task log file to extract the actual error/warning reason.
Returns a human-readable reason extracted from the task log,
or falls back to the status code if log cannot be read.
"""
try:
# Parse UPID to find log file
# UPID format: UPID:node:pid:pstart:starttime:type:id:user:
# Example: UPID:pve:0000F234:0000B890:67890ABC:qmstart:100:root@pam:
parts = upid.split(':')
if len(parts) < 5:
return status
# Task logs are stored in /var/log/pve/tasks/X/UPID
# where X is the LAST character of starttime hex (uppercase)
# Example: starttime=69CE20CF -> subdirectory is "F"
# The starttime field (parts[4]) is a hex timestamp
starttime_hex = parts[4]
if starttime_hex:
# LAST character of hex starttime determines subdirectory
subdir = starttime_hex[-1].upper()
# The log filename is the full UPID INCLUDING the trailing colon
# Proxmox names the file exactly as the UPID (with colon at end)
log_path = os.path.join(self.TASK_DIR, subdir, upid)
if os.path.exists(log_path):
with open(log_path, 'r', errors='replace') as f:
lines = f.readlines()
# Look for error/warning messages in the log
# Proxmox uses various patterns: "WARN:", "warning:", "error:", etc.
error_lines = []
for line in lines:
line_strip = line.strip()
line_lower = line_strip.lower()
# Skip empty lines and status lines at the end
if not line_strip or line_strip.startswith('TASK '):
continue
# Capture warning/error lines with various patterns
# Proxmox uses: "WARN: ...", "warning: ...", "error: ...", "ERROR: ..."
is_warning_error = any(kw in line_lower for kw in [
'warn:', 'warning:', 'error:', 'failed', 'failure',
'unable to', 'cannot', 'exception', 'critical',
'certificate', 'expired', 'expires' # EFI cert warnings
])
# Also check for lines starting with common prefixes
starts_with_prefix = any(line_strip.upper().startswith(p) for p in [
'WARN:', 'WARNING:', 'ERROR:', 'CRITICAL:', 'FATAL:'
])
if is_warning_error or starts_with_prefix:
if len(line_strip) < 300: # Reasonable length
error_lines.append(line_strip)
if error_lines:
# Return the most relevant lines (up to 5 for better context)
return '; '.join(error_lines[:5])
return status
except Exception as e:
# Log error for debugging but return status as fallback
return status
# Map PVE task types to our event types
TASK_MAP = {
@@ -1399,7 +1674,7 @@ class TaskWatcher:
except Exception as e:
print(f"[TaskWatcher] Error reading task log: {e}")
time.sleep(2) # Check every 2 seconds
time.sleep(5) # Check every 5 seconds (reduced from 2s for efficiency)
def _check_active_tasks(self):
"""Scan /var/log/pve/tasks/active to track vzdump for VM suppression.
@@ -1457,7 +1732,10 @@ class TaskWatcher:
return
upid = parts[0]
status = parts[2] if len(parts) >= 3 else ''
# Status can be multi-word like "WARNINGS: 1" or "OK"
# Format: UPID TIMESTAMP STATUS [...]
# Join everything after timestamp as status
status = ' '.join(parts[2:]) if len(parts) >= 3 else ''
# Parse UPID
upid_parts = upid.split(':')
@@ -1490,16 +1768,32 @@ class TaskWatcher:
# Backup just finished -- start grace period for VM restarts
self._vzdump_running_since = time.time() # will expire via grace_period
# Check if task failed
is_error = status and status != 'OK' and status != ''
# Check if task failed or completed with warnings
# WARNINGS means the task completed but with non-fatal issues (e.g., EFI cert warnings)
# The VM/CT DID start successfully, just with caveats
# Status format can be "WARNINGS: N" where N is the count, so use startswith
is_warning = status and status.upper().startswith('WARNINGS')
is_error = status and status != 'OK' and not is_warning and status != ''
if is_error:
# Override to failure event
# Override to failure event - task actually failed
if 'start' in event_type:
event_type = event_type.replace('_start', '_fail')
elif 'complete' in event_type:
event_type = event_type.replace('_complete', '_fail')
severity = 'CRITICAL'
elif is_warning:
# Task completed with warnings - VM/CT started but has issues
# Use specific warning event types for better messaging
if event_type == 'vm_start':
event_type = 'vm_start_warning'
elif event_type == 'ct_start':
event_type = 'ct_start_warning'
elif event_type == 'backup_start':
event_type = 'backup_warning' # Backup finished with warnings
elif event_type == 'migration_start':
event_type = 'migration_warning' # Migration finished with warnings
severity = 'WARNING'
elif status == 'OK':
# Task completed successfully
if event_type == 'backup_start':
@@ -1511,12 +1805,18 @@ class TaskWatcher:
# Task just started (no status yet)
severity = default_severity
# Get the actual reason from task log if error or warning
if is_error or is_warning:
reason = self._get_task_log_reason(upid, status)
else:
reason = ''
data = {
'vmid': vmid,
'vmname': vmname or f'ID {vmid}',
'hostname': self._hostname,
'user': user,
'reason': status if is_error else '',
'reason': reason,
'target_node': '',
'size': '',
'snapshot_name': '',
@@ -1529,9 +1829,9 @@ class TaskWatcher:
# EXCLUSIVELY by the PVE webhook, which delivers richer data (full
# logs, sizes, durations, filenames). TaskWatcher skips these to
# avoid duplicates.
# NOTE: backup_start is NOT in this set -- PVE's webhook only fires
# when a backup FINISHES, so TaskWatcher is the only source for
# the "backup started" notification.
# NOTE: backup_start and backup_warning are NOT in this set --
# PVE's webhook only fires when backup FINISHES with OK or ERROR,
# but WARNINGS come through TaskWatcher with richer context.
_WEBHOOK_EXCLUSIVE = {'backup_complete', 'backup_fail',
'replication_complete', 'replication_fail'}
if event_type in _WEBHOOK_EXCLUSIVE:
@@ -1539,13 +1839,33 @@ class TaskWatcher:
# Suppress VM/CT start/stop/shutdown while a vzdump is active.
# These are backup-induced operations (mode=stop), not user actions.
# Exception: if a VM/CT FAILS to start after backup, that IS important.
# Exception: if a VM/CT FAILS or has WARNINGS, that IS important.
_BACKUP_NOISE = {'vm_start', 'vm_stop', 'vm_shutdown', 'vm_restart',
'ct_start', 'ct_stop', 'ct_shutdown', 'ct_restart'}
if event_type in _BACKUP_NOISE and not is_error:
if event_type in _BACKUP_NOISE and not is_error and not is_warning:
if self._is_vzdump_active():
return
# Suppress VM/CT stop/shutdown during host shutdown/reboot.
# When the host shuts down, all VMs/CTs stop - that's expected behavior,
# not something that needs individual notifications.
# Exception: errors and warnings should still be notified.
_SHUTDOWN_NOISE = {'vm_stop', 'vm_shutdown', 'ct_stop', 'ct_shutdown'}
if event_type in _SHUTDOWN_NOISE and not is_error and not is_warning:
if _shared_state.is_host_shutting_down():
return
# During startup period, aggregate VM/CT starts into a single message.
# Instead of N individual "VM X started" messages, collect them and
# let PollingCollector emit one "System startup: X VMs, Y CTs started".
# Exception: errors and warnings should NOT be aggregated - notify immediately.
_STARTUP_EVENTS = {'vm_start', 'ct_start'}
if event_type in _STARTUP_EVENTS and not is_error and not is_warning:
if _shared_state.is_startup_period():
vm_type = 'ct' if event_type == 'ct_start' else 'vm'
_shared_state.add_startup_vm(vmid, vmname or f'ID {vmid}', vm_type)
return
self._queue.put(NotificationEvent(
event_type, severity, data, source='tasks',
entity=entity, entity_id=vmid,
@@ -1618,6 +1938,8 @@ class PollingCollector:
# Key = health_persistence category name
# Value = minimum seconds between notifications for the same error_key
_CATEGORY_COOLDOWNS = {
# Category cooldown: minimum time between DIFFERENT errors of the same category
# This prevents notification storms when multiple issues arise together
'disks': 86400, # 24h - I/O errors are persistent hardware issues
'smart': 86400, # 24h - SMART errors same as I/O
'zfs': 86400, # 24h - ZFS pool issues are persistent
@@ -1627,6 +1949,7 @@ class PollingCollector:
'temperature': 3600, # 1h - temp can fluctuate near thresholds
'logs': 3600, # 1h - repeated log patterns
'vms': 1800, # 30m - VM state oscillation
'vmct': 1800, # 30m - VM/CT state oscillation
'security': 3600, # 1h - auth failures tend to be bursty
'cpu': 1800, # 30m - CPU spikes can be transient
'memory': 1800, # 30m - memory pressure oscillation
@@ -1634,6 +1957,10 @@ class PollingCollector:
'updates': 86400, # 24h - update info doesn't change fast
}
# Global cooldown: minimum time before the SAME error can be re-notified
# This is independent of category - same error_key cannot repeat before this time
SAME_ERROR_COOLDOWN = 86400 # 24 hours
_ENTITY_MAP = {
'cpu': ('node', ''), 'memory': ('node', ''), 'temperature': ('node', ''),
'load': ('node', ''),
@@ -1673,7 +2000,11 @@ class PollingCollector:
self._poll_interval = poll_interval
self._hostname = _hostname()
self._last_update_check = 0
self._last_proxmenux_check = 0
self._last_ai_model_check = 0
# Track notified ProxMenux versions to avoid duplicates
self._notified_proxmenux_version: str | None = None
self._notified_proxmenux_beta_version: str | None = None
# In-memory cache: error_key -> last notification timestamp
self._last_notified: Dict[str, float] = {}
# Track known error keys + metadata so we can detect new ones AND emit recovery
@@ -1693,25 +2024,73 @@ class PollingCollector:
def stop(self):
self._running = False
def _sleep_until_offset(self, cycle_start: float, offset: float):
"""Sleep until the specified offset within the current cycle."""
target = cycle_start + offset
now = time.time()
if now < target:
time.sleep(target - now)
# ── Main loop ──────────────────────────────────────────────
# Categories where transient errors are suppressed during startup grace period.
# Now using centralized startup_grace module for consistency.
def _poll_loop(self):
"""Main polling loop."""
# Initial delay to let health monitor warm up
for _ in range(15):
# Initial delay to let health monitor and external services warm up.
# PBS storage, NFS mounts, VMs with guest agent all need time after boot.
for _ in range(60):
if not self._running:
return
time.sleep(1)
# Staggered execution: spread checks across the polling interval
# to avoid CPU spikes when multiple checks run simultaneously.
# Schedule: health=10s, updates=30s, proxmenux=45s, ai_model=50s
STAGGER_HEALTH = 10
STAGGER_UPDATES = 30
STAGGER_PROXMENUX = 45
STAGGER_AI_MODEL = 50
while self._running:
cycle_start = time.time()
try:
# Health check at offset 10s
self._sleep_until_offset(cycle_start, STAGGER_HEALTH)
if not self._running:
return
self._check_persistent_health()
# Updates check at offset 30s
self._sleep_until_offset(cycle_start, STAGGER_UPDATES)
if not self._running:
return
self._check_updates()
# ProxMenux check at offset 45s
self._sleep_until_offset(cycle_start, STAGGER_PROXMENUX)
if not self._running:
return
self._check_proxmenux_updates()
# AI model check at offset 50s
self._sleep_until_offset(cycle_start, STAGGER_AI_MODEL)
if not self._running:
return
self._check_ai_model_availability()
# Check if startup period ended and we have aggregated VMs to report
self._check_startup_aggregation()
except Exception as e:
print(f"[PollingCollector] Error: {e}")
for _ in range(self._poll_interval):
# Sleep remaining time until next cycle
elapsed = time.time() - cycle_start
remaining = max(self._poll_interval - elapsed, 1)
for _ in range(int(remaining)):
if not self._running:
return
time.sleep(1)
@@ -1763,6 +2142,13 @@ class PollingCollector:
if error.get('acknowledged') == 1:
continue
# Startup grace period: ignore transient errors from categories that
# typically need time to stabilize after boot (storage, VMs, network).
# PBS storage, NFS mounts, VMs with qemu-guest-agent need time to connect.
# Uses centralized startup_grace module for consistency.
if startup_grace.should_suppress_category(category):
continue
# On first poll, seed _last_notified for all existing errors so we
# don't re-notify old persistent errors that were already sent before
# a service restart. Only genuinely NEW errors (appearing after the
@@ -1791,15 +2177,28 @@ class PollingCollector:
# Determine if we should notify
is_new = error_key not in self._known_errors
last_sent = self._last_notified.get(error_key, 0)
cat_cooldown = self._CATEGORY_COOLDOWNS.get(category, self.DIGEST_INTERVAL)
is_due = (now - last_sent) >= cat_cooldown
time_since_last = now - last_sent
# ── SAME ERROR COOLDOWN (24h) ──
# The SAME error_key cannot be re-notified before 24 hours.
# This is the PRIMARY deduplication mechanism.
# EXCEPTION: If user dismissed the error, the cooldown is cleared in DB
# and we should re-check DB to see if cooldown still applies.
if time_since_last < self.SAME_ERROR_COOLDOWN:
# Check if user dismissed this - clears DB cooldown
db_ts = self._get_cooldown_from_db(error_key)
if db_ts is not None and now - db_ts < self.SAME_ERROR_COOLDOWN:
continue # DB confirms cooldown still active
# DB says cooldown was cleared (user dismissed) - remove from memory
self._last_notified.pop(error_key, None)
# Continue to the next checks (category cooldown etc.)
# ── CATEGORY COOLDOWN (varies) ──
# DIFFERENT errors within the same category respect category cooldown.
# This prevents notification storms when multiple issues arise together.
cat_cooldown = self._CATEGORY_COOLDOWNS.get(category, self.DIGEST_INTERVAL)
is_due = time_since_last >= cat_cooldown
# Anti-oscillation: even if "new" (resolved then reappeared),
# respect the per-category cooldown interval. This prevents
# "semi-cascades" where the same root cause generates multiple
# slightly different notifications across health check cycles.
# Each category has its own appropriate cooldown (30m for network,
# 24h for disks, 1h for temperature, etc.).
if not is_due:
continue
@@ -2009,6 +2408,113 @@ class PollingCollector:
self._known_errors = current_keys
self._first_poll_done = True
def _check_startup_aggregation(self):
"""Check if startup period ended and emit comprehensive startup report.
At the end of the health grace period, collects:
- VMs/CTs that started successfully
- VMs/CTs that failed to start
- Service status
- Storage status
- Journal errors (for AI enrichment)
Emits a single "system_startup" notification with full report data.
IMPORTANT: Only emits if this is a REAL system boot, not a service restart.
Checks system uptime to distinguish between the two cases.
"""
# Wait until health grace period is over (5 min) for complete picture
if startup_grace.is_startup_health_grace():
return
# Only emit once
if startup_grace.was_startup_aggregated():
return
# CRITICAL: Check if this is a real system boot
# If the system was already running for > 10 min when service started,
# this is just a service restart, not a system boot - skip notification
if not startup_grace.is_real_system_boot():
# Mark as aggregated to prevent future checks, but don't send notification
startup_grace.mark_startup_aggregated()
return
# Collect comprehensive startup report
report = startup_grace.collect_startup_report()
# Generate human-readable summary
summary = startup_grace.format_startup_summary(report)
# Count totals
vms_ok = len(report.get('vms_started', []))
cts_ok = len(report.get('cts_started', []))
vms_fail = len(report.get('vms_failed', []))
cts_fail = len(report.get('cts_failed', []))
total_ok = vms_ok + cts_ok
total_fail = vms_fail + cts_fail
# Build entity list for backwards compatibility
entity_names = []
for vm in report.get('vms_started', [])[:5]:
entity_names.append(f"{vm['name']} ({vm['vmid']})")
for ct in report.get('cts_started', [])[:5]:
entity_names.append(f"{ct['name']} ({ct['vmid']})")
if total_ok > 10:
entity_names.append(f"...and {total_ok - 10} more")
# Determine severity based on issues
has_issues = (
total_fail > 0 or
not report.get('services_ok', True) or
not report.get('storage_ok', True) or
report.get('health_status') in ['CRITICAL', 'WARNING']
)
severity = 'WARNING' if has_issues else 'INFO'
# Build notification data
data = {
'hostname': self._hostname,
'summary': summary,
# VM/CT counts (backwards compatible)
'vm_count': vms_ok,
'ct_count': cts_ok,
'total_count': total_ok,
'entity_list': ', '.join(entity_names),
# New: failure counts
'vms_failed_count': vms_fail,
'cts_failed_count': cts_fail,
'total_failed': total_fail,
# New: detailed lists
'vms_started': report.get('vms_started', []),
'cts_started': report.get('cts_started', []),
'vms_failed': report.get('vms_failed', []),
'cts_failed': report.get('cts_failed', []),
# New: system status
'services_ok': report.get('services_ok', True),
'services_failed': report.get('services_failed', []),
'storage_ok': report.get('storage_ok', True),
'storage_unavailable': report.get('storage_unavailable', []),
'health_status': report.get('health_status', 'UNKNOWN'),
'health_issues': report.get('health_issues', []),
# For AI enrichment
'_journal_context': report.get('_journal_context', ''),
# Metadata
'startup_duration_seconds': report.get('startup_duration_seconds', 0),
'has_issues': has_issues,
'reason': summary.split('\n')[0], # First line as reason
}
self._queue.put(NotificationEvent(
'system_startup', severity, data, source='polling',
entity='node', entity_id='',
))
# ── Update check (enriched) ────────────────────────────────
# Proxmox-related package prefixes used for categorisation
@@ -2102,7 +2608,7 @@ class PollingCollector:
for pkg in all_pkgs:
if pkg['name'] in self._IMPORTANT_PKGS and pkg['cur']:
important_lines.append(
f"{pkg['name']} ({pkg['cur']} -> {pkg['new']})"
f"{pkg['name']} ({pkg['cur']} {pkg['new']})"
)
# ── Emit structured update_summary ─────────────────────
@@ -2128,7 +2634,7 @@ class PollingCollector:
'current_version': pve_manager_info['cur'],
'new_version': pve_manager_info['new'],
'version': pve_manager_info['new'],
'details': f"pve-manager {pve_manager_info['cur']} -> {pve_manager_info['new']}",
'details': f"pve-manager {pve_manager_info['cur']} {pve_manager_info['new']}",
}
self._queue.put(NotificationEvent(
'pve_update', 'INFO', pve_data,
@@ -2137,6 +2643,135 @@ class PollingCollector:
except Exception:
pass
# ── ProxMenux update check ────────────────────────────────
PROXMENUX_VERSION_FILE = '/usr/local/share/proxmenux/version.txt'
PROXMENUX_BETA_VERSION_FILE = '/usr/local/share/proxmenux/beta_version.txt'
REPO_MAIN_VERSION_URL = 'https://raw.githubusercontent.com/MacRimi/ProxMenux/main/version.txt'
REPO_DEVELOP_VERSION_URL = 'https://raw.githubusercontent.com/MacRimi/ProxMenux/develop/beta_version.txt'
def _check_proxmenux_updates(self):
"""Check for ProxMenux updates (main and beta channels).
Compares local version files with remote GitHub repository versions
and emits notifications when updates are available.
Uses same 24h interval as system updates.
"""
import urllib.request
now = time.time()
if now - self._last_proxmenux_check < self.UPDATE_CHECK_INTERVAL:
return
self._last_proxmenux_check = now
def read_local_version(path: str) -> str | None:
"""Read version from local file."""
try:
if os.path.exists(path):
with open(path, 'r') as f:
return f.read().strip()
except Exception:
pass
return None
def read_remote_version(url: str) -> str | None:
"""Fetch version from remote URL."""
try:
req = urllib.request.Request(url, headers={'User-Agent': 'ProxMenux-Monitor/1.0'})
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.read().decode('utf-8').strip()
except Exception:
pass
return None
def version_tuple(v: str) -> tuple:
"""Convert version string to tuple for comparison."""
try:
return tuple(int(x) for x in v.split('.'))
except Exception:
return (0,)
def update_config_json(stable: bool = None, stable_version: str = None,
beta: bool = None, beta_version: str = None):
"""Update update_available status in config.json."""
config_path = Path('/usr/local/share/proxmenux/config.json')
try:
config = {}
if config_path.exists():
with open(config_path, 'r') as f:
config = json.load(f)
if 'update_available' not in config:
config['update_available'] = {
'stable': False, 'stable_version': '',
'beta': False, 'beta_version': ''
}
if stable is not None:
config['update_available']['stable'] = stable
config['update_available']['stable_version'] = stable_version or ''
if beta is not None:
config['update_available']['beta'] = beta
config['update_available']['beta_version'] = beta_version or ''
with open(config_path, 'w') as f:
json.dump(config, f, indent=2)
except Exception as e:
print(f"[PollingCollector] Failed to update config.json: {e}")
try:
# Check main version
local_main = read_local_version(self.PROXMENUX_VERSION_FILE)
if local_main:
remote_main = read_remote_version(self.REPO_MAIN_VERSION_URL)
if remote_main and version_tuple(remote_main) > version_tuple(local_main):
# Update config.json with stable update status
update_config_json(stable=True, stable_version=remote_main)
# Only notify if we haven't already notified for this version
if self._notified_proxmenux_version != remote_main:
self._notified_proxmenux_version = remote_main
data = {
'hostname': self._hostname,
'current_version': local_main,
'new_version': remote_main,
}
self._queue.put(NotificationEvent(
'proxmenux_update', 'INFO', data,
source='polling', entity='node', entity_id='',
))
else:
# No update available - reset the flag
update_config_json(stable=False, stable_version='')
self._notified_proxmenux_version = None
# Check beta version (only if user has beta file)
local_beta = read_local_version(self.PROXMENUX_BETA_VERSION_FILE)
if local_beta:
remote_beta = read_remote_version(self.REPO_DEVELOP_VERSION_URL)
if remote_beta and version_tuple(remote_beta) > version_tuple(local_beta):
# Update config.json with beta update status
update_config_json(beta=True, beta_version=remote_beta)
# Only notify if we haven't already notified for this version
if self._notified_proxmenux_beta_version != remote_beta:
self._notified_proxmenux_beta_version = remote_beta
data = {
'hostname': self._hostname,
'current_version': local_beta,
'new_version': f'{remote_beta} (Beta)',
}
# Use same event_type - single toggle controls both
self._queue.put(NotificationEvent(
'proxmenux_update', 'INFO', data,
source='polling', entity='node', entity_id='',
))
else:
# No beta update available - reset the flag
update_config_json(beta=False, beta_version='')
self._notified_proxmenux_beta_version = None
except Exception:
pass
# ── AI Model availability check ────────────────────────────
def _check_ai_model_availability(self):
@@ -2221,6 +2856,41 @@ class PollingCollector:
conn.close()
except Exception:
pass
def _get_cooldown_from_db(self, error_key: str) -> Optional[float]:
"""
Get cooldown timestamp from DB for an error_key.
Used to re-check DB when user might have dismissed the error,
which clears the DB entry via health_persistence._clear_disk_io_cooldown().
Returns the timestamp if found and within 24h window, None otherwise.
"""
try:
db_path = Path('/usr/local/share/proxmenux/health_monitor.db')
if not db_path.exists():
return None
conn = sqlite3.connect(str(db_path), timeout=5)
conn.execute('PRAGMA busy_timeout=3000')
cursor = conn.cursor()
# PollingCollector uses 'health_' prefix for its fingerprints
fp = f'health_{error_key}'
cursor.execute(
"SELECT last_sent_ts FROM notification_last_sent WHERE fingerprint = ?",
(fp,)
)
row = cursor.fetchone()
conn.close()
if row:
ts = float(row[0])
# Only return if within 24h window
if time.time() - ts < self.SAME_ERROR_COOLDOWN:
return ts
return None
except Exception:
return None
# ─── Proxmox Webhook Receiver ───────────────────────────────────
+84 -7
View File
@@ -44,6 +44,13 @@ from notification_events import (
ProxmoxHookWatcher,
)
# AI context enrichment (uptime, frequency, SMART data, known errors)
try:
from ai_context_enrichment import enrich_context_for_ai
except ImportError:
def enrich_context_for_ai(title, body, event_type, data, journal_context='', detail_level='standard'):
return journal_context
# ─── Constants ────────────────────────────────────────────────────
@@ -653,9 +660,10 @@ class NotificationManager:
# Suppress VM/CT start/stop during active backups (second layer of defense).
# The primary filter is in TaskWatcher, but timing gaps can let events
# slip through. This catch-all filter checks at dispatch time.
# Exception: CRITICAL and WARNING events should always be notified.
_BACKUP_NOISE_TYPES = {'vm_start', 'vm_stop', 'vm_shutdown', 'vm_restart',
'ct_start', 'ct_stop', 'ct_shutdown', 'ct_restart'}
if event.event_type in _BACKUP_NOISE_TYPES and event.severity != 'CRITICAL':
if event.event_type in _BACKUP_NOISE_TYPES and event.severity not in ('CRITICAL', 'WARNING'):
if self._is_backup_running():
return
@@ -739,10 +747,12 @@ class NotificationManager:
'ai_model': self._config.get('ai_model', ''),
'ai_language': self._config.get('ai_language', 'en'),
'ai_ollama_url': self._config.get('ai_ollama_url', ''),
'ai_prompt_mode': self._config.get('ai_prompt_mode', 'default'),
'ai_custom_prompt': self._config.get('ai_custom_prompt', ''),
}
# Get journal context if available
journal_context = data.get('_journal_context', '')
# Get journal context if available (will be enriched per-channel based on detail_level)
raw_journal_context = data.get('_journal_context', '')
for ch_name, channel in channels.items():
# ── Per-channel category check ──
@@ -761,9 +771,12 @@ class NotificationManager:
ch_title, ch_body = title, body
# ── Per-channel settings ──
# Email defaults to 'detailed' (technical report), others to 'standard'
detail_level_key = f'{ch_name}.ai_detail_level'
detail_level = self._config.get(detail_level_key, 'standard')
default_detail = 'detailed' if ch_name == 'email' else 'standard'
detail_level = self._config.get(detail_level_key, default_detail)
# Rich format (emojis) is a user preference per channel
rich_key = f'{ch_name}.rich_format'
use_rich_format = self._config.get(rich_key, 'false') == 'true'
@@ -772,10 +785,21 @@ class NotificationManager:
# If AI is enabled AND rich_format is on, AI will include emojis directly
# Pass channel_type so AI knows whether to append original (email only)
channel_ai_config = {**ai_config, 'channel_type': ch_name}
# Enrich context with uptime, frequency, SMART data, and known errors
enriched_context = enrich_context_for_ai(
title=ch_title,
body=ch_body,
event_type=event_type,
data=data,
journal_context=raw_journal_context,
detail_level=detail_level
)
ai_result = format_with_ai_full(
ch_title, ch_body, severity, channel_ai_config,
detail_level=detail_level,
journal_context=journal_context,
journal_context=enriched_context,
use_emojis=use_rich_format
)
ch_title = ai_result.get('title', ch_title)
@@ -1013,6 +1037,45 @@ class NotificationManager:
# ─── Public API (used by Flask routes and CLI) ──────────────
def emit_event(self, event_type: str, severity: str, data: Dict,
source: str = 'api', entity: str = 'node', entity_id: str = '') -> Dict[str, Any]:
"""Emit an event through the notification system.
This creates a NotificationEvent and processes it through the normal pipeline,
including toggle checks, template rendering, and cooldown.
Used by internal endpoints like the shutdown notification hook.
Args:
event_type: Type of event (must match TEMPLATES key)
severity: INFO, WARNING, CRITICAL
data: Event data for template rendering
source: Origin of event
entity: Entity type (node, vm, ct, storage, etc.)
entity_id: Entity identifier
"""
from notification_events import NotificationEvent
event = NotificationEvent(
event_type=event_type,
severity=severity,
data=data,
source=source,
entity=entity,
entity_id=entity_id,
)
# For urgent events (shutdown/reboot), dispatch directly to ensure
# immediate delivery before the system goes down.
# For other events, use the normal pipeline with aggregation.
_URGENT_EVENTS = {'system_shutdown', 'system_reboot'}
if event_type in _URGENT_EVENTS:
self._dispatch_event(event)
return {'success': True, 'event_type': event_type, 'dispatched': 'direct'}
else:
self._process_event(event)
return {'success': True, 'event_type': event_type, 'dispatched': 'queued'}
def send_notification(self, event_type: str, severity: str,
title: str, message: str,
data: Optional[Dict] = None,
@@ -1070,6 +1133,8 @@ class NotificationManager:
'ai_model': self._config.get('ai_model', ''),
'ai_language': self._config.get('ai_language', 'en'),
'ai_ollama_url': self._config.get('ai_ollama_url', ''),
'ai_prompt_mode': self._config.get('ai_prompt_mode', 'default'),
'ai_custom_prompt': self._config.get('ai_custom_prompt', ''),
}
results = {}
@@ -1166,12 +1231,21 @@ class NotificationManager:
'ai_model': self._config.get('ai_model', ''),
'ai_language': self._config.get('ai_language', 'en'),
'ai_ollama_url': self._config.get('ai_ollama_url', ''),
'ai_prompt_mode': self._config.get('ai_prompt_mode', 'default'),
'ai_custom_prompt': self._config.get('ai_custom_prompt', ''),
}
ai_enabled = self._config.get('ai_enabled', 'false')
if isinstance(ai_enabled, str):
ai_enabled = ai_enabled.lower() == 'true'
ai_language = self._config.get('ai_language', 'en')
ai_prompt_mode = self._config.get('ai_prompt_mode', 'default')
# Determine AI info string based on prompt mode
if ai_prompt_mode == 'custom':
ai_info = f'{ai_provider} / custom prompt'
else:
ai_info = f'{ai_provider} / {ai_language}'
# ProxMenux logo for welcome message
logo_url = 'https://proxmenux.com/telegram.png'
@@ -1189,10 +1263,10 @@ class NotificationManager:
# Build status indicators for icons and AI, adapted to channel format
if use_rich_format:
icon_status = '✅ Icons: enabled'
ai_status = f'✅ AI: enabled ({ai_provider} / {ai_language})' if ai_enabled else '❌ AI: disabled'
ai_status = f'✅ AI: enabled ({ai_info})' if ai_enabled else '❌ AI: disabled'
else:
icon_status = 'Icons: disabled'
ai_status = f'AI: enabled ({ai_provider} / {ai_language})' if ai_enabled else 'AI: disabled'
ai_status = f'AI: enabled ({ai_info})' if ai_enabled else 'AI: disabled'
# Base test message — shows current channel config
# NOTE: narrative lines are intentionally unlabeled so the AI
@@ -1559,6 +1633,9 @@ class NotificationManager:
'ai_language': self._config.get('ai_language', 'en'),
'ai_ollama_url': self._config.get('ai_ollama_url', 'http://localhost:11434'),
'ai_openai_base_url': self._config.get('ai_openai_base_url', ''),
'ai_prompt_mode': self._config.get('ai_prompt_mode', 'default'),
'ai_custom_prompt': self._config.get('ai_custom_prompt', ''),
'ai_allow_suggestions': self._config.get('ai_allow_suggestions', 'false') == 'true',
'ai_detail_levels': ai_detail_levels,
'hostname': self._config.get('hostname', ''),
'webhook_secret': self._config.get('webhook_secret', ''),
+486 -310
View File
@@ -17,7 +17,7 @@ import socket
import time
import urllib.request
import urllib.error
from typing import Dict, Any, Optional, List
from typing import Dict, Any, Optional, List, Tuple
# ─── vzdump message parser ───────────────────────────────────────
@@ -314,6 +314,90 @@ def _format_vzdump_body(parsed: Dict[str, Any], is_success: bool) -> str:
return '\n'.join(parts)
def _format_system_startup(data: Dict[str, Any]) -> Tuple[str, str]:
"""
Format comprehensive system startup report.
Returns (title, body) tuple for the notification.
Handles both simple startups (all OK) and those with issues.
"""
hostname = data.get('hostname', 'unknown')
has_issues = data.get('has_issues', False)
# Build title
if has_issues:
total_issues = (
data.get('total_failed', 0) +
len(data.get('services_failed', [])) +
len(data.get('storage_unavailable', []))
)
title = f"{hostname}: System startup - {total_issues} issue(s) detected"
else:
title = f"{hostname}: System startup completed"
# Build body
parts = []
# Overall status
if not has_issues:
parts.append("All systems operational.")
# VMs/CTs started
vms_ok = len(data.get('vms_started', []))
cts_ok = len(data.get('cts_started', []))
if vms_ok or cts_ok:
count_parts = []
if vms_ok:
count_parts.append(f"{vms_ok} VM{'s' if vms_ok > 1 else ''}")
if cts_ok:
count_parts.append(f"{cts_ok} CT{'s' if cts_ok > 1 else ''}")
# List names (up to 5)
names = []
for vm in data.get('vms_started', [])[:3]:
names.append(f"{vm['name']} ({vm['vmid']})")
for ct in data.get('cts_started', [])[:3]:
names.append(f"{ct['name']} ({ct['vmid']})")
line = f"\u2705 {' and '.join(count_parts)} started"
if names:
if len(names) <= 5:
line += f": {', '.join(names)}"
else:
line += f": {', '.join(names[:5])}..."
parts.append(line)
# Failed VMs/CTs
for vm in data.get('vms_failed', []):
reason = vm.get('reason', 'unknown error')
parts.append(f"\u274C VM failed: {vm['name']} - {reason}")
for ct in data.get('cts_failed', []):
reason = ct.get('reason', 'unknown error')
parts.append(f"\u274C CT failed: {ct['name']} - {reason}")
# Storage issues
storage_unavailable = data.get('storage_unavailable', [])
if storage_unavailable:
names = [s['name'] for s in storage_unavailable[:3]]
parts.append(f"\u26A0\uFE0F Storage: {len(storage_unavailable)} unavailable ({', '.join(names)})")
# Service issues
services_failed = data.get('services_failed', [])
if services_failed:
names = [s['name'] for s in services_failed[:3]]
parts.append(f"\u26A0\uFE0F Services: {len(services_failed)} failed ({', '.join(names)})")
# Startup duration
duration = data.get('startup_duration_seconds', 0)
if duration:
minutes = int(duration // 60)
parts.append(f"\u23F1\uFE0F Startup completed in {minutes} min")
body = '\n'.join(parts)
return title, body
# ─── Severity Icons ──────────────────────────────────────────────
SEVERITY_ICONS = {
@@ -387,6 +471,13 @@ TEMPLATES = {
'group': 'vm_ct',
'default_enabled': True,
},
'vm_start_warning': {
'title': '{hostname}: VM {vmname} ({vmid}) started with warnings',
'body': 'Virtual machine {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}',
'label': 'VM started (warnings)',
'group': 'vm_ct',
'default_enabled': True,
},
'vm_stop': {
'title': '{hostname}: VM {vmname} ({vmid}) stopped',
'body': 'Virtual machine {vmname} (ID: {vmid}) has been stopped.',
@@ -422,6 +513,13 @@ TEMPLATES = {
'group': 'vm_ct',
'default_enabled': True,
},
'ct_start_warning': {
'title': '{hostname}: CT {vmname} ({vmid}) started with warnings',
'body': 'Container {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}',
'label': 'CT started (warnings)',
'group': 'vm_ct',
'default_enabled': True,
},
'ct_stop': {
'title': '{hostname}: CT {vmname} ({vmid}) stopped',
'body': 'Container {vmname} (ID: {vmid}) has been stopped.',
@@ -464,6 +562,13 @@ TEMPLATES = {
'group': 'vm_ct',
'default_enabled': True,
},
'migration_warning': {
'title': '{hostname}: Migration complete with warnings — {vmname} ({vmid})',
'body': '{vmname} (ID: {vmid}) migrated to node {target_node} but encountered warnings.\nWarnings: {reason}',
'label': 'Migration (warnings)',
'group': 'vm_ct',
'default_enabled': True,
},
'migration_fail': {
'title': '{hostname}: Migration FAILED — {vmname} ({vmid})',
'body': 'Migration of {vmname} (ID: {vmid}) to node {target_node} failed.\nReason: {reason}',
@@ -501,6 +606,13 @@ TEMPLATES = {
'group': 'backup',
'default_enabled': True,
},
'backup_warning': {
'title': '{hostname}: Backup complete with warnings — {vmname} ({vmid})',
'body': 'Backup of {vmname} (ID: {vmid}) completed but encountered warnings.\nWarnings: {reason}',
'label': 'Backup (warnings)',
'group': 'backup',
'default_enabled': True,
},
'backup_fail': {
'title': '{hostname}: Backup FAILED — {vmname} ({vmid})',
'body': 'Backup of {vmname} (ID: {vmid}) failed.\nReason: {reason}',
@@ -566,6 +678,59 @@ TEMPLATES = {
'group': 'storage',
'default_enabled': True,
},
'smart_test_complete': {
'title': '{hostname}: SMART test completed — {device}',
'body': 'SMART {test_type} test on /dev/{device} has completed.\nResult: {result}\nDuration: {duration}',
'label': 'SMART test completed',
'group': 'storage',
'default_enabled': True,
},
'smart_test_failed': {
'title': '{hostname}: SMART test FAILED — {device}',
'body': 'SMART {test_type} test on /dev/{device} has failed.\nResult: {result}\nReason: {reason}',
'label': 'SMART test FAILED',
'group': 'storage',
'default_enabled': True,
},
# ── GPU / PCIe passthrough events ──
'gpu_mode_switch': {
'title': '{hostname}: GPU mode changed to {new_mode}',
'body': (
'GPU passthrough mode has been switched.\n'
'GPU: {gpu_name} ({gpu_pci})\n'
'Previous mode: {old_mode}\n'
'New mode: {new_mode}\n'
'{details}'
),
'label': 'GPU mode switched',
'group': 'hardware',
'default_enabled': True,
},
'gpu_passthrough_blocked': {
'title': '{hostname}: {guest_type} {guest_id} blocked at startup',
'body': (
'PCIe passthrough guard prevented {guest_type} {guest_id} ({guest_name}) from starting.\n'
'Reason: {reason}\n'
'{details}'
),
'label': 'GPU passthrough blocked',
'group': 'hardware',
'default_enabled': True,
},
'pci_passthrough_conflict': {
'title': '{hostname}: PCIe device conflict detected',
'body': (
'A PCIe device is assigned to multiple guests.\n'
'Device: {device_pci}\n'
'Conflicting guests: {guest_list}\n'
'Action required: Stop one of the guests or reassign the device.'
),
'label': 'PCIe device conflict',
'group': 'hardware',
'default_enabled': True,
},
'load_high': {
'title': '{hostname}: High system load — {value}',
'body': 'System load average is {value} on {cores} cores.\n{details}',
@@ -644,6 +809,14 @@ TEMPLATES = {
},
# ── Services events ──
'system_startup': {
'title': '{hostname}: {reason}',
'body': '{summary}',
'label': 'System startup report',
'group': 'services',
'default_enabled': True,
'formatter': '_format_system_startup',
},
'system_shutdown': {
'title': '{hostname}: System shutting down',
'body': 'The node is shutting down.\n{reason}',
@@ -787,6 +960,20 @@ TEMPLATES = {
),
'label': 'AI model auto-updated',
'group': 'system',
'severity': 'info',
'default_enabled': True,
},
# ── ProxMenux updates ──
'proxmenux_update': {
'title': '{hostname}: ProxMenux {new_version} available',
'body': (
'A new version of ProxMenux is available.\n'
'Current: {current_version}\n'
'New: {new_version}'
),
'label': 'ProxMenux update available',
'group': 'updates',
'default_enabled': True,
},
@@ -938,7 +1125,19 @@ def render_template(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
pve_message = data.get('pve_message', '')
pve_title = data.get('pve_title', '')
if event_type in ('backup_complete', 'backup_fail') and pve_message:
# Check for custom formatter function
formatter_name = template.get('formatter')
if formatter_name and formatter_name in globals():
formatter_func = globals()[formatter_name]
try:
title, body_text = formatter_func(data)
except Exception:
# Fallback to standard formatting if formatter fails
try:
body_text = template['body'].format(**variables)
except (KeyError, ValueError):
body_text = template['body']
elif event_type in ('backup_complete', 'backup_fail') and pve_message:
parsed = _parse_vzdump_message(pve_message)
if parsed:
is_success = (event_type == 'backup_complete')
@@ -1057,6 +1256,7 @@ CATEGORY_EMOJI = {
'services': '\u2699\uFE0F', # gear
'health': '\U0001FA7A', # stethoscope
'updates': '\U0001F504', # counterclockwise arrows (update)
'hardware': '\U0001F3AE', # video game controller (GPU/PCIe hardware)
'other': '\U0001F4E8', # incoming envelope
}
@@ -1064,23 +1264,27 @@ CATEGORY_EMOJI = {
EVENT_EMOJI = {
# VM / CT
'vm_start': '\u25B6\uFE0F', # play button
'vm_start_warning': '\u26A0\uFE0F', # warning sign - started with warnings
'vm_stop': '\u23F9\uFE0F', # stop button
'vm_shutdown': '\u23CF\uFE0F', # eject
'vm_fail': '\U0001F4A5', # collision (crash)
'vm_restart': '\U0001F504', # cycle
'ct_start': '\u25B6\uFE0F',
'ct_start_warning': '\u26A0\uFE0F', # warning sign - started with warnings
'ct_stop': '\u23F9\uFE0F',
'ct_shutdown': '\u23CF\uFE0F',
'ct_restart': '\U0001F504',
'ct_fail': '\U0001F4A5',
'migration_start': '\U0001F69A', # moving truck
'migration_complete': '\u2705', # check mark
'migration_warning': '\U0001F69A\u26A0\uFE0F', # 🚚⚠️ truck + warning
'migration_fail': '\u274C', # cross mark
'replication_fail': '\u274C',
'replication_complete': '\u2705',
# Backups
'backup_start': '\U0001F4BE\U0001F680', # 💾🚀 floppy + rocket
'backup_complete': '\U0001F4BE\u2705', # 💾✅ floppy + check
'backup_warning': '\U0001F4BE\u26A0\uFE0F', # 💾⚠️ floppy + warning
'backup_fail': '\U0001F4BE\u274C', # 💾❌ floppy + cross
'snapshot_complete': '\U0001F4F8', # camera with flash
'snapshot_fail': '\u274C',
@@ -1106,6 +1310,7 @@ EVENT_EMOJI = {
'node_disconnect': '\U0001F50C',
'node_reconnect': '\u2705',
# Services
'system_startup': '\U0001F680', # rocket (startup)
'system_shutdown': '\u23FB\uFE0F', # power symbol (Unicode)
'system_reboot': '\U0001F504',
'system_problem': '\u26A0\uFE0F',
@@ -1121,8 +1326,13 @@ EVENT_EMOJI = {
'update_summary': '\U0001F4E6',
'pve_update': '\U0001F195', # NEW
'update_complete': '\u2705',
'proxmenux_update': '\U0001F195', # NEW
# AI
'ai_model_migrated': '\U0001F916', # robot
'ai_model_migrated': '\U0001F504', # arrows counterclockwise (refresh/update)
# GPU / PCIe
'gpu_mode_switch': '\U0001F3AE', # video game controller (represents GPU)
'gpu_passthrough_blocked': '\U0001F6AB', # prohibited sign (blocked)
'pci_passthrough_conflict': '\u26A0\uFE0F', # warning triangle (conflict)
}
# Decorative field-level icons for body text enrichment
@@ -1164,6 +1374,8 @@ def enrich_with_emojis(event_type: str, title: str, body: str,
The function is idempotent: if the title already starts with an emoji,
it is returned unchanged.
"""
import re
# Pick the best title icon: event-specific > category > severity circle
template = TEMPLATES.get(event_type, {})
group = template.get('group', 'other')
@@ -1184,8 +1396,55 @@ def enrich_with_emojis(event_type: str, title: str, body: str,
enriched_title = title.replace(sev_icon, icon, 1)
break
# ── Preprocess body: add line breaks before known patterns ──
# This helps when everything comes concatenated
preprocessed = body
# First, clean up duplicated device references like "/dev/sda: /dev/sda: /dev/sda [SAT]"
# Convert to just "/dev/sda [SAT]" or "/dev/sda:"
preprocessed = re.sub(r'(/dev/\w+):\s*\1:\s*\1', r'\1', preprocessed)
preprocessed = re.sub(r'(/dev/\w+):\s*\1', r'\1', preprocessed)
# Patterns that should start on a new line
line_break_patterns = [
(r';\s*/dev/', '\n/dev/'), # ;/dev/sdb -> newline + /dev/sdb
(r'(?<=[a-z])\s+/dev/', '\n/dev/'), # "sectors /dev/sdb" -> newline before /dev/
(r'(?<=\))\s*/dev/', '\n/dev/'), # ") /dev/sdb" -> newline before /dev/
(r'\bDevice:', '\nDevice:'), # Device: on new line
(r'\bError:', '\nError:'), # Error: on new line
(r'\bAction:', '\nAction:'), # Action: on new line
(r'\bAffected:', '\nAffected:'), # Affected: on new line
(r'Device not currently', '\nDevice not currently'), # Note about missing device
(r'\bSMART:', '\nSMART:'), # SMART status
]
for pattern, replacement in line_break_patterns:
preprocessed = re.sub(pattern, replacement, preprocessed)
# Clean up multiple newlines and leading newlines
preprocessed = re.sub(r'\n{3,}', '\n\n', preprocessed)
preprocessed = re.sub(r'^\n+', '', preprocessed)
preprocessed = preprocessed.strip()
# ── Extended emoji mappings for health/disk messages ──
HEALTH_EMOJI_MAP = {
# Disk patterns
'/dev/': '\U0001F4BF', # DVD disk
'Device:': '\U0001F4BF', # DVD disk
'Error:': '\u274C', # Red X
'Action:': '\U0001F4A1', # Light bulb (tip)
'Affected:': '\U0001F3AF', # Target
'SMART:': '\U0001F4CA', # Chart
'Device not currently': '\U0001F4CC', # Pushpin (note)
# Status patterns
'unreadable': '\u26A0\uFE0F', # Warning
'pending': '\u26A0\uFE0F', # Warning
'FAILED': '\u274C', # Red X
'PASSED': '\u2705', # Green check
}
# Build enriched body: prepend field emojis to recognizable lines
lines = body.split('\n')
lines = preprocessed.split('\n')
enriched_lines = []
for line in lines:
@@ -1194,6 +1453,21 @@ def enrich_with_emojis(event_type: str, title: str, body: str,
enriched_lines.append(line)
continue
# First, check health-specific patterns
health_enriched = False
for pattern, emoji in HEALTH_EMOJI_MAP.items():
if stripped.startswith(pattern):
# Don't double-add emoji if already present
if not stripped.startswith(emoji):
enriched_lines.append(f'{emoji} {stripped}')
else:
enriched_lines.append(stripped)
health_enriched = True
break
if health_enriched:
continue
# Try to match "FieldName: value" patterns
enriched = False
for field_key, field_icon in FIELD_EMOJI.items():
@@ -1255,341 +1529,230 @@ AI_LANGUAGES = {
# Token limits for different detail levels
# max_tokens is a LIMIT, not fixed consumption - you only pay for tokens actually generated
# Note: Some providers (especially Gemini) may have lower default limits, so we use generous values
AI_DETAIL_TOKENS = {
'brief': 300, # Short messages, 2-3 lines
'standard': 1000, # Standard messages, sufficient for 15-20 VMs
'detailed': 2000, # Complete technical reports with all details
'brief': 500, # Short messages, 2-3 lines
'standard': 1500, # Standard messages, sufficient for 15-20 VMs
'detailed': 3000, # Complete technical reports with all details
}
# System prompt template - informative, no recommendations
AI_SYSTEM_PROMPT = """You are a system notification formatter for ProxMenux Monitor, a Proxmox VE monitoring tool.
# System prompt template - optimized hybrid version
AI_SYSTEM_PROMPT = """You are a notification FORMATTER for ProxMenux Monitor (Proxmox VE).
Your job: translate alerts into {language} and enrich them with context when provided.
Your task is to translate and reformat incoming server alert messages into {language}.
ABSOLUTE CONSTRAINTS (NO EXCEPTIONS)
- NO HALLUCINATIONS: Do not invent causes, solutions, or facts not present in the provided data
- NO SPECULATION: If something is unclear, state what IS known, not what MIGHT be
- NO CONVERSATIONAL TEXT: Never write "Here is...", "I've translated...", "Let me explain..."
- ONLY use information from: the message, journal context, and known error database (if provided)
ABSOLUTE RULES
1. Translate BOTH title and body to {language}. Every word, label, and unit must be in {language}.
2. NO markdown: no **bold**, no *italic*, no `code`, no headers (#), no bullet lists (- or *)
3. Plain text only the output is sent to chat apps and email which handle their own formatting
4. Tone: factual, concise, technical. No greetings, no closings, no apologies
5. DO NOT add recommendations, action items, or suggestions ("you should…", "consider…")
6. Present ONLY the facts already in the input do not invent or assume information
7. OUTPUT ONLY THE FINAL RESULT never include both original and processed versions.
Do NOT append "Original message:", "Original:", "Source:", or any before/after comparison.
Return ONLY the single, final formatted message in {language}.
8. PLAIN NARRATIVE LINES if a line in the input is a complete sentence (not a "Label: value"
pair), translate it as-is. Never prepend "Message:", "Note:", or any other label to a sentence.
9. Detail level to apply: {detail_level}
- brief 2-3 lines, essential data only (status + key metric)
- standard short paragraph covering who/what/where and the key value
- detailed full technical breakdown of all available fields
10. Keep the "hostname: " prefix in the title. Translate only the descriptive part.
Example: "pve01: Updates available" "pve01: Actualizaciones disponibles"
11. EMPTY LIST VALUES if a list field is empty, "none", or "0":
Always write the translated word for "none" on the line after the label, never leave it blank.
Example: 🗂 Important packages:\\n none
Example (Spanish): 🗂 Paquetes importantes:\\n ninguno
Example (Français): 🗂 Paquets importants:\\n aucun
12. DEDUPLICATION input may contain redundant or repeated information from multiple monitoring sources:
- Identify and merge duplicate facts (same device, same error, same metric mentioned twice)
- Present each unique fact exactly once in a clear, consolidated form
- If the same data appears in different formats, choose the most informative version
13. PROXMOX CONTEXT silently translate Proxmox technical references into plain language.
Never explain what the term means just use the human-readable equivalent directly.
WHAT TO TRANSLATE
Translate: labels, descriptions, status words, units (GBGo in French, etc.)
DO NOT translate: hostnames, IPs, paths, VM/CT IDs, device names (/dev/sdX), technical identifiers
Service / process name mapping (replace the raw name with the friendly form):
- "pve-container@XXXX.service" "Container CT XXXX"
- "qemu-server@XXXX.service" "Virtual Machine VM XXXX"
- "pvesr-XXXX" "storage replication job for XXXX"
- "vzdump" "backup process"
- "pveproxy" "Proxmox web proxy"
- "pvedaemon" "Proxmox daemon"
- "pvestatd" "Proxmox statistics service"
- "pvescheduler" "Proxmox task scheduler"
- "pve-cluster" "Proxmox cluster service"
- "corosync" "cluster communication service"
- "ceph-osd@N" "Ceph storage disk N"
- "ceph-mon" "Ceph monitor service"
CORE RULES
1. Plain text only NO markdown, no **bold**, no `code`, no bullet lists (use "" for packages only)
2. Preserve severity: "failed" stays "failed", "warning" stays "warning" never soften errors
3. Preserve structure: keep same fields and line order, only translate content
4. Detail level "{detail_level}" - controls AMOUNT OF EVENT INFO (not tips/suggestions):
- brief: 1-2 lines max. Only: what happened + where
- standard: 3-6 lines. Include: what, where, cause, affected devices
- detailed: Full report with ALL info: what, where, cause, affected, logs, SMART data, history
5. DEDUPLICATION: merge duplicate facts from multiple sources into one clear statement
6. EMPTY LISTS: write translated "none" after label, never leave blank
7. Keep "hostname:" prefix in title translate only the descriptive part
8. DO NOT add recommendations or suggestions UNLESS AI Suggestions mode is enabled below
9. ENRICHED CONTEXT: You may receive additional context data including:
- "System uptime: X days (stable system)" helps distinguish startup issues from runtime failures
- "Event frequency: N occurrences, first seen X ago" indicates recurring vs one-time issues
- "SMART Health: PASSED/FAILED" with disk attributes critical for disk errors
- "KNOWN PROXMOX ERROR DETECTED" with cause/solution YOU MUST USE this exact information
How to use enriched context:
- If uptime is <10min and error is service-related mention "occurred shortly after boot"
- If frequency shows recurring pattern mention "recurring issue (N times in X hours)"
- If SMART shows FAILED treat as CRITICAL: "Disk failing - immediate attention required"
- If KNOWN ERROR is provided YOU MUST incorporate its Cause and Solution (translate, don't copy verbatim)
systemd message patterns (rewrite the whole phrase, not just the service name):
- "systemd[1]: pve-container@9000.service: Failed"
"Container CT 9000 service failed"
- "systemd[1]: qemu-server@100.service: Failed with result 'exit-code'"
"Virtual Machine VM 100 failed to start"
- "systemd[1]: Started pve-container@9000.service"
"Container CT 9000 started"
ATA / SMART / kernel error patterns (replace raw kernel log with plain description):
- "ata8.00: exception Emask 0x1 SAct 0x4ce0 SErr 0x40000 action 0x0"
"ATA controller error on port 8"
- "blk_update_request: I/O error, dev sdX, sector NNNN"
"I/O error on disk /dev/sdX at sector NNNN"
- "SCSI error: return code = 0x08000002"
"SCSI communication error"
Apply these mappings everywhere: in the body narrative, in field values, and when
the raw technical string appears inside a longer sentence.
10. JOURNAL CONTEXT EXTRACTION: When journal logs are provided:
- Extract specific IDs (VM/CT numbers, disk devices, service names)
- Include relevant timestamps if they help explain the timeline
- Identify root cause when logs clearly show it (e.g., "exit-code 255" -> "process crashed")
- Translate technical terms: "Emask 0x10" -> "ATA bus error", "DRDY ERR" -> "drive not ready"
- If logs show the same error repeating, state frequency: "occurred 15 times in 10 minutes"
- IGNORE journal entries unrelated to the main event
11. OUTPUT ONLY the final result no "Original:", no before/after comparisons
12. Unknown input: preserve as closely as possible, translate what you can
13. REDUNDANCY: Never repeat the same information twice. If title says "CT 103 failed", body should not start with "Container 103 failed"
{suggestions_addon}
PROXMOX MAPPINGS (use directly, never explain)
pve-container@XXXX "CT XXXX" | qemu-server@XXXX "VM XXXX" | vzdump "backup"
pveproxy/pvedaemon/pvestatd "Proxmox service" | corosync "cluster service"
"ata8.00: exception Emask..." "ATA error on port 8"
"blk_update_request: I/O error, dev sdX" "I/O error on /dev/sdX"
{emoji_instructions}
MESSAGE FORMATS
MESSAGE TYPES FORMAT RULES
BACKUP: List each VM/CT with status/size/duration/storage. End with summary.
- Partial failure (some OK, some failed) = "Backup partially failed", not "failed"
- NEVER collapse multi-VM backup into one line show each VM separately
- ALWAYS include storage path and summary line
BACKUP (backup_complete / backup_fail / backup_start):
Input contains: VM/CT names, IDs, size, duration, storage location, status per VM
Output body: first line is plain text (no emoji) describing the event briefly.
Then list each VM/CT with its fields. End with a summary line.
PARTIAL FAILURE RULE: if some VMs succeeded and at least one failed, use a combined title
like "Backup partially failed" / "Copia de seguridad parcialmente fallida" never say
"backup failed" when there are also successful VMs in the same job.
NEVER omit the storage/archive line or the summary line always include them even for long jobs.
UPDATES: Counts on own lines. Packages use "" under header. No redundant summary.
UPDATES (update_summary):
- Each count on its own line with its label.
- Package list uses "" (bullet + space) per package, NOT the 🗂 emoji on each line.
- The 🗂 emoji goes only on the "Important packages:" header line.
- NEVER add a redundant summary line repeating the total count.
PVE UPDATE (pve_update):
- First line: plain sentence announcing the new version (no emoji on this line).
- Blank line after intro.
- Current version: 🔹 prefix | New version: 🟢 prefix
- Blank line before packages block.
- Packages header: 🗂 | Package lines: 📌 prefix with version arrow v{{old}} v{{new}}
DISK/SMART: Device + specific error. Deduplicate repeated info.
DISK / SMART ERRORS (disk_io_error / storage_unavailable):
Input contains: device name, error type, SMART values or I/O error codes
Output body: device, then the specific error or failing attribute
DEDUPLICATION: Input may contain repeated or similar information from multiple sources.
If you see the same device, error count, or technical details mentioned multiple times,
consolidate them into a single, clear statement. Never repeat the same information twice.
HEALTH: Category + severity + what changed. Duration if resolved.
RESOURCES (cpu_high / ram_high / temp_high / load_high):
Input contains: current value, threshold, core count
Output: current value vs threshold, context if available
VM/CT LIFECYCLE: Confirm event with key facts (1-2 lines).
SECURITY (auth_fail / ip_block):
Input contains: source IP, user, service, jail, failure count
Output: list each field on its own line
OUTPUT FORMAT (CRITICAL - MUST FOLLOW EXACTLY)
VM/CT LIFECYCLE (vm_start, vm_stop, vm_fail, ct_*, migration_*, replication_*):
Input contains: VM name, ID, target node (migrations), reason (failures)
Output: one or two lines confirming the event with key facts
CLUSTER (split_brain / node_disconnect / node_reconnect):
Input: node name, quorum status
Output: state change + quorum value
HEALTH (new_error / error_resolved / health_persistent / health_degraded):
Input: category, severity, duration, reason
Output: what changed, in which category, for how long (if resolved)
OUTPUT FORMAT (follow exactly parsers rely on these markers)
Your response MUST have EXACTLY this structure:
[TITLE]
translated title here
your translated title text
[BODY]
translated body here
your translated body text
CRITICAL:
- [TITLE] on its own line, title text on the very next line no blank line between them
- [BODY] on its own line, body text starting on the very next line no blank line between them
- Do NOT write "Title:", "Body:", or any label substituting the markers
- Do NOT include the literal words TITLE or BODY anywhere in the translated content"""
ABSOLUTE RULES (violations break the parser):
1. [TITLE] and [BODY] are INVISIBLE PARSING MARKERS they separate title from body
2. Your actual title/body content must NEVER contain the words "[TITLE]" or "[BODY]"
3. Your actual title/body content must NEVER contain "Title:" or "Body:" prefixes
4. Line 1: write exactly [TITLE]
5. Line 2: write your title text (emoji + hostname: description)
6. Line 3: write exactly [BODY]
7. Line 4+: write your body text
WRONG (markers appear in content):
[TITLE]
🔵 server: [TITLE] Updates available
[BODY]
[BODY] 153 updates available
CORRECT (markers are separators only):
[TITLE]
🔵 server: Updates available
[BODY]
153 updates available
- Output ONLY the formatted result no explanations, no "Original:", no commentary"""
# Addon for experimental suggestions mode
AI_SUGGESTIONS_ADDON = """
AI SUGGESTIONS MODE (ENABLED)
You MAY add ONE brief, actionable tip at the END of the body using this exact format:
💡 Tip: [your concise suggestion here]
Rules for the tip:
- ONLY include if the log context or Known Error database clearly points to a specific fix
- Keep under 100 characters
- Be specific: "Run 'pvecm status' to check quorum" NOT "Check cluster status"
- If Known Error provides a solution, YOU MUST USE IT (don't invent your own)
- Never guess skip the tip if the cause/solution is unclear
"""
# Emoji instructions injected into AI_SYSTEM_PROMPT for rich channels (Telegram, Discord, Pushover)
AI_EMOJI_INSTRUCTIONS = """
EMOJI RULES
Place ONE emoji at the START of every non-empty line (title and each body line).
Never skip a line. Never put the emoji at the end.
A blank line must be completely empty no emoji, no spaces.
TITLE emoji one per event type:
success / resolved / complete / reconnected
failed / FAILED / error
💥 crash / I/O error / hardware fault
🆘 new critical health issue
📦 backup started / updates available (update_summary)
🆕 new PVE version available (pve_update)
🔺 escalated / severity increased
📋 health digest / persistent issues
🚚 migration started
🔌 network down / node disconnected
🚨 auth failure / security alert
🚷 IP banned / blocked
🔑 permission change
💢 split-brain
💣 OOM kill
🚀 VM or CT started
VM or CT stopped
🔽 VM or CT shutdown
🔄 restarted / reboot / proxmox updates
🔥 high CPU / firewall issue
💧 high memory
🌡 high temperature
warning / degraded / high load / system problem
📉 low disk space
🚫 storage unavailable
🐢 high latency
📸 snapshot created
system shutdown
BODY LINE emoji one per line based on content:
🏷 VM name / CT name / ID line (first line of VM/CT lifecycle events)
status ok / success / action confirmed
status error / failed
💽 size (individual VM/CT backup)
💾 total backup size (summary line only)
duration
🗄 storage location / PBS path
📦 total updates count
🔒 security updates / jail
🔄 proxmox updates
kernel updates / service name
🗂 important packages header
🌐 source IP
👤 user
📝 reason / details
🌡 temperature
🔥 CPU usage
💧 memory usage
📊 summary line / statistics
👥 quorum / cluster nodes
💿 disk device
📂 filesystem / mount point
📌 category / package item (pve_update)
🚦 severity
🖥 node name
🎯 target node
🔹 current version (pve_update)
🟢 new version (pve_update)
EMOJI ENRICHMENT (VISUAL CLARITY)
Your goal is to maintain the original structure of the message while using emojis to add visual clarity,
ESPECIALLY when adding new context, formatting technical data, or writing tips.
RULES:
1. PRESERVE BASE STRUCTURE: Respect the original fields and layout provided in the input message.
2. ENHANCE WITH ICONS: Place emojis at the START of a line to identify the data type.
3. NEW CONTEXT: When adding journal info, SMART data, or known errors, use appropriate icons to make it readable.
4. NO SPAM: Do not put emojis in the middle or end of sentences. Use 1-3 emojis at START of lines where they add clarity. Combine when meaningful (💾 backup ok).
5. HIGHLIGHT ONLY: Use emojis to highlight, not as filler. Blank lines = completely empty.
BLANK LINES FOR READABILITY insert ONE blank line between logical sections within the body.
Blank lines go BETWEEN groups, not before the first line or after the last line.
A blank line must be completely empty no emoji, no spaces.
TITLE EMOJIS:
success failed 💥 crash 🆘 critical 📦 updates 🆕 pve-update 🚚 migration
stop 🔽 shutdown warning 💢 split-brain 🔌 disconnect 🚨 auth-fail 🚷 banned 📋 digest
🚀 = something STARTS (VM/CT start, backup start, server boot, task begin)
Combine: 💾🚀 backup-start 🖥🚀 system-boot 🚀 VM/CT-start
When to add a blank line:
- Updates: after the last count line, before the packages block
- Backup multi-VM: one blank line between each VM entry; one blank line before the summary line
- Disk/SMART errors: after the device line, before the error description lines
- VM events with a reason: after the main status line, before Reason / Node / Target lines
- Health events: after the category/status line, before duration or detail lines
BODY EMOJIS:
🏷 VM/CT name ok error 💽 size 💾 total duration 🗄 storage 📊 summary
📦 updates 🔒 security 🔄 proxmox kernel 🗂 packages 💿 disk 📝 reason/log
🌐 IP 👤 user 🌡 temp 🔥 CPU 💧 RAM 🎯 target 🔹 current 🟢 new 📌 item
EXAMPLE CT shutdown:
[TITLE]
🔽 amd: CT alpine (101) shut down
[BODY]
🏷 Container alpine (ID: 101)
Cleanly shut down
BLANK LINES: Insert between logical sections (VM entries, before summary, before packages block).
EXAMPLE VM started:
[TITLE]
🚀 pve01: VM arch-linux (100) started
[BODY]
🏷 Virtual machine arch-linux (ID: 100)
Now running
HOSTNAME RULE (CRITICAL)
The Title field contains the real hostname before the colon e.g.:
("constructor: VM started" hostname is "constructor").
("amd: VM started" hostname is "amd").
("pve01: VM started" hostname is "pve01").
("pve05: VM started" hostname is "pve05").
You MUST use this EXACT hostname in your output. NEVER use generic names like "server", "host", or "node".
EXAMPLE migration complete:
[TITLE]
🚚 amd: Migration complete web01 (100)
[BODY]
🏷 Virtual machine web01 (ID: 100)
Successfully migrated
🎯 Target: node02
EXAMPLES (follow these formats)
EXAMPLE updates message (no important packages):
[TITLE]
📦 amd: Updates available
[BODY]
📦 Total updates: 24
🔒 Security updates: 6
🔄 Proxmox updates: 0
Kernel updates: 0
BACKUP START:
[TITLE]
💾🚀 constructor: Backup started
[BODY]
Backup job starting on storage PBS.
🏷 VMs: web01 (100)
🗂 Important packages:
none
🗄 Storage: PBS | Mode: stop
EXAMPLE updates message (with important packages):
[TITLE]
📦 amd: Updates available
[BODY]
📦 Total updates: 90
🔒 Security updates: 6
🔄 Proxmox updates: 14
Kernel updates: 1
BACKUP COMPLETE:
[TITLE]
💾 amd: Backup complete
[BODY]
Backup job finished on storage local-bak.
🗂 Important packages:
pve-manager (9.1.4 -> 9.1.6)
qemu-server (9.1.3 -> 9.1.4)
pve-container (6.0.18 -> 6.1.2)
EXAMPLE pve_update (new Proxmox VE version):
[TITLE]
🆕 pve01: Proxmox VE 9.1.6 available
[BODY]
🚀 A new Proxmox VE release is available.
🏷 VM web01 (ID: 100)
Status: ok
💽 Size: 12.3 GiB
Duration: 00:04:21
🗄 Storage: vm/100/2026-03-17T22:00:08Z
🔹 Current: 9.1.4
🟢 New: 9.1.6
📊 Total: 1 backup | 💾 12.3 GiB | 00:04:21
🗂 Important packages:
📌 pve-manager (v9.1.4 v9.1.6)
BACKUP PARTIAL FAIL:
[TITLE]
💾 pve05: Backup partially failed
[BODY]
Backup job finished with errors.
EXAMPLE backup complete with multiple VMs:
[TITLE]
💾 pve01: Backup complete
[BODY]
Backup job finished on storage local-bak.
🏷 VM web01 (ID: 100)
Status: ok
💽 Size: 12.3 GiB
🏷 VM web01 (ID: 100)
Status: ok
💽 Size: 12.3 GiB
Duration: 00:04:21
🗄 Storage: vm/100/2026-03-17T22:00:08Z
🏷 VM broken (ID: 102)
Status: error
🏷 CT db (ID: 101)
Status: ok
💽 Size: 4.1 GiB
Duration: 00:01:10
🗄 Storage: ct/101/2026-03-17T22:04:29Z
📊 Total: 2 backups | 1 failed
📊 Total: 2 backups | 💾 16.4 GiB | 00:05:31
UPDATES:
[TITLE]
📦 amd: Updates available
[BODY]
📦 Total updates: 24
🔒 Security updates: 6
🔄 Proxmox updates: 0
EXAMPLE backup partially failed (some ok, some failed):
[TITLE]
💾 pve01: Backup partially failed
[BODY]
Backup job finished with errors on storage PBS2.
🗂 Important packages:
none
🏷 VM web01 (ID: 100)
Status: ok
💽 Size: 12.3 GiB
Duration: 00:04:21
🗄 Storage: vm/100/2026-03-17T22:00:08Z
VM/CT START:
[TITLE]
🚀 pve01: VM arch-linux (100) started
[BODY]
🏷 Virtual machine arch-linux (ID: 100)
Now running
🏷 VM broken (ID: 102)
Status: error
💽 Size: 0 B
Duration: 00:00:37
📊 Total: 2 backups | 1 failed | 💾 12.3 GiB | 00:04:58
EXAMPLE disk I/O health warning:
[TITLE]
💥 amd: Health warning Disk I/O errors
[BODY]
💿 Device: /dev/sda
1 sector currently unreadable (pending)
📝 Disk reports sectors in pending reallocation state
EXAMPLE health degraded (multiple issues):
[TITLE]
amd: 2 health checks degraded
[BODY]
💥 Disk I/O error on /dev/sda: 1 sector currently unreadable (pending)
🏷 Container CT 9005: failed to start
🏷 Container CT 9004: failed to start
🏷 Container CT 9002: failed to start"""
HEALTH DEGRADED:
[TITLE]
constructor: Health warning Disk I/O
[BODY]
💿 Device: /dev/sda
1 sector unreadable (pending)
📝 Log: process crashed (exit-code 255)
Recurring: 5 times in 24h
💡 Tip: Run 'systemctl status pvedaemon'"""
# No emoji instructions for email/plain text channels
@@ -1682,18 +1845,31 @@ class AIEnhancer:
language_code = self.config.get('ai_language', 'en')
language_name = AI_LANGUAGES.get(language_code, 'English')
# Get token limit for detail level
max_tokens = AI_DETAIL_TOKENS.get(detail_level, 200)
# Check for custom prompt mode
prompt_mode = self.config.get('ai_prompt_mode', 'default')
custom_prompt = self.config.get('ai_custom_prompt', '')
# Select emoji instructions based on channel type
emoji_instructions = AI_EMOJI_INSTRUCTIONS if use_emojis else AI_NO_EMOJI_INSTRUCTIONS
# Build system prompt with emoji instructions
system_prompt = AI_SYSTEM_PROMPT.format(
language=language_name,
detail_level=detail_level,
emoji_instructions=emoji_instructions
)
if prompt_mode == 'custom' and custom_prompt.strip():
# Custom prompt: user controls everything, use higher token limit
system_prompt = custom_prompt
max_tokens = 500 # Allow more tokens for custom prompts
else:
# Default prompt: use detail level and emoji settings
max_tokens = AI_DETAIL_TOKENS.get(detail_level, 200)
emoji_instructions = AI_EMOJI_INSTRUCTIONS if use_emojis else AI_NO_EMOJI_INSTRUCTIONS
# Check if experimental suggestions mode is enabled
allow_suggestions = self.config.get('ai_allow_suggestions', 'false')
if isinstance(allow_suggestions, str):
allow_suggestions = allow_suggestions.lower() == 'true'
suggestions_addon = AI_SUGGESTIONS_ADDON if allow_suggestions else ''
system_prompt = AI_SYSTEM_PROMPT.format(
language=language_name,
detail_level=detail_level,
emoji_instructions=emoji_instructions,
suggestions_addon=suggestions_addon
)
# Build user message
user_msg = f"Severity: {severity}\nTitle: {title}\nMessage:\n{body}"
+348
View File
@@ -0,0 +1,348 @@
#!/usr/bin/env python3
"""
Database of known Proxmox/Linux errors with causes, solutions, and severity levels.
This provides the AI with accurate, pre-verified information about common errors,
reducing hallucinations and ensuring consistent, helpful responses.
Each entry includes:
- pattern: regex pattern to match against error messages/logs
- cause: brief explanation of what causes this error
- cause_detailed: more comprehensive explanation for detailed mode
- severity: info, warning, critical
- solution: brief actionable solution
- solution_detailed: step-by-step solution for detailed mode
- url: optional documentation link
"""
import re
from typing import Optional, Dict, Any, List
# Known error patterns with causes and solutions
PROXMOX_KNOWN_ERRORS: List[Dict[str, Any]] = [
# ==================== SUBSCRIPTION/LICENSE ====================
{
"pattern": r"no valid subscription|subscription.*invalid|not subscribed",
"cause": "Proxmox enterprise repository requires paid subscription",
"cause_detailed": "Proxmox VE uses a subscription model for enterprise features. Without a valid subscription key, access to the enterprise repository is denied. This is normal for home/lab users.",
"severity": "info",
"solution": "Use no-subscription repository or purchase subscription",
"solution_detailed": "For home/lab use: Switch to the no-subscription repository by editing /etc/apt/sources.list.d/pve-enterprise.list. For production: Purchase a subscription at proxmox.com/pricing",
"url": "https://pve.proxmox.com/wiki/Package_Repositories",
"category": "updates"
},
# ==================== CLUSTER/COROSYNC ====================
{
"pattern": r"quorum.*lost|lost.*quorum|not.*quorate",
"cause": "Cluster lost majority of voting nodes",
"cause_detailed": "Corosync cluster requires more than 50% of configured votes to maintain quorum. When quorum is lost, the cluster becomes read-only to prevent split-brain scenarios.",
"severity": "critical",
"solution": "Check network connectivity between nodes; ensure majority of nodes are online",
"solution_detailed": "1. Verify network connectivity: ping all cluster nodes\n2. Check corosync status: systemctl status corosync\n3. View cluster status: pvecm status\n4. If nodes are unreachable, check firewall rules (ports 5405-5412 UDP)\n5. For emergency single-node operation: pvecm expected 1",
"url": "https://pve.proxmox.com/wiki/Cluster_Manager",
"category": "cluster"
},
{
"pattern": r"corosync.*qdevice.*error|qdevice.*connection.*failed|qdevice.*not.*connected",
"cause": "QDevice helper node is unreachable",
"cause_detailed": "The Corosync QDevice provides an additional vote for 2-node clusters. When it cannot connect, the cluster may lose quorum if one node fails.",
"severity": "warning",
"solution": "Check QDevice server connectivity and corosync-qnetd service",
"solution_detailed": "1. Verify QDevice server is running: systemctl status corosync-qnetd (on QDevice host)\n2. Check connectivity: nc -zv <qdevice-ip> 5403\n3. Restart qdevice: systemctl restart corosync-qdevice\n4. Check certificates: corosync-qdevice-net-certutil -s",
"url": "https://pve.proxmox.com/wiki/Cluster_Manager#_corosync_external_vote_support",
"category": "cluster"
},
{
"pattern": r"corosync.*retransmit|corosync.*token.*timeout|ring.*mark.*faulty",
"cause": "Network latency or packet loss between cluster nodes",
"cause_detailed": "Corosync uses multicast/unicast for cluster communication. High latency, packet loss, or network congestion causes token timeouts and retransmissions, potentially leading to node eviction.",
"severity": "warning",
"solution": "Check network quality between nodes; consider increasing token timeout",
"solution_detailed": "1. Test network latency: ping -c 100 <other-node>\n2. Check for packet loss between nodes\n3. Verify MTU settings match on all interfaces\n4. Increase token timeout in /etc/pve/corosync.conf if needed (default 1000ms)\n5. Check switch/router for congestion",
"category": "cluster"
},
# ==================== DISK/STORAGE ====================
{
"pattern": r"SMART.*FAILED|smart.*failed.*health|Pre-fail|Old_age.*FAILING",
"cause": "Disk SMART health check failed - disk is failing",
"cause_detailed": "SMART (Self-Monitoring, Analysis and Reporting Technology) detected critical disk health issues. The disk is likely failing and data loss is imminent.",
"severity": "critical",
"solution": "IMMEDIATELY backup data and replace disk",
"solution_detailed": "1. URGENT: Backup all data from this disk immediately\n2. Check SMART details: smartctl -a /dev/sdX\n3. Note the failing attributes (Reallocated_Sector_Ct, Current_Pending_Sector, etc.)\n4. Plan disk replacement\n5. If in RAID/ZFS: initiate disk replacement procedure",
"category": "disks"
},
{
"pattern": r"Reallocated_Sector_Ct.*threshold|reallocated.*sectors?.*exceeded",
"cause": "Disk has excessive bad sectors being remapped",
"cause_detailed": "The disk firmware has remapped multiple bad sectors to spare areas. While the disk is still functioning, this indicates physical degradation and eventual failure.",
"severity": "warning",
"solution": "Monitor closely and plan disk replacement",
"solution_detailed": "1. Check current value: smartctl -A /dev/sdX | grep Reallocated\n2. If value is increasing, plan immediate replacement\n3. Backup important data\n4. Run extended SMART test: smartctl -t long /dev/sdX",
"category": "disks"
},
{
"pattern": r"ata.*error|ATA.*bus.*error|Emask.*0x|DRDY.*ERR|UNC.*error",
"cause": "ATA communication error with disk",
"cause_detailed": "The SATA/ATA controller encountered communication errors with the disk. This can indicate cable issues, controller problems, or disk failure.",
"severity": "warning",
"solution": "Check SATA cables and connections; verify disk health with smartctl",
"solution_detailed": "1. Check SMART health: smartctl -H /dev/sdX\n2. Inspect and reseat SATA cables\n3. Try different SATA port\n4. Check dmesg for pattern of errors\n5. If errors persist, disk may be failing",
"category": "disks"
},
{
"pattern": r"I/O.*error|blk_update_request.*error|Buffer I/O error",
"cause": "Disk I/O operation failed",
"cause_detailed": "The kernel failed to read or write data to the disk. This can be caused by disk failure, cable issues, or filesystem corruption.",
"severity": "critical",
"solution": "Check disk health and connections immediately",
"solution_detailed": "1. Check SMART status: smartctl -H /dev/sdX\n2. Check dmesg for related errors: dmesg | grep -i error\n3. Verify disk is still accessible: lsblk\n4. If ZFS: check pool status with zpool status\n5. Consider filesystem check if safe to unmount",
"category": "disks"
},
{
"pattern": r"zfs.*pool.*DEGRADED|pool.*is.*degraded",
"cause": "ZFS pool has reduced redundancy",
"cause_detailed": "One or more devices in the ZFS pool are unavailable or experiencing errors. The pool is still functional but without full redundancy.",
"severity": "warning",
"solution": "Identify failed device with 'zpool status' and replace",
"solution_detailed": "1. Check pool status: zpool status <pool>\n2. Identify the DEGRADED or UNAVAIL device\n3. If device is present but erroring: zpool scrub <pool>\n4. To replace: zpool replace <pool> <old-device> <new-device>\n5. Monitor resilver progress: zpool status",
"category": "storage"
},
{
"pattern": r"zfs.*pool.*FAULTED|pool.*is.*faulted",
"cause": "ZFS pool is inaccessible",
"cause_detailed": "The ZFS pool has lost too many devices and cannot maintain data integrity. Data may be inaccessible.",
"severity": "critical",
"solution": "Check failed devices; may need data recovery",
"solution_detailed": "1. Check status: zpool status <pool>\n2. Identify all failed devices\n3. Attempt to online devices: zpool online <pool> <device>\n4. If drives are physically present, try zpool clear <pool>\n5. May require data recovery if multiple drives failed",
"category": "storage"
},
# ==================== CEPH ====================
{
"pattern": r"ceph.*OSD.*down|osd\.\d+.*down|ceph.*osd.*failed",
"cause": "Ceph OSD daemon is not running",
"cause_detailed": "A Ceph Object Storage Daemon (OSD) has stopped or crashed. This reduces storage redundancy and may trigger data rebalancing.",
"severity": "warning",
"solution": "Check disk health and restart OSD service",
"solution_detailed": "1. Check OSD status: ceph osd tree\n2. View OSD logs: journalctl -u ceph-osd@<id>\n3. Check underlying disk: smartctl -H /dev/sdX\n4. Restart OSD: systemctl start ceph-osd@<id>\n5. If OSD keeps crashing, check for disk failure",
"category": "storage"
},
{
"pattern": r"ceph.*health.*WARN|HEALTH_WARN",
"cause": "Ceph cluster has warnings",
"cause_detailed": "Ceph detected issues that don't prevent operation but should be addressed. Common causes: degraded PGs, clock skew, full OSDs.",
"severity": "warning",
"solution": "Run 'ceph health detail' for specific issues",
"solution_detailed": "1. Get details: ceph health detail\n2. Common fixes:\n - Degraded PGs: wait for recovery or add capacity\n - Clock skew: sync NTP on all nodes\n - Full OSDs: add storage or delete data\n3. Check: ceph status",
"category": "storage"
},
{
"pattern": r"ceph.*health.*ERR|HEALTH_ERR",
"cause": "Ceph cluster has critical errors",
"cause_detailed": "Ceph has detected critical issues that may affect data availability or integrity. Immediate attention required.",
"severity": "critical",
"solution": "Run 'ceph health detail' and address errors immediately",
"solution_detailed": "1. Get details: ceph health detail\n2. Check OSD status: ceph osd tree\n3. Check MON status: ceph mon stat\n4. View PG status: ceph pg stat\n5. Address each error shown in health detail",
"category": "storage"
},
# ==================== VM/CT ERRORS ====================
{
"pattern": r"TASK ERROR.*failed to get exclusive lock|lock.*timeout|couldn't acquire lock",
"cause": "Resource is locked by another operation",
"cause_detailed": "Another task is currently holding a lock on this VM/CT. This prevents concurrent modifications that could cause corruption.",
"severity": "info",
"solution": "Wait for other task to complete or check for stuck tasks",
"solution_detailed": "1. Check running tasks: cat /var/log/pve/tasks/active\n2. Wait for task completion\n3. If task is stuck (>1h), check process: ps aux | grep <vmid>\n4. As last resort, remove lock file: rm /var/lock/qemu-server/lock-<vmid>.conf",
"category": "vms"
},
{
"pattern": r"kvm.*not.*available|kvm.*disabled|hardware.*virtualization.*disabled",
"cause": "KVM/hardware virtualization not available",
"cause_detailed": "The CPU's hardware virtualization extensions (Intel VT-x or AMD-V) are either not supported, not enabled in BIOS, or blocked by another hypervisor.",
"severity": "warning",
"solution": "Enable VT-x/AMD-V in BIOS settings",
"solution_detailed": "1. Reboot into BIOS/UEFI\n2. Find Virtualization settings (often in CPU or Advanced section)\n3. Enable Intel VT-x or AMD-V/SVM\n4. Save and reboot\n5. Verify: grep -E 'vmx|svm' /proc/cpuinfo",
"category": "vms"
},
{
"pattern": r"out of memory|OOM.*kill|cannot allocate memory|memory.*exhausted",
"cause": "System or VM ran out of memory",
"cause_detailed": "The Linux OOM (Out Of Memory) killer terminated a process to free memory. This indicates memory pressure from overcommitment or memory leaks.",
"severity": "critical",
"solution": "Increase memory allocation or reduce VM memory usage",
"solution_detailed": "1. Check what was killed: dmesg | grep -i oom\n2. Review memory usage: free -h\n3. Check balloon driver status for VMs\n4. Consider adding swap or RAM\n5. Review VM memory allocations for overcommitment",
"category": "memory"
},
# ==================== NETWORK ====================
{
"pattern": r"bond.*slave.*link.*down|bond.*no.*active.*slave",
"cause": "Network bond lost a slave interface",
"cause_detailed": "One or more physical interfaces in a network bond have lost link. Depending on bond mode, this may reduce bandwidth or affect failover.",
"severity": "warning",
"solution": "Check physical cable connections and switch ports",
"solution_detailed": "1. Check bond status: cat /proc/net/bonding/bond0\n2. Identify down slave interface\n3. Check physical cable connection\n4. Check switch port status and errors\n5. Verify interface: ethtool <slave-iface>",
"category": "network"
},
{
"pattern": r"link.*not.*ready|carrier.*lost|link.*down|NIC.*Link.*Down",
"cause": "Network interface lost link",
"cause_detailed": "The physical or virtual network interface has lost its connection. This could be a cable issue, switch problem, or driver issue.",
"severity": "warning",
"solution": "Check cable, switch port, and interface status",
"solution_detailed": "1. Check interface: ip link show <iface>\n2. Check cable connection\n3. Check switch port LEDs\n4. Try: ip link set <iface> down && ip link set <iface> up\n5. Check driver: ethtool -i <iface>",
"category": "network"
},
{
"pattern": r"bridge.*STP.*blocked|spanning.*tree.*blocked",
"cause": "Spanning Tree Protocol blocked a port",
"cause_detailed": "STP detected a potential network loop and blocked a bridge port to prevent broadcast storms. This is normal behavior but may indicate network topology issues.",
"severity": "info",
"solution": "Review network topology; this may be expected behavior",
"solution_detailed": "1. Check bridge status: brctl show\n2. View STP state: brctl showstp <bridge>\n3. If unexpected, review network topology for loops\n4. Consider disabling STP if network is simple: brctl stp <bridge> off",
"category": "network"
},
# ==================== SERVICES ====================
{
"pattern": r"pvedaemon.*failed|pveproxy.*failed|pvestatd.*failed",
"cause": "Critical Proxmox service failed",
"cause_detailed": "One of the core Proxmox daemons has crashed or failed to start. This may affect web GUI access or API functionality.",
"severity": "critical",
"solution": "Restart the failed service; check logs for cause",
"solution_detailed": "1. Check status: systemctl status <service>\n2. View logs: journalctl -u <service> -n 50\n3. Restart: systemctl restart <service>\n4. If persistent, check: /var/log/pveproxy/access.log",
"category": "pve_services"
},
{
"pattern": r"failed to start.*service|service.*start.*failed|service.*activation.*failed",
"cause": "System service failed to start",
"cause_detailed": "A systemd service unit failed during startup. This could be due to configuration errors, missing dependencies, or resource issues.",
"severity": "warning",
"solution": "Check service logs with journalctl -u <service>",
"solution_detailed": "1. Check status: systemctl status <service>\n2. View logs: journalctl -xeu <service>\n3. Check config: systemctl cat <service>\n4. Verify dependencies: systemctl list-dependencies <service>\n5. Try restart: systemctl restart <service>",
"category": "services"
},
# ==================== BACKUP ====================
{
"pattern": r"backup.*failed|vzdump.*error|backup.*job.*failed",
"cause": "Backup job failed",
"cause_detailed": "A scheduled or manual backup operation failed. Common causes: storage full, VM locked, network issues for remote storage.",
"severity": "warning",
"solution": "Check backup storage space and VM status",
"solution_detailed": "1. Check backup log in Datacenter > Backup\n2. Verify storage space: df -h\n3. Check if VM is locked: qm list or pct list\n4. Verify backup storage is accessible\n5. Try manual backup to identify specific error",
"category": "backups"
},
# ==================== CERTIFICATES ====================
{
"pattern": r"certificate.*expired|SSL.*certificate.*expired|cert.*expir",
"cause": "SSL/TLS certificate has expired",
"cause_detailed": "An SSL certificate used for secure communication has passed its expiration date. This may cause connection failures or security warnings.",
"severity": "warning",
"solution": "Renew the certificate using pvenode cert set or Let's Encrypt",
"solution_detailed": "1. Check certificate: pvenode cert info\n2. For self-signed renewal: pvecm updatecerts\n3. For Let's Encrypt: pvenode acme cert order\n4. Restart pveproxy after renewal: systemctl restart pveproxy",
"url": "https://pve.proxmox.com/wiki/Certificate_Management",
"category": "security"
},
# ==================== HARDWARE/TEMPERATURE ====================
{
"pattern": r"temperature.*critical|thermal.*critical|CPU.*overheating|temp.*above.*threshold",
"cause": "Component temperature critical",
"cause_detailed": "A hardware component (CPU, disk, etc.) has reached a dangerous temperature. Sustained high temperatures can cause hardware damage or system shutdowns.",
"severity": "critical",
"solution": "Check cooling system immediately; clean dust, verify fans",
"solution_detailed": "1. Check current temps: sensors\n2. Verify all fans are running\n3. Clean dust from heatsinks and filters\n4. Ensure adequate airflow\n5. Consider reapplying thermal paste if CPU\n6. Check ambient room temperature",
"category": "temperature"
},
# ==================== AUTHENTICATION ====================
{
"pattern": r"authentication.*failed|login.*failed|invalid.*credentials|access.*denied",
"cause": "Authentication failure",
"cause_detailed": "A login attempt failed due to invalid credentials or permissions. Multiple failures may indicate a brute-force attack.",
"severity": "info",
"solution": "Verify credentials; check for unauthorized access attempts",
"solution_detailed": "1. Review auth logs: journalctl -u pvedaemon | grep auth\n2. Check for multiple failures from same IP\n3. Verify user exists: pveum user list\n4. If attack suspected, consider fail2ban\n5. Reset password if needed: pveum passwd <user>",
"category": "security"
},
]
def find_matching_error(text: str, category: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Find a known error that matches the given text.
Args:
text: Error message or log content to match against
category: Optional category to filter by
Returns:
Matching error dict or None
"""
if not text:
return None
text_lower = text.lower()
for error in PROXMOX_KNOWN_ERRORS:
# Filter by category if specified
if category and error.get("category") != category:
continue
try:
if re.search(error["pattern"], text_lower, re.IGNORECASE):
return error
except re.error:
continue
return None
def get_error_context(text: str, category: Optional[str] = None, detail_level: str = "standard") -> Optional[str]:
"""Get formatted context for a known error.
Args:
text: Error message to match
category: Optional category filter
detail_level: "minimal", "standard", or "detailed"
Returns:
Formatted context string or None
"""
error = find_matching_error(text, category)
if not error:
return None
if detail_level == "minimal":
return f"Known issue: {error['cause']}"
elif detail_level == "standard":
lines = [
f"KNOWN PROXMOX ERROR DETECTED:",
f" Cause: {error['cause']}",
f" Severity: {error['severity'].upper()}",
f" Solution: {error['solution']}"
]
if error.get("url"):
lines.append(f" Docs: {error['url']}")
return "\n".join(lines)
else: # detailed
lines = [
f"KNOWN PROXMOX ERROR DETECTED:",
f" Cause: {error.get('cause_detailed', error['cause'])}",
f" Severity: {error['severity'].upper()}",
f" Solution: {error.get('solution_detailed', error['solution'])}"
]
if error.get("url"):
lines.append(f" Documentation: {error['url']}")
return "\n".join(lines)
def get_all_patterns() -> List[str]:
"""Get all error patterns for external use."""
return [error["pattern"] for error in PROXMOX_KNOWN_ERRORS]
+49 -6
View File
@@ -8,18 +8,32 @@ Monitors configured Proxmox storages and tracks unavailable storages
import json
import subprocess
import socket
import time
from typing import Dict, List, Any, Optional
class ProxmoxStorageMonitor:
"""Monitor Proxmox storage configuration and status"""
# Cache TTL: 177 seconds (~3 min) - offset to avoid sync with other processes
_CACHE_TTL = 177
def __init__(self):
self.configured_storages: Dict[str, Dict[str, Any]] = {}
self._node_name_cache = {'name': None, 'time': 0}
self._storage_status_cache = {'data': None, 'time': 0}
self._config_cache_time = 0 # Track when config was last loaded
self._load_configured_storages()
def _get_node_name(self) -> str:
"""Get current Proxmox node name"""
"""Get current Proxmox node name (cached)"""
current_time = time.time()
cache = self._node_name_cache
# Return cached result if fresh
if cache['name'] and (current_time - cache['time']) < self._CACHE_TTL:
return cache['name']
try:
result = subprocess.run(
['pvesh', 'get', '/nodes', '--output-format', 'json'],
@@ -32,9 +46,14 @@ class ProxmoxStorageMonitor:
hostname = socket.gethostname()
for node in nodes:
if node.get('node') == hostname:
cache['name'] = hostname
cache['time'] = current_time
return hostname
if nodes:
return nodes[0].get('node', hostname)
name = nodes[0].get('node', hostname)
cache['name'] = name
cache['time'] = current_time
return name
return socket.gethostname()
except Exception:
return socket.gethostname()
@@ -84,7 +103,7 @@ class ProxmoxStorageMonitor:
def get_storage_status(self) -> Dict[str, List[Dict[str, Any]]]:
"""
Get storage status, including unavailable storages
Get storage status, including unavailable storages (cached)
Returns:
{
@@ -92,6 +111,13 @@ class ProxmoxStorageMonitor:
'unavailable': [...]
}
"""
current_time = time.time()
cache = self._storage_status_cache
# Return cached result if fresh
if cache['data'] and (current_time - cache['time']) < self._CACHE_TTL:
return cache['data']
try:
local_node = self._get_node_name()
@@ -176,10 +202,16 @@ class ProxmoxStorageMonitor:
'node': local_node
})
return {
result_data = {
'available': available_storages,
'unavailable': unavailable_storages
}
# Cache the result
cache['data'] = result_data
cache['time'] = current_time
return result_data
except Exception:
return {
@@ -192,10 +224,21 @@ class ProxmoxStorageMonitor:
status = self.get_storage_status()
return len(status['unavailable'])
def reload_configuration(self) -> None:
"""Reload storage configuration from Proxmox"""
def reload_configuration(self, force: bool = False) -> None:
"""Reload storage configuration from Proxmox (cached)
Args:
force: If True, bypass cache and force reload
"""
current_time = time.time()
# Skip reload if cache is still fresh (unless forced)
if not force and (current_time - self._config_cache_time) < self._CACHE_TTL:
return
self.configured_storages.clear()
self._load_configured_storages()
self._config_cache_time = current_time
# Global instance
+146
View File
@@ -1984,3 +1984,149 @@ def parse_lynis_report():
report["proxmox_context_applied"] = True
return report
# -------------------------------------------------------------------
# Uninstall Functions
# -------------------------------------------------------------------
def uninstall_fail2ban():
"""
Uninstall Fail2Ban and clean up all configuration.
Returns (success, message).
"""
try:
# Stop fail2ban service
_run_cmd(["systemctl", "stop", "fail2ban"], timeout=30)
_run_cmd(["systemctl", "disable", "fail2ban"], timeout=10)
# Stop and remove auth logger services
_run_cmd(["systemctl", "stop", "proxmox-auth-logger.service"], timeout=10)
_run_cmd(["systemctl", "disable", "proxmox-auth-logger.service"], timeout=10)
_run_cmd(["systemctl", "stop", "ssh-auth-logger.service"], timeout=10)
_run_cmd(["systemctl", "disable", "ssh-auth-logger.service"], timeout=10)
# Remove systemd service files
for svc_file in [
"/etc/systemd/system/proxmox-auth-logger.service",
"/etc/systemd/system/ssh-auth-logger.service",
]:
if os.path.exists(svc_file):
os.remove(svc_file)
_run_cmd(["systemctl", "daemon-reload"], timeout=10)
# Remove log files created by auth loggers
for log_file in ["/var/log/proxmox-auth.log", "/var/log/ssh-auth.log"]:
if os.path.exists(log_file):
os.remove(log_file)
# Purge fail2ban package
_run_cmd(["apt-get", "purge", "-y", "fail2ban"], timeout=120)
# Remove configuration files
for cfg_file in [
"/etc/fail2ban/jail.d/proxmox.conf",
"/etc/fail2ban/jail.d/proxmenux.conf",
"/etc/fail2ban/filter.d/proxmox.conf",
"/etc/fail2ban/filter.d/proxmenux.conf",
"/etc/fail2ban/jail.local",
]:
if os.path.exists(cfg_file):
os.remove(cfg_file)
# Restore SSH MaxAuthTries if backup exists
base_dir = "/usr/local/share/proxmenux"
backup_file = os.path.join(base_dir, "sshd_maxauthtries_backup")
sshd_config = "/etc/ssh/sshd_config"
if os.path.exists(backup_file) and os.path.exists(sshd_config):
try:
with open(backup_file, 'r') as f:
original_val = f.read().strip()
if original_val:
with open(sshd_config, 'r') as f:
content = f.read()
import re
content = re.sub(
r'^MaxAuthTries.*$',
f'MaxAuthTries {original_val}',
content,
flags=re.MULTILINE
)
with open(sshd_config, 'w') as f:
f.write(content)
_run_cmd(["systemctl", "reload", "sshd"], timeout=10)
os.remove(backup_file)
except Exception:
pass
# Remove journald drop-in
journald_dropin = "/etc/systemd/journald.conf.d/proxmenux-loglevel.conf"
if os.path.exists(journald_dropin):
os.remove(journald_dropin)
_run_cmd(["systemctl", "restart", "systemd-journald"], timeout=30)
# Update component status
components_file = os.path.join(base_dir, "components_status.json")
if os.path.exists(components_file):
try:
import json
with open(components_file, 'r') as f:
components = json.load(f)
if "fail2ban" in components:
components["fail2ban"]["status"] = "removed"
components["fail2ban"]["version"] = ""
with open(components_file, 'w') as f:
json.dump(components, f, indent=2)
except Exception:
pass
return True, "Fail2Ban has been uninstalled successfully"
except Exception as e:
return False, f"Error uninstalling Fail2Ban: {str(e)}"
def uninstall_lynis():
"""
Uninstall Lynis and clean up all files.
Returns (success, message).
"""
try:
import shutil
# Remove installation directory
if os.path.exists("/opt/lynis"):
shutil.rmtree("/opt/lynis")
# Remove wrapper script
if os.path.exists("/usr/local/bin/lynis"):
os.remove("/usr/local/bin/lynis")
# Remove report files
for report_file in [
"/var/log/lynis-report.dat",
"/var/log/lynis.log",
"/var/log/lynis-output.log",
]:
if os.path.exists(report_file):
os.remove(report_file)
# Update component status
base_dir = "/usr/local/share/proxmenux"
components_file = os.path.join(base_dir, "components_status.json")
if os.path.exists(components_file):
try:
import json
with open(components_file, 'r') as f:
components = json.load(f)
if "lynis" in components:
components["lynis"]["status"] = "removed"
components["lynis"]["version"] = ""
with open(components_file, 'w') as f:
json.dump(components, f, indent=2)
except Exception:
pass
return True, "Lynis has been uninstalled successfully"
except Exception as e:
return False, f"Error uninstalling Lynis: {str(e)}"
+510
View File
@@ -0,0 +1,510 @@
"""
Centralized Startup Grace Period Management
This module provides a single source of truth for startup grace period logic.
During system boot, various transient issues occur (high latency, storage not ready,
QMP timeouts, etc.) that shouldn't trigger notifications or critical alerts.
Grace Periods:
- VM/CT aggregation: 3 minutes - Aggregate multiple VM/CT starts into one notification
- Health suppression: 5 minutes - Suppress transient health warnings/errors
- Shutdown suppression: 2 minutes - Suppress VM/CT stops during system shutdown
Categories suppressed during startup:
- storage: NFS/CIFS mounts may take time to become available
- vms: VMs may have QMP timeouts or startup delays
- network: Latency spikes during boot are normal
- services: PVE services may take time to fully initialize
"""
import time
import threading
from typing import Set, List, Tuple, Optional
# ─── Configuration ───────────────────────────────────────────────────────────
# Grace period durations (seconds)
STARTUP_VM_GRACE_SECONDS = 180 # 3 minutes for VM/CT start aggregation
STARTUP_HEALTH_GRACE_SECONDS = 300 # 5 minutes for health warning suppression
SHUTDOWN_GRACE_SECONDS = 120 # 2 minutes for VM/CT stop suppression
# Maximum system uptime to consider this a real server boot (not just service restart)
# If system uptime > this value when service starts, skip startup notification
MAX_BOOT_UPTIME_SECONDS = 600 # 10 minutes - if system was up longer, it's a service restart
def _get_system_uptime() -> float:
"""
Get actual system uptime in seconds from /proc/uptime.
Returns 0 if unable to read (will default to treating as new boot).
"""
try:
with open('/proc/uptime', 'r') as f:
return float(f.readline().split()[0])
except Exception:
return 0
# Categories to suppress during startup grace period
# These categories typically have transient issues during boot
STARTUP_GRACE_CATEGORIES: Set[str] = {
'storage', # NFS/CIFS mounts may take time
'vms', # VMs may have QMP timeouts
'network', # Latency spikes during boot
'services', # PVE services initialization
}
# ─── Singleton State ─────────────────────────────────────────────────────────
class _StartupGraceState:
"""
Thread-safe singleton managing all startup/shutdown grace period state.
Initialized when the module loads (service start), which serves as the
reference point for determining if we're still in the startup period.
"""
_instance: Optional['_StartupGraceState'] = None
_init_lock = threading.Lock()
def __new__(cls) -> '_StartupGraceState':
if cls._instance is None:
with cls._init_lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self._lock = threading.Lock()
# Startup time = when service started (module load time)
self._startup_time: float = time.time()
# Check if this is a REAL system boot or just a service restart
# by comparing system uptime to our threshold
system_uptime = _get_system_uptime()
self._is_real_boot: bool = system_uptime < MAX_BOOT_UPTIME_SECONDS
# Shutdown tracking
self._shutdown_time: float = 0
# VM/CT aggregation during startup
self._startup_vms: List[Tuple[str, str, str]] = [] # [(vmid, vmname, 'vm'|'ct'), ...]
self._startup_aggregated: bool = False
self._initialized = True
# ─── Startup Period Checks ───────────────────────────────────────────────
def is_startup_vm_period(self) -> bool:
"""
Check if we're within the VM/CT start aggregation period (3 min).
During this period, individual VM/CT start notifications are collected
and later sent as a single aggregated notification.
"""
with self._lock:
return (time.time() - self._startup_time) < STARTUP_VM_GRACE_SECONDS
def is_startup_health_grace(self) -> bool:
"""
Check if we're within the health suppression period (5 min).
During this period:
- Transient health warnings (latency, storage, etc.) are suppressed
- CRITICAL/WARNING may be downgraded to INFO for certain categories
- Health degradation notifications are skipped for grace categories
"""
with self._lock:
return (time.time() - self._startup_time) < STARTUP_HEALTH_GRACE_SECONDS
def should_suppress_category(self, category: str) -> bool:
"""
Check if notifications for a category should be suppressed.
Args:
category: Health category name (e.g., 'network', 'storage', 'vms')
Returns:
True if we're in grace period AND category is in STARTUP_GRACE_CATEGORIES
"""
if category.lower() in STARTUP_GRACE_CATEGORIES:
return self.is_startup_health_grace()
return False
def is_real_system_boot(self) -> bool:
"""
Check if the service started during a real system boot.
Returns False if the system was already running for more than 10 minutes
when the service started (indicates a service restart, not a system boot).
This prevents sending "System startup completed" notifications when
just restarting the ProxMenux Monitor service.
"""
with self._lock:
return self._is_real_boot
def get_startup_elapsed(self) -> float:
"""Get seconds elapsed since service startup."""
with self._lock:
return time.time() - self._startup_time
# ─── Shutdown Tracking ───────────────────────────────────────────────────
def mark_shutdown(self):
"""
Called when system_shutdown or system_reboot is detected.
After this, VM/CT stop notifications will be suppressed for the
shutdown grace period (expected stops during system shutdown).
"""
with self._lock:
self._shutdown_time = time.time()
def is_host_shutting_down(self) -> bool:
"""
Check if we're within the shutdown grace period.
During this period, VM/CT stop events are expected and should not
generate notifications.
"""
with self._lock:
if self._shutdown_time == 0:
return False
return (time.time() - self._shutdown_time) < SHUTDOWN_GRACE_SECONDS
# ─── VM/CT Start Aggregation ─────────────────────────────────────────────
def add_startup_vm(self, vmid: str, vmname: str, vm_type: str):
"""
Record a VM/CT start during startup period for later aggregation.
Args:
vmid: VM/CT ID
vmname: VM/CT name
vm_type: 'vm' or 'ct'
"""
with self._lock:
self._startup_vms.append((vmid, vmname, vm_type))
def get_and_clear_startup_vms(self) -> List[Tuple[str, str, str]]:
"""
Get all recorded startup VMs and clear the list.
Should be called once after the VM aggregation grace period ends
to get all VMs that started during boot for a single notification.
Returns:
List of (vmid, vmname, vm_type) tuples
"""
with self._lock:
vms = self._startup_vms.copy()
self._startup_vms = []
self._startup_aggregated = True
return vms
def has_startup_vms(self) -> bool:
"""Check if there are any startup VMs recorded."""
with self._lock:
return len(self._startup_vms) > 0
def was_startup_aggregated(self) -> bool:
"""Check if startup aggregation has already been processed."""
with self._lock:
return self._startup_aggregated
def mark_startup_aggregated(self) -> None:
"""Mark startup aggregation as completed without returning VMs."""
with self._lock:
self._startup_aggregated = True
# ─── Module-level convenience functions ──────────────────────────────────────
# Global singleton instance
_state = _StartupGraceState()
def is_startup_vm_period() -> bool:
"""Check if we're within the VM/CT start aggregation period (3 min)."""
return _state.is_startup_vm_period()
def is_startup_health_grace() -> bool:
"""Check if we're within the health suppression period (5 min)."""
return _state.is_startup_health_grace()
def should_suppress_category(category: str) -> bool:
"""Check if notifications for a category should be suppressed during startup."""
return _state.should_suppress_category(category)
def get_startup_elapsed() -> float:
"""Get seconds elapsed since service startup."""
return _state.get_startup_elapsed()
def mark_shutdown():
"""Mark that system shutdown/reboot has been detected."""
_state.mark_shutdown()
def is_host_shutting_down() -> bool:
"""Check if we're within the shutdown grace period."""
return _state.is_host_shutting_down()
def add_startup_vm(vmid: str, vmname: str, vm_type: str):
"""Record a VM/CT start during startup period for aggregation."""
_state.add_startup_vm(vmid, vmname, vm_type)
def get_and_clear_startup_vms() -> List[Tuple[str, str, str]]:
"""Get all recorded startup VMs and clear the list."""
return _state.get_and_clear_startup_vms()
def has_startup_vms() -> bool:
"""Check if there are any startup VMs recorded."""
return _state.has_startup_vms()
def was_startup_aggregated() -> bool:
"""Check if startup aggregation has already been processed."""
return _state.was_startup_aggregated()
def mark_startup_aggregated() -> None:
"""Mark startup aggregation as completed without processing VMs.
Use this when skipping startup notification (e.g., service restart
instead of real system boot) to prevent future checks.
"""
_state.mark_startup_aggregated()
def is_real_system_boot() -> bool:
"""
Check if this is a real system boot (not just a service restart).
Returns True if the system uptime was less than 10 minutes when the
service started. Returns False if the system was already running
longer (indicates the service was restarted, not the whole system).
Use this to prevent sending "System startup completed" notifications
when just restarting the ProxMenux Monitor service.
"""
return _state.is_real_system_boot()
# ─── Startup Report Collection ───────────────────────────────────────────────
def collect_startup_report() -> dict:
"""
Collect comprehensive startup report data.
Called at the end of the grace period to generate a complete
startup report including:
- VMs/CTs that started successfully
- VMs/CTs that failed to start
- Service status
- Storage status
- Journal errors during boot (for AI enrichment)
Returns:
Dictionary with startup report data
"""
import subprocess
report = {
# VMs/CTs
'vms_started': [],
'cts_started': [],
'vms_failed': [],
'cts_failed': [],
# System status
'services_ok': True,
'services_failed': [],
'storage_ok': True,
'storage_unavailable': [],
# Health summary
'health_status': 'OK',
'health_issues': [],
# For AI enrichment
'_journal_context': '',
'_startup_errors': [],
# Metadata
'startup_duration_seconds': get_startup_elapsed(),
'timestamp': int(time.time()),
}
# Get VMs/CTs that started during boot
startup_vms = get_and_clear_startup_vms()
for vmid, vmname, vm_type in startup_vms:
if vm_type == 'vm':
report['vms_started'].append({'vmid': vmid, 'name': vmname})
else:
report['cts_started'].append({'vmid': vmid, 'name': vmname})
# Try to get health status from health_monitor
try:
import health_monitor
health_data = health_monitor.get_detailed_status()
if health_data:
report['health_status'] = health_data.get('overall_status', 'UNKNOWN')
# Check storage
storage_cat = health_data.get('categories', {}).get('storage', {})
if storage_cat.get('status') in ['CRITICAL', 'WARNING']:
report['storage_ok'] = False
for check in storage_cat.get('checks', []):
if check.get('status') in ['CRITICAL', 'WARNING', 'error']:
report['storage_unavailable'].append({
'name': check.get('name', 'unknown'),
'reason': check.get('reason', check.get('message', ''))
})
# Check services
services_cat = health_data.get('categories', {}).get('services', {})
if services_cat.get('status') in ['CRITICAL', 'WARNING']:
report['services_ok'] = False
for check in services_cat.get('checks', []):
if check.get('status') in ['CRITICAL', 'WARNING', 'error']:
report['services_failed'].append({
'name': check.get('name', 'unknown'),
'reason': check.get('reason', check.get('message', ''))
})
# Check VMs category for failed VMs
vms_cat = health_data.get('categories', {}).get('vms', {})
for check in vms_cat.get('checks', []):
if check.get('status') in ['CRITICAL', 'WARNING', 'error']:
# Determine if VM or CT based on name/type
check_name = check.get('name', '')
check_reason = check.get('reason', check.get('message', ''))
if 'error al iniciar' in check_reason.lower() or 'failed to start' in check_reason.lower():
if 'CT' in check_name or 'Container' in check_name:
report['cts_failed'].append({
'name': check_name,
'reason': check_reason
})
else:
report['vms_failed'].append({
'name': check_name,
'reason': check_reason
})
# Collect all health issues for summary
for cat_name, cat_data in health_data.get('categories', {}).items():
if cat_data.get('status') in ['CRITICAL', 'WARNING']:
report['health_issues'].append({
'category': cat_name,
'status': cat_data.get('status'),
'reason': cat_data.get('reason', '')
})
except Exception as e:
report['_startup_errors'].append(f"Error getting health data: {e}")
# Get journal errors during startup (for AI enrichment)
try:
boot_time = int(_state._startup_time)
result = subprocess.run(
['journalctl', '-p', 'err', '--since', f'@{boot_time}', '--no-pager', '-n', '50'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0 and result.stdout.strip():
report['_journal_context'] = result.stdout.strip()
except Exception as e:
report['_startup_errors'].append(f"Error getting journal: {e}")
return report
def format_startup_summary(report: dict) -> str:
"""
Format a human-readable startup summary from report data.
Args:
report: Dictionary from collect_startup_report()
Returns:
Formatted summary string
"""
lines = []
# Count totals
vms_ok = len(report.get('vms_started', []))
cts_ok = len(report.get('cts_started', []))
vms_fail = len(report.get('vms_failed', []))
cts_fail = len(report.get('cts_failed', []))
total_ok = vms_ok + cts_ok
total_fail = vms_fail + cts_fail
# Determine overall status
has_issues = (
total_fail > 0 or
not report.get('services_ok', True) or
not report.get('storage_ok', True) or
report.get('health_status') in ['CRITICAL', 'WARNING']
)
# Header
if has_issues:
issue_count = total_fail + len(report.get('services_failed', [])) + len(report.get('storage_unavailable', []))
lines.append(f"System startup - {issue_count} issue(s) detected")
else:
lines.append("System startup completed")
lines.append("All systems operational.")
# VMs/CTs started
if total_ok > 0:
parts = []
if vms_ok > 0:
parts.append(f"{vms_ok} VM{'s' if vms_ok > 1 else ''}")
if cts_ok > 0:
parts.append(f"{cts_ok} CT{'s' if cts_ok > 1 else ''}")
# List names
names = []
for vm in report.get('vms_started', []):
names.append(f"{vm['name']} ({vm['vmid']})")
for ct in report.get('cts_started', []):
names.append(f"{ct['name']} ({ct['vmid']})")
line = f"{' and '.join(parts)} started"
if names and len(names) <= 5:
line += f": {', '.join(names)}"
elif names:
line += f": {', '.join(names[:3])}... (+{len(names)-3} more)"
lines.append(line)
# Failed VMs/CTs
if total_fail > 0:
for vm in report.get('vms_failed', []):
lines.append(f"VM failed: {vm['name']} - {vm.get('reason', 'unknown error')}")
for ct in report.get('cts_failed', []):
lines.append(f"CT failed: {ct['name']} - {ct.get('reason', 'unknown error')}")
# Storage issues
if not report.get('storage_ok', True):
unavailable = report.get('storage_unavailable', [])
if unavailable:
names = [s['name'] for s in unavailable]
lines.append(f"Storage: {len(unavailable)} unavailable ({', '.join(names[:3])})")
# Service issues
if not report.get('services_ok', True):
failed = report.get('services_failed', [])
if failed:
names = [s['name'] for s in failed]
lines.append(f"Services: {len(failed)} failed ({', '.join(names[:3])})")
return '\n'.join(lines)
# ─── For backwards compatibility ─────────────────────────────────────────────
# Expose constants for external use
GRACE_CATEGORIES = STARTUP_GRACE_CATEGORIES
+74
View File
@@ -112,6 +112,50 @@ export interface UPS {
[key: string]: any
}
export interface CoralTPU {
type: "pcie" | "usb"
name: string
vendor: string
vendor_id: string
device_id: string
slot?: string // PCIe only, e.g. "0000:0c:00.0"
bus_device?: string // USB only, e.g. "002:007"
form_factor?: string // "M.2 / Mini PCIe (x1)" | "USB Accelerator" | ...
interface_speed?: string // "PCIe 2.5GT/s x1" | "USB 3.0" | ...
kernel_driver?: string | null
usb_driver?: string | null
kernel_modules?: {
gasket: boolean
apex: boolean
}
device_nodes?: string[]
edgetpu_runtime?: string
programmed?: boolean // USB only: runtime has interacted with the device
drivers_ready: boolean
// Thermal data — PCIe/M.2 only (apex driver). Always null for USB Coral.
temperature?: number | null // °C current die temperature
temperature_trips?: number[] | null // trip_point0/1/2_temp, ordered warn→critical
thermal_warnings?: Array<{
name: string // e.g. "hw_temp_warn1"
threshold_c: number | null
enabled: boolean
}> | null
}
export interface UsbDevice {
bus_device: string // "002:007"
vendor_id: string // "18d1"
product_id: string // "9302"
vendor: string
name: string
class_code: string // "ff"
class_label: string // "Vendor Specific", "HID", "Mass Storage", ...
speed_mbps: number
speed_label: string // "USB 3.0" | "USB 2.0" | ...
serial?: string
driver?: string
}
export interface GPU {
slot: string
name: string
@@ -146,6 +190,34 @@ export interface GPU {
}>
has_monitoring_tool?: boolean
note?: string
// SR-IOV state — populated from sysfs (physfn symlink + sriov_{num,total}vfs).
// "vf" — this slot is a Virtual Function; sriov_physfn is its PF.
// "pf-active" — this slot is a Physical Function with sriov_vf_count > 0.
// "pf-idle" — SR-IOV capable PF but no VFs currently active.
// "none" — not involved in SR-IOV.
sriov_role?: "vf" | "pf-active" | "pf-idle" | "none"
sriov_physfn?: string
sriov_vf_count?: number
sriov_totalvfs?: number
// SR-IOV detail — only populated by the /api/gpu/<slot>/realtime endpoint
// when the modal is open (scanning guest configs is too expensive for the
// hardware snapshot path).
sriov_vfs?: SriovVfDetail[] // filled when role === "pf-active"
sriov_consumer?: SriovConsumer | null // filled when role === "vf"
}
export interface SriovVfDetail {
bdf: string // e.g. "0000:00:02.1"
driver: string // current kernel driver (i915, vfio-pci, ...)
render_node: string // "" when the VF does not expose a DRM node
consumer: SriovConsumer | null // which guest is using this VF, if any
}
export interface SriovConsumer {
type: "vm" | "lxc"
id: string // VMID or CTID
name: string // VM name / LXC hostname
running: boolean
}
export interface DiskHardwareInfo {
@@ -208,6 +280,8 @@ export interface HardwareData {
fans?: Fan[]
power_supplies?: PowerSupply[]
ups?: UPS | UPS[]
coral_tpus?: CoralTPU[]
usb_devices?: UsbDevice[]
}
export const fetcher = async (url: string) => {
+438 -1
View File
@@ -1,3 +1,440 @@
## 2026-04-20
### New version ProxMenux v1.2.1 — *SR-IOV Awareness & GPU Passthrough Hardening*
Targeted release on top of **v1.2.0** addressing three community-reported areas that needed fixing before the next stable cycle: full SR-IOV awareness across the GPU/PCI subsystem, robust handling of GPU + audio companions during passthrough attach and detach (Intel iGPU with chipset audio, discrete cards with HDMI audio, mixed-GPU VMs), and compatibility fixes for the AI notification providers (OpenAI-compatible custom endpoints such as LiteLLM/MLX/LM Studio, OpenAI reasoning models, and Gemini 2.5+/3.x thinking models). Also bundles quality-of-life fixes in the NVIDIA installer, the disk health monitor, and the LXC lifecycle helpers used by the passthrough wizards.
---
## 🎛️ SR-IOV Awareness Across the GPU Subsystem
Intel `i915-sriov-dkms` and AMD MxGPU split a GPU's Physical Function (PF) into Virtual Functions (VFs) that can be assigned independently to LXCs and VMs. Previously ProxMenux had zero SR-IOV awareness: it treated VFs and PFs identically, which could rewrite `vfio.conf` with the PF's vendor:device ID, collapse the VF tree on the next boot, and leave users unable to start their guests. Every path that could disrupt an active VF tree has been audited and hardened.
### Detection helpers
- New `_pci_is_vf`, `_pci_has_active_vfs`, `_pci_sriov_role`, `_pci_sriov_filter_array` in `scripts/global/pci_passthrough_helpers.sh`
- HTTP/JSON equivalents in the Flask GPU route — the Monitor UI reads VF/PF state directly from sysfs (`physfn`, `sriov_totalvfs`, `sriov_numvfs`, `virtfn*`)
### Pre-start hook (`gpu_hook_guard_helpers.sh`)
The VM pre-start guard now recognises Virtual Functions. Both the slot-only syntax branch (which used to iterate every function of the slot and demand `vfio-pci` everywhere) and the full-BDF branch skip VFs, so Proxmox can perform its per-VF vfio-pci rebind as usual. The false "GPU passthrough device is not ready" block on SR-IOV VMs is gone.
### Mode-switch scripts refuse SR-IOV operations
`switch_gpu_mode.sh`, `switch_gpu_mode_direct.sh`, `add_gpu_vm.sh`, `add_gpu_lxc.sh`, `vm_creator.sh`, `synology.sh`, `zimaos.sh` and `add_controller_nvme_vm.sh` all reject VFs and PFs with active VFs before touching host configuration. A clear "SR-IOV Configuration Detected" dialog explains the situation. For wizards invoked mid-flow (VM creators) the message is delivered through `whiptail` so it interrupts cleanly, followed by a per-device `msg_warn` line for the log trail.
### New "SR-IOV active" state in the Monitor UI
The GPU card in the Hardware page gains a third visual state with a dedicated teal colour, an in-line `SR-IOV ×N` pill (or `SR-IOV VF` for a Virtual Function), and dashed/faded LXC and VM branches. The Edit button is hidden because the state is hardware-managed.
![SR-IOV active card and modal](https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/sriov-indicator.png)
### Modal dashboard for SR-IOV GPUs
Opening the modal for a Physical Function with active VFs now shows:
- Aggregate-metrics banner ("Metrics below reflect the Physical Function, aggregate across N VFs")
- Normal GPU real-time telemetry for the PF
- A **Virtual Functions** table, one row per VF, with the current driver (`i915`, `vfio-pci`, unbound) and the specific VM or LXC that consumes it, including running/stopped state — consumers are discovered by cross-referencing `hostpci` entries and `/dev/dri/renderDN` mount lines against the VF's BDF and DRM render node
Opening the modal for a Virtual Function shows its parent PF (clickable to navigate back to the PF's modal), current driver, and consumer.
### VM Conflict Policy popup no longer fires for SR-IOV VFs
The regex in `detect_affected_vms_for_selected` matched the slot (`00:02`) against VMs that had a VF (`00:02.1`) assigned, producing a confusing "Keep GPU in VM config" dialog. With the SR-IOV gate upstream, the flow never reaches that code path for SR-IOV slots.
---
## 🔊 GPU + Audio Passthrough — Full Lifecycle Hardening
A round of fixes around how GPU passthrough handles its audio companion device. Previously, only the `.1` sibling of a discrete GPU was picked up automatically; Intel iGPU passthrough to a VM — where the audio lives separately on the chipset at `00:1f.3` and not at `00:02.1` — was silently skipped. On detach, the old `sed` that wiped hostpci lines by slot substring could also remove an unrelated GPU whose BDF happened to contain the search slot as a substring (e.g. slot `00:02` matching inside `0000:02:00.0`). Both paths are now robust.
### iGPU audio-companion checklist on attach
`add_gpu_vm.sh::detect_optional_gpu_audio` keeps the auto-include fast path for the classic `.1` sibling (discrete NVIDIA / AMD with HDMI audio on the card). When no `.1` audio exists, the script now:
- Scans sysfs for every PCI audio controller on the host
- Skips anything already covered by the GPU's IOMMU group
- Asks the user via a `_pmx_checklist` (`dialog` in standalone mode, `whiptail` in wizard mode called from `vm_creator`/`synology`/`zimaos`) which audio controllers to pass through alongside the GPU
- Displays each entry with its current host driver (`snd_hda_intel`, `snd_hda_codec_*`, etc.) so the decision is informed
- Defaults to **none** — the user actively opts in
### Orphan audio cascade on detach
When the user picks "Remove GPU from VM config" during a mode switch, the scripts now follow up with a targeted cleanup:
- `switch_gpu_mode.sh`, `switch_gpu_mode_direct.sh` and `add_gpu_vm.sh::cleanup_vm_config` (source-VM cleanup on the "move GPU" flow) all call the shared helper `_vm_list_orphan_audio_hostpci`
- The helper uses a two-pass scan of the VM config: pass 1 records slot bases of display/3D hostpci entries; pass 2 classifies audio entries and **skips any audio whose slot still has a display sibling in the same VM** — protecting the HDMI audio of other dGPUs left in the VM
- Previously the bare substring match would have flagged NVIDIA's `02:00.1` as orphan when detaching an Intel iGPU at `00:02.0`
- The interactive switch flow confirms removals with a `dialog` checklist (default ON). The web variant auto-removes without prompting — the runner has no good way to render a checklist — and logs every BDF it touched
### vfio.conf cascade extension
For each audio removed by the cascade, the switch-mode scripts now check whether its BDF is still referenced by any other VM via `_pci_bdf_in_any_vm`. If nothing else uses it, the `vendor:device` is appended to `SELECTED_IOMMU_IDS` before the `/etc/modprobe.d/vfio.conf` update runs. That closes the loop for the Intel iGPU case: `8086:51c8` (PCH HD Audio) is now pulled from `vfio.conf` alongside `8086:46a3` (iGPU) when both leave VM mode and no other VM references them. If another VM still uses the audio, the ID is deliberately kept — no breaking side effects on other VMs. `add_gpu_vm.sh` does NOT extend the cleanup in the *move* flow, because the GPU is still in use elsewhere and its IDs must remain.
### Precise hostpci removal regex
Every inline `sed` used to detach a GPU from a VM config previously matched the slot as a free substring:
```
/^hostpci[0-9]+:.*${slot}/d
```
For `slot=00:02` that pattern matches the substring inside `0000:02:00.0` (an unrelated NVIDIA dGPU at slot `02:00`) and would wipe both cards. The fix anchors the match to the real BDF shape:
```
/^hostpci[0-9]+:[[:space:]]*(0000:)?${slot}\.[0-7]([,[:space:]]|$)/d
```
Applied in `switch_gpu_mode.sh`, `switch_gpu_mode_direct.sh` and `add_gpu_vm.sh::cleanup_vm_config`. The awk-based helper in `vm_storage_helpers.sh::_remove_pci_slot_from_vm_config` (used by the NVMe wizards) already used the correct pattern and did not need changes.
---
## 🤖 AI Provider Compatibility — OpenAI-Compatible, Reasoning & Thinking Models
Three coordinated fixes that unblock model categories previously rejected by the notification enhancement pipeline.
### OpenAI-compatible endpoints
LiteLLM, MLX, LM Studio, vLLM, LocalAI, Ollama-proxy — the provider's `list_models()` used to require `"gpt"` in every model name, so local setups serving `mlx-community/...`, `Qwen3-...`, `mistralai/...` saw an empty model list. When a Custom Base URL is set, the `"gpt"` substring check is now skipped and `EXCLUDED_PATTERNS` (embeddings, whisper, tts, dall-e) is the only filter. The Flask route layer also stops intersecting the result against `verified_ai_models.json` for custom endpoints — the verified list only describes OpenAI's official model IDs and was erasing every local model the user actually served.
### OpenAI reasoning models
`o1`, `o3`, `o3-mini`, `o4-mini`, `gpt-5`, `gpt-5-mini`, `gpt-5.1`, `gpt-5.2-pro`, `gpt-5.4-nano`, etc. (excluding the `*-chat-latest` variants) use a stricter API contract: `max_completion_tokens` instead of `max_tokens`, no `temperature`. Sending the classic chat parameters produced HTTP 400 Bad Request for every one of them. A detector in `openai_provider.py` now branches the payload accordingly and sets `reasoning_effort: "minimal"` — by default these models spend their output budget on internal reasoning and return an empty reply for the short notification-translation request.
### Gemini 2.5+ / 3.x thinking models
`gemini-2.5-flash`, `2.5-pro`, `gemini-3-pro-preview`, `gemini-3.1-pro-preview`, etc. have internal "thinking" enabled by default. With the small token budget used for notification enrichment (≤250 tokens), the thinking budget consumed the entire allowance and the model returned empty output with `finishReason: MAX_TOKENS`. `gemini_provider.py` now sets `thinkingConfig.thinkingBudget: 0` for non-`lite` variants of 2.5+ and 3.x, so the available tokens go to the user-visible response. Lite variants (no thinking enabled) are untouched.
---
## 📋 Verified AI Models Refresh
`AppImage/config/verified_ai_models.json` refreshed for the providers re-tested against live APIs. The new private maintenance tool (kept out of the AppImage) re-runs a standardised translate+explain test against every model each provider advertises, classifies pass / warn / fail, and prints a ready-to-paste JSON snippet. Re-run before each ProxMenux release to keep the list current.
| Provider | New recommended | Notes |
|----------|-----------------|-------|
| **OpenAI** | `gpt-4.1-nano` | `gpt-4.1-nano`, `gpt-4.1-mini`, `gpt-4o-mini`, `gpt-4.1`, `gpt-4o`, `gpt-5-chat-latest`, plus `gpt-5.4-nano` / `gpt-5.4-mini` from 2026-03. Dated snapshots and legacy models excluded. Reasoning models supported by code but not listed by default — slower / costlier without improving notification quality |
| **Gemini** | `gemini-2.5-flash-lite` | `gemini-2.5-flash-lite`, `gemini-2.5-flash` (works now), `gemini-3-flash-preview`. `latest` aliases intentionally omitted — resolved to different models across runs and produced timeouts in some regions. Pro variants reject `thinkingBudget=0` and are overkill for notification translation |
| Groq / Anthropic / OpenRouter | *unchanged* | Marked with a `_note` — will be re-verified as soon as keys are available |
---
## 🩺 Disk Health Monitor — Observation Persistence in the Journal Watcher
A latent bug in `notification_events.py::_check_disk_io` meant real-time kernel I/O errors caught by the journal watcher were surfaced as notifications but never written to the permanent per-disk observations table. In practice the parallel periodic dmesg scan usually recorded the observation shortly after, but under timing edge cases (stale dmesg window, service restart right after the error, buffer rotation) the observation could go missing.
The journal watcher now records the observation before the 24h notification cooldown gate, using the same family-based signature classification (`io_<disk>_ata_connection_error`, `io_<disk>_block_io_error`, `io_<disk>_ata_failed_command`) as the periodic scan. Both paths now deduplicate into the same row via the UPSERT in `record_disk_observation`, so occurrence counts are accurate regardless of which detector fired first.
---
## 🔧 NVIDIA Installer Polish
### `lsmod` race condition silenced
During reinstall, the module-unload verification in `unload_nvidia_modules` produced spurious `lsmod: ERROR: could not open '/sys/module/nvidia_uvm/holders'` errors because `lsmod` reads `/proc/modules` and then opens each module's `holders/` directory, which disappears transiently while the module is being removed. The check now reads `/proc/modules` directly and inserts short sleeps to let the kernel finalise the unload before re-verifying. Applied in the same spirit to the four other `lsmod` call sites in the script.
### Dialog → whiptail in the LXC update flow
The "Insufficient Disk Space" message in `update_lxc_nvidia` and the "Update NVIDIA in LXC Containers" confirmation now use `whiptail`-style dialogs consistent with the rest of the in-flow messaging, avoiding the visual break that `dialog --msgbox` caused when rendered mid-sequence in the container-update phase.
---
## 🧵 LXC Lifecycle Helper — Timeout-Safe Stop
A plain `pct stop` can hang indefinitely when the container has a stale lock from a previous aborted operation, when processes inside (Plex, Jellyfin, databases) ignore TERM and fall into uninterruptible-sleep while the GPU they were using is yanked out, or when `pct shutdown --timeout` is not enforced by pct itself. Field reports of 5+ min waits during GPU mode switches made this a real UX hazard.
New shared helper `_pmx_stop_lxc <ctid> [log_file]` in `pci_passthrough_helpers.sh`:
1. Returns 0 immediately if the container is not running
2. Best-effort `pct unlock` (silent on failure) — most containers aren't actually locked; we only care about the cases where they are
3. `pct shutdown --forceStop 1 --timeout 30` wrapped in an external `timeout 45` so we never wait longer than that for the graceful phase, even if pct stalls on backend I/O
4. Verifies actual status via `pct status` — pct can return non-zero while the container is in fact stopped
5. If still running, `pct stop` wrapped in `timeout 60`. Verify again
6. Returns 1 only if the container is truly stuck after ~107 s total — the wizard moves on instead of hanging
Wired into the three GPU-mode paths that stop LXCs during a switch: `switch_gpu_mode.sh`, `switch_gpu_mode_direct.sh`, and `add_gpu_vm.sh::cleanup_lxc_configs`.
---
## ⚙️ `add_gpu_vm.sh` Reboot Prompt Stability
The final "Reboot Required" prompt of the GPU-to-VM assignment wizard was triggering spurious reboots in certain menu-chain invocations (`menu``main_menu``hw_grafics_menu``add_gpu_vm`). With the `_pmx_yesno` helper it sometimes returned exit 0 without the user having actually confirmed, calling `reboot` immediately. With a bare `read` in its place the process would get SIGTTIN-suspended when the menu chain detached the script from the terminal's foreground process group, leaving `[N]+ Stopped menu` on the parent shell with no chance to answer.
The prompt now uses `whiptail --yesno` invoked directly (the pattern verified to work reliably in that menu chain) and inserts a `Press Enter to continue ... read -r` pause between the "Yes" answer and the actual `reboot` call — so an accidental Enter on the confirm button cannot trigger an immediate reboot without a visible confirmation step first.
---
### 🙏 Thanks
Thank you to the users who reported the SR-IOV, LiteLLM/MLX and GPU + audio cases — these improvements exist because of detailed, reproducible reports. Feel free to keep reporting issues or suggesting improvements 🙌.
---
## 2026-04-17
### New version ProxMenux v1.2.0 — *AI-Enhanced Monitoring*
![ProxMenux AI](https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/ProxMenux_ai.png)
This release is the culmination of the v1.1.9.1 → v1.1.9.6 beta cycle and introduces the biggest evolution of **ProxMenux Monitor** to date: AI-enhanced notifications, a redesigned multi-channel notification system, a fully reworked hardware and storage experience, and broad performance improvements across the monitoring stack. It also consolidates all recent work on the Storage, Hardware and GPU/TPU scripts.
---
## 🤖 ProxMenux Monitor — AI-Enhanced Notifications
Notifications can now be enhanced using AI to generate clear, contextual messages instead of raw technical output.
Example — instead of `backup completed exitcode=0 size=2.3GB`, AI produces: *"The web server backup completed successfully. Size: 2.3GB"*.
### What AI does
- Transforms technical notifications into readable messages
- Translates to your preferred language
- Lets you choose detail level: minimal, standard, or detailed
- Works with Telegram, Discord, Email, Pushover, and Webhooks
### What AI does NOT do
- It is **not** a chatbot or assistant
- It does **not** analyze your system or make decisions
- It does **not** have access to data beyond the notification being processed
- It does **not** execute commands or modify the server
- It does **not** store history or learn from your data
### Multi-Provider Support
Choose between 6 AI providers, each with its own API key stored independently:
- **Groq** — fast inference, generous free tier
- **Google Gemini** — excellent quality/price ratio, free tier available
- **OpenAI** — industry standard
- **Anthropic Claude** — excellent for writing and translation
- **OpenRouter** — 300+ models with a single API key
- **Ollama** — 100% local execution, no internet required
### Verified AI Models
A curated list of models (`verified_ai_models.json`) tested specifically for notification enhancement.
- **Hybrid verification**: the system fetches provider-side models and filters to only show those tested to work correctly
- **Per-Provider Model Memory**: selected model is saved per provider, so switching providers preserves each choice
- **Daily verification**: background task checks model availability and auto-migrates to a verified alternative if the current model disappears
- **Incompatible models excluded**: Whisper, TTS, image/video, embeddings, guard models, etc. are filtered out per provider
| Provider | Recommended | Also Verified |
|----------|-------------|---------------|
| Gemini | gemini-2.5-flash-lite | gemini-flash-lite-latest |
| OpenAI | gpt-4o-mini | gpt-4.1-mini |
| Groq | llama-3.3-70b-versatile | llama-3.1-70b-versatile, llama-3.1-8b-instant, llama3-70b-8192, llama3-8b-8192, mixtral-8x7b-32768, gemma2-9b-it |
| Anthropic | claude-3-5-haiku-latest | claude-3-5-sonnet-latest, claude-3-opus-latest |
| OpenRouter | meta-llama/llama-3.3-70b-instruct | meta-llama/llama-3.1-70b-instruct, anthropic/claude-3.5-haiku, google/gemini-flash-2.5-flash-lite, openai/gpt-4o-mini, mistralai/mixtral-8x7b-instruct |
| Ollama | (all local models) | No filtering — shows all installed models |
### Custom AI Prompts
Advanced users can define their own prompt for full control over formatting and translation.
- **Prompt Mode selector** — Default Prompt or Custom Prompt
- **Export / Import** — save and share custom prompts across installations
- **Example Template** — starting point to build your own prompt
- **Community Prompts** — direct link to GitHub Discussions to share templates
- Language selector is hidden in Custom Prompt mode (you define the output language in the prompt itself)
### Enriched Context
- System **uptime** is included only for error/warning events (not informational ones) — helps distinguish startup vs runtime errors
- **Event frequency** tracking — indicates recurring vs one-time issues
- **SMART disk health** data is passed for disk-related errors
- **Known Proxmox errors** database improves diagnosis accuracy
- Clearer prompt instructions to prevent AI hallucinations
---
## 📨 Notification System Redesign
- **Multi-Channel Architecture** — Telegram, Discord, Pushover, Email, and Webhook channels running simultaneously
- **Per-Event Configuration** — enable/disable specific event types per channel
- **Channel Overrides** — customize notification behaviour per channel
- **Secure Webhook Endpoint** — external systems can send authenticated notifications
- **Encrypted Storage** — API keys and sensitive data stored encrypted
- **Queue-Based Processing** — background worker with automatic retry for failed notifications
- **SQLite-Based Config Storage** — replaces file-based config for reliability
### Telegram Topics Support
Send notifications to a specific topic inside groups with Topics enabled.
- New **Topic ID** field on the Telegram channel
- Automatic detection of topic-enabled groups
- Fully backwards compatible
### ProxMenux Update Notifications
The Monitor now detects when a new ProxMenux version is released.
- **Dual-channel** — monitors both stable (`version.txt`) and beta (`beta_version.txt`)
- **GitHub integration** — compares local vs remote versions
- **Dashboard Update Indicator** — the ProxMenux logo changes to an update variant when a new version is detected (non-intrusive, no popups)
- **Persistent state** — status stored in `config.json`, reset by update scripts
- Single toggle in Settings controls both channels (enabled by default)
---
## 🖥️ Hardware Panel — Expanded Detection
The Hardware page has been significantly expanded, with better detection and richer per-device detail.
- **SCSI / SAS / RAID Controllers** — model, driver and PCI slot shown in the storage controllers section
- **PCIe Link Speed Detection** — NVMe drives show current link speed (PCIe generation and lane width), making it easy to spot drives underperforming due to limited slot bandwidth
- **Enhanced Disk Detail Modal** — NVMe, SATA, SAS, and USB drives now expose their specific fields (PCIe link info, SAS version/speed, interface type) instead of a generic view
- **Smarter Disk Type Recognition** — uniform labelling for NVMe SSDs, SATA SSDs, HDDs and removable disks
- **Hardware Info Caching** (`lspci`, `lspci -vmm`) — 5 min cache avoids repeated scans for data that doesn't change
---
## 💽 Storage Overview — Health, Observations, Exclusions
The Storage Overview has been reworked around real-time state and user-controlled tracking.
### Disk Health Status Alignment
- Badges now reflect the **current** SMART state reported by Proxmox, not a historical worst value
- **Observations preserved** — historical findings remain accessible via the "X obs." badge
- **Automatic recovery** — when SMART reports healthy again, the disk immediately shows **Healthy**
- Removed the old `worst_health` tracking that required manual clearing
### Disk Registry Improvements
- **Smart serial lookup** — when a serial is unknown the system checks for an existing entry with a serial before inserting a new one
- **No more duplicates** — prevents separate entries for the same disk appearing with/without a serial
- **USB disk support** — handles USB drives that may appear under different device names between reboots
### Storage and Network Interface Exclusions
- **Storage Exclusions** section — exclude drives from health monitoring and notifications
- **Network Interface Exclusions** — new section for excluding interfaces (bridges `vmbr`, bonds, physical NICs, VLANs) from health and notifications; ideal for intentionally disabled interfaces that would otherwise generate false alerts
- **Separate toggles** per item for Health monitoring and Notifications
### Disk Detection Robustness
- **Power-On-Hours validation** — detects and corrects absurdly large values (billions of hours) on drives with non-standard SMART encoding
- **Intelligent bit masking** — extracts the correct value from drives that pack extra info into high bytes
- **Graceful fallback** — shows "N/A" instead of impossible numbers when data cannot be parsed
---
## 🧠 Health Monitor & Error Lifecycle
### Stale Error Cleanup
Errors for resources that no longer exist are now resolved automatically.
- **Deleted VMs / CTs** — related errors auto-resolve when the resource is removed
- **Removed Disks** — errors for disconnected USB or hot-swap drives are cleaned up
- **Cluster Changes** — cluster errors clear when a node leaves the cluster
- **Log Patterns** — log-based errors auto-resolve after 48 hours without recurrence
- **Security Updates** — update notifications auto-resolve after 7 days
### Database Migration System
- **Automatic column detection** — missing columns are added on startup
- **Schema compatibility** — works with both old and new column naming conventions
- **Backwards compatible** — databases from older ProxMenux versions are supported
- **Graceful migration** — no data loss during schema updates
---
## 🧩 VM / CT Detail Modal
The VM/CT detail modal has been completely redesigned for usability.
- **Tabbed Navigation***Overview* (general information, status, resource usage) and *Backups* (dedicated history)
- **Visual Enhancements** — icons throughout, improved hierarchy and spacing, better VM vs CT distinction
- **Mobile Responsiveness** — adapts correctly to mobile screens in both webapp and direct browser access, no more overflow on small devices
- **Touch-Friendly Controls** — larger buttons and spacing
### Secure Gateway Modal
- **Scrollable storage list** when many destinations are available
- Mobile-adapted layout and improved visual hierarchy
### Terminal Connection
- **Reconnection loop fix** that was affecting mobile devices
- Improved WebSocket handling for mobile browsers
- More graceful connection timeout recovery
### Fail2ban & Lynis Management
- **Delete buttons** added in Settings for both tools
- Clean removal of packages and configuration files
- Confirmation dialog to prevent accidental deletion
---
## ⚡ Performance Optimizations
Major reduction in CPU usage and elimination of spikes on the Monitor.
### Staggered Polling Intervals
Collectors now run on offset schedules to prevent simultaneous execution:
| Collector | Schedule |
|-----------|----------|
| CPU sampling | Every 30s at offset 0 |
| Temperature sampling | Every 15s at offset 7s |
| Latency pings | Every 60s at offset 25s |
| Temperature record | Every 60s at offset 40s |
| Health collector | Starts at 55s offset |
| Notification polling | Health=10s, Updates=30s, ProxMenux=45s, AI=50s |
### Cached System Information
Expensive commands now cached to reduce repeated execution:
| Command | Cache TTL | Impact |
|---------|-----------|--------|
| `pveversion` | 6 hours | Eliminates 23%+ CPU spikes from Perl execution |
| `apt list --upgradable` | 6 hours | Reduces package manager queries |
| `pvesh get /cluster/resources` | 30 seconds | 6 API calls per request reduced to 1 |
| `sensors` | 10 seconds | Temperature readings cached between polls |
| `smartctl` (SMART health) | 30 minutes | Disk health checks reduced from every 5 min |
| `lspci` / `lspci -vmm` | 5 minutes | Hardware info cached (doesn't change) |
| `journalctl --since 24h` | 1 hour | Login attempts count cached (92% reduction) |
### Increased journalctl Timeouts
Prevents timeout cascades under system load:
| Query Type | Before | After |
|------------|--------|-------|
| Short-term (3-10 min) | 3s | 10s |
| Medium-term (1 hour) | 5s | 15s |
| Long-term (24 hours) | 5s | 20s |
### Reduced Polling Frequency
- `TaskWatcher` interval raised from **2s → 5s** (60% fewer checks)
### GitHub Actions
- All workflow actions upgraded to **v6** for Node.js 24 compatibility
- Deprecation warnings eliminated in CI/CD
---
## 🧰 Scripts — Storage, Hardware and GPU/TPU Work
This release also consolidates significant work on the core ProxMenux scripts.
### Storage scripts
- **SMART scheduled tests** and improved interactive SMART test workflow with clearer progress feedback
- **Disk formatting** (`format-disk.sh`) rework with safer device selection and dialog flow
- **Disk passthrough** for VMs and CTs — updated device enumeration, serial-based identification, and cleaner teardown
- **NVMe controller addition for VMs** — improved controller type selection and slot detection
- **Import disk image** — smoother path validation and progress reporting
- **Disk & storage manual guide** refresh
### Hardware / GPU / TPU scripts
- **Coral TPU installer** updated for current kernels and udev rules (Proxmox VE 8 & VE 9)
- **NVIDIA installer** — cleaner driver installation, kernel header handling, and VM/LXC attachment flow
- **GPU mode switch** (direct and interactive variants) — safer switching between iGPU modes
- **Add GPU to VM / LXC** — unified selection dialogs and permission handling
- **Intel / AMD GPU tools** kept in sync with the new shared patterns
- **Hardware & graphics menu** restructured for consistency with the rest of ProxMenux
## 2026-03-14
### New version v1.1.9 — *Helper Scripts Catalog Rebuilt*
### Changed
- **Helper Scripts Menu — Full Catalog Rebuild**
The Helper Scripts catalog has been completely rebuilt to adapt to the new data architecture of the [Community Scripts](https://community-scripts.github.io/ProxmoxVE/) project.
The previous implementation relied on a `metadata.json` file that no longer exists in the upstream repository. The catalog now connects directly to the **PocketBase API** (`db.community-scripts.org`), which is the new official data source for the project.
A new GitHub Actions workflow generates a local `helpers_cache.json` index that replaces the old metadata dependency. This new cache is richer, more structured, and includes:
- Script type, slug, description, notes, and default credentials
- OS variants per script (e.g. Debian, Alpine) — each shown as a separate selectable option in the menu
- Direct GitHub URL and **Mirror URL** (`git.community-scripts.org`) for every script
- Category names embedded directly in the cache — no external requests needed to build the menu
- Additional metadata: default port, website, logo, update support, ARM availability
Scripts that support multiple OS variants (e.g. Docker with Alpine and Debian) now correctly show **one entry per OS**, each with its own GitHub and Mirror download option — restoring the behavior that existed before the upstream migration.
---
### 🎖 Special Acknowledgment
This update would not have been possible without the openness and collaboration of the **Community Scripts** maintainers.
When the upstream metadata structure changed and broke the ProxMenux catalog, the maintainers responded quickly, explained the new architecture in detail, and provided all the information needed to rebuild the integration cleanly.
Special thanks to:
- **MickLeskCanbiZ ([@MickLesk](https://github.com/MickLesk))** — for documenting the new script path structure by type and slug, and for the clear and direct technical guidance.
- **Michel Roegl-Brunner ([@michelroegl-brunner](https://github.com/michelroegl-brunner))** — for explaining the new PocketBase collections structure (`script_scripts`, `script_categories`).
The Helper Scripts project is an extraordinary resource for the Proxmox community. The scripts belong entirely to their authors and maintainers — ProxMenux simply offers a guided way to discover and launch them. All credit goes to the community behind [community-scripts/ProxmoxVE](https://github.com/community-scripts/ProxmoxVE).
## 2025-09-18
### New version v1.1.8 — *ProxMenux Offline Mode*
@@ -625,4 +1062,4 @@ Disks now display tags like ⚠ In use, ⚠ RAID, ⚠ LVM, or ⚠ ZFS, making it
## [1.0.0] - 2024-12-18
### Added
- Initial release of **ProxMenux**.
- Created a script to add **Coral TPU drivers** to Proxmox.
- Created a script to add **Coral TPU drivers** to Proxmox.
+5 -2
View File
@@ -144,10 +144,13 @@ The following dependencies are installed automatically during setup:
| `python3` + `python3-venv` | Translation support *(Translation version only)* |
| `googletrans` | Google Translate library *(Translation version only)* |
<br>
> **🛡️ Security Note / VirusTotal False Positive**
> If you scan the raw installation URL on VirusTotal, you might see a 1/95 detection by heuristic engines like *Chong Lua Dao*. This is a **known false positive**. Because this script uses the standard `curl | bash` installation pattern and downloads legitimate binaries (like `jq` from its official GitHub release), overly aggressive scanners flag the *behavior*. The script is 100% open source and safe to review. You can read more about this in [Issue #162](https://github.com/MacRimi/ProxMenux/issues/162).
---
## ⭐ Support the Project!
If you find **ProxMenux** useful, consider giving it a ⭐ on GitHub to help others discover it!
@@ -160,7 +163,6 @@ Contributions, bug reports and feature suggestions are welcome!
- 💡 [Suggest a feature](https://github.com/MacRimi/ProxMenux/discussions)
- 🔀 [Submit a pull request](https://github.com/MacRimi/ProxMenux/pulls)
If you find ProxMenux useful, consider giving it a ⭐ on GitHub — it helps others discover the project!
---
@@ -171,6 +173,7 @@ If you find ProxMenux useful, consider giving it a ⭐ on GitHub — it helps ot
[![Star History Chart](https://api.star-history.com/svg?repos=MacRimi/ProxMenux&type=Date)](https://www.star-history.com/#MacRimi/ProxMenux&Date)
<div style="display: flex; justify-content: center; align-items: center;">
<a href="https://ko-fi.com/G2G313ECAN" target="_blank" style="display: flex; align-items: center; text-decoration: none;">
<img src="https://raw.githubusercontent.com/MacRimi/HWEncoderX/main/images/kofi.png" alt="Support me on Ko-fi" style="width:140px; margin-right:40px;"/>
-720
View File
@@ -1,720 +0,0 @@
# base-packages.txt - Generated on 2025-05-15 21:15:29
# Proxmox Version: pve-manager/8.4.1/ (running kernel: 6.8.12-9-pve)
adduser
apparmor
apt
apt-listchanges
apt-utils
attr
base-files
base-passwd
bash
bash-completion
bc
bind9-dnsutils
bind9-host
bind9-libs
binutils
binutils-common
binutils-x86-64-linux-gnu
bridge-utils
bsdextrautils
bsd-mailx
bsdutils
btrfs-progs
busybox
bzip2
ca-certificates
ceph-common
ceph-fuse
chrony
cifs-utils
console-setup
console-setup-linux
coreutils
corosync
cpio
criu
cron
cron-daemon-common
cstream
curl
dash
dbus
dbus-bin
dbus-daemon
dbus-session-bus-common
dbus-system-bus-common
debconf
debconf-i18n
debian-archive-keyring
debian-faq
debianutils
dialog
diffutils
dirmngr
distro-info-data
dmeventd
dmidecode
dmsetup
doc-debian
dosfstools
dpkg
dtach
e2fsprogs
ebtables
efibootmgr
eject
ethtool
faketime
fdisk
fdutils
file
findutils
fontconfig
fontconfig-config
fonts-dejavu-core
fonts-font-awesome
fonts-font-logos
fonts-glyphicons-halflings
frr
frr-pythontools
fuse
gcc-12-base
gdisk
genisoimage
gettext-base
glusterfs-client
glusterfs-common
gnupg
gnupg-l10n
gnupg-utils
gnutls-bin
gpg
gpg-agent
gpgconf
gpgsm
gpgv
gpg-wks-client
gpg-wks-server
grep
groff-base
grub2-common
grub-common
grub-efi-amd64
grub-efi-amd64-bin
grub-efi-amd64-signed
grub-pc-bin
gzip
hdparm
hostname
ifupdown2
inetutils-telnet
init
initramfs-tools
initramfs-tools-core
init-system-helpers
iproute2
ipset
iptables
iputils-ping
isc-dhcp-client
isc-dhcp-common
iso-codes
jq
kbd
keyboard-configuration
keyutils
klibc-utils
kmod
krb5-locales
ksm-control-daemon
less
libacl1
libaio1
libanyevent-http-perl
libanyevent-perl
libapparmor1
libappconfig-perl
libapt-pkg6.0
libapt-pkg-perl
libarchive13
libargon2-1
libasound2
libasound2-data
libassuan0
libasyncns0
libattr1
libaudit1
libaudit-common
libauthen-pam-perl
libavahi-client3
libavahi-common3
libavahi-common-data
libbabeltrace1
libbinutils
libblas3
libblkid1
libbpf1
libbrotli1
libbsd0
libbytes-random-secure-perl
libbz2-1.0
libc6
libcairo2
libcap2
libcap2-bin
libcap-ng0
libc-ares2
libc-bin
libcbor0.8
libcephfs2
libcfg7
libc-l10n
libclone-perl
libcmap4
libcom-err2
libcommon-sense-perl
libconvert-asn1-perl
libcorosync-common4
libcpg4
libcrypt1
libcrypt-openssl-bignum-perl
libcrypt-openssl-random-perl
libcrypt-openssl-rsa-perl
libcrypt-random-seed-perl
libcryptsetup12
libcrypt-ssleay-perl
libctf0
libctf-nobfd0
libcurl3-gnutls
libcurl4
libdatrie1
libdb5.3
libdbi1
libdbus-1-3
libdebconfclient0
libdevel-cycle-perl
libdevmapper1.02.1
libdevmapper-event1.02.1
libdigest-hmac-perl
libdouble-conversion3
libdrm2
libdrm-common
libdw1
libedit2
libefiboot1
libefivar1
libelf1
libencode-locale-perl
libepoxy0
libevent-2.1-7
libevent-core-2.1-7
libexpat1
libext2fs2
libfaketime
libfdisk1
libfdt1
libffi8
libfido2-1
libfile-chdir-perl
libfile-find-rule-perl
libfile-listing-perl
libfile-readbackwards-perl
libfilesys-df-perl
libflac12
libfmt9
libfontconfig1
libfreetype6
libfribidi0
libfstrm0
libfuse2
libfuse3-3
libgbm1
libgcc-s1
libgcrypt20
libgdbm6
libgdbm-compat4
libgfapi0
libgfchangelog0
libgfrpc0
libgfxdr0
libglib2.0-0
libglusterd0
libglusterfs0
libgmp10
libgnutls30
libgnutls-dane0
libgnutlsxx30
libgoogle-perftools4
libgpg-error0
libgprofng0
libgraphite2-3
libgssapi-krb5-2
libgstreamer1.0-0
libgstreamer-plugins-base1.0-0
libharfbuzz0b
libhogweed6
libhtml-parser-perl
libhtml-tagset-perl
libhtml-tree-perl
libhttp-cookies-perl
libhttp-daemon-perl
libhttp-date-perl
libhttp-message-perl
libhttp-negotiate-perl
libibverbs1
libicu72
libidn2-0
libinih1
libio-html-perl
libio-multiplex-perl
libio-socket-ssl-perl
libio-stringy-perl
libip4tc2
libip6tc2
libipset13
libiscsi7
libisns0
libjansson4
libjemalloc2
libjpeg62-turbo
libjq1
libjs-bootstrap
libjs-extjs
libjs-jquery
libjson-c5
libjson-glib-1.0-0
libjson-glib-1.0-common
libjson-perl
libjson-xs-perl
libjs-qrcodejs
libjs-sencha-touch
libk5crypto3
libkeyutils1
libklibc
libkmod2
libknet1
libkrb5-3
libkrb5support0
libksba8
libldap-2.5-0
libldb2
liblinear4
liblinux-inotify2-perl
liblmdb0
liblocale-gettext-perl
liblockfile1
liblockfile-bin
liblttng-ust1
liblttng-ust-common1
liblttng-ust-ctl5
liblua5.3-0
liblvm2cmd2.03
liblwp-mediatypes-perl
liblwp-protocol-https-perl
liblz4-1
liblzma5
liblzo2-2
libmagic1
libmagic-mgc
libmath-random-isaac-perl
libmaxminddb0
libmd0
libmime-base32-perl
libmnl0
libmount1
libmp3lame0
libmpg123-0
libncurses6
libncursesw6
libnet1
libnetaddr-ip-perl
libnet-dbus-perl
libnet-dns-perl
libnetfilter-conntrack3
libnetfilter-log1
libnet-http-perl
libnet-ip-perl
libnet-ldap-perl
libnet-ssleay-perl
libnet-subnet-perl
libnettle8
libnewt0.52
libnfnetlink0
libnfsidmap1
libnftables1
libnftnl11
libnghttp2-14
libnl-3-200
libnl-route-3-200
libnozzle1
libnpth0
libnsl2
libnspr4
libnss3
libnss-systemd
libnuma1
libnumber-compare-perl
libnvpair3linux
liboath0
libogg0
libonig5
libopeniscsiusr
libopus0
liborc-0.4-0
libp11-kit0
libpam0g
libpam-modules
libpam-modules-bin
libpam-runtime
libpam-systemd
libpango-1.0-0
libpangocairo-1.0-0
libpangoft2-1.0-0
libpcap0.8
libpci3
libpcre2-16-0
libpcre2-8-0
libpcre3
libperl5.36
libpipeline1
libpixman-1-0
libpng16-16
libpopt0
libposix-strptime-perl
libproc2-0
libprotobuf32
libprotobuf-c1
libproxmox-acme-perl
libproxmox-acme-plugins
libproxmox-backup-qemu0
libproxmox-rs-perl
libpsl5
libpulse0
libpve-access-control
libpve-apiclient-perl
libpve-cluster-api-perl
libpve-cluster-perl
libpve-common-perl
libpve-guest-common-perl
libpve-http-server-perl
libpve-network-api-perl
libpve-network-perl
libpve-notify-perl
libpve-rs-perl
libpve-storage-perl
libpve-u2f-server-perl
libpython3.11-minimal
libpython3.11-stdlib
libpython3-stdlib
libqb100
libqrencode4
libqt5core5a
libqt5dbus5
libqt5network5
libquorum5
librabbitmq4
librados2
librados2-perl
libradosstriper1
librbd1
librdkafka1
librdmacm1
libreadline8
libregexp-ipv6-perl
librgw2
librrd8
librrds-perl
librtmp1
libsasl2-2
libsasl2-modules-db
libseccomp2
libselinux1
libsemanage2
libsemanage-common
libsepol2
libslang2
libslirp0
libsmartcols1
libsmbclient
libsnappy1v5
libsndfile1
libsocket6-perl
libspice-server1
libsqlite3-0
libss2
libssh2-1
libssl3
libstatgrab10
libstdc++6
libstring-shellquote-perl
libsubid4
libsystemd0
libsystemd-shared
libtalloc2
libtasn1-6
libtcmalloc-minimal4
libtdb1
libtemplate-perl
libterm-readline-gnu-perl
libtevent0
libtext-charwidth-perl
libtext-glob-perl
libtext-iconv-perl
libtext-wrapi18n-perl
libthai0
libthai-data
libthrift-0.17.0
libtimedate-perl
libtinfo6
libtirpc3
libtirpc-common
libtpms0
libtry-tiny-perl
libtypes-serialiser-perl
libu2f-server0
libuchardet0
libudev1
libunbound8
libunistring2
libunwind8
liburcu8
liburing2
liburi-perl
libusb-1.0-0
libusbredirparser1
libuuid1
libuuid-perl
libuutil3linux
libuv1
libva2
libva-drm2
libvirglrenderer1
libvorbis0a
libvorbisenc2
libvotequorum8
libvulkan1
libwayland-server0
libwbclient0
libwrap0
libwww-perl
libwww-robotrules-perl
libx11-6
libx11-data
libx11-xcb1
libxau6
libxcb1
libxcb-render0
libxcb-shm0
libxdmcp6
libxext6
libxml2
libxml-libxml-perl
libxml-namespacesupport-perl
libxml-parser-perl
libxml-sax-base-perl
libxml-sax-perl
libxml-twig-perl
libxrender1
libxslt1.1
libxtables12
libxxhash0
libyaml-0-2
libyaml-libyaml-perl
libyang3
libzfs4linux
libzpool5linux
libzstd1
linux-base
locales
login
logrotate
logsave
lsof
lua-lpeg
lvm2
lxcfs
lxc-pve
lzop
mailcap
man-db
manpages
mawk
media-types
memtest86+
mime-support
mokutil
mount
nano
ncurses-base
ncurses-bin
ncurses-term
netbase
netcat-traditional
nfs-common
nftables
nmap
nmap-common
novnc-pve
open-iscsi
openssh-client
openssh-server
openssh-sftp-server
openssl
passwd
pci.ids
pciutils
perl
perl-base
perl-modules-5.36
perl-openssl-defaults
pinentry-curses
postfix
procmail
procps
proxmox-archive-keyring
proxmox-backup-client
proxmox-backup-file-restore
proxmox-backup-restore-image
proxmox-default-kernel
proxmox-firewall
proxmox-grub
proxmox-kernel-6.8
proxmox-kernel-6.8.12-10-pve-signed
proxmox-kernel-6.8.12-9-pve-signed
proxmox-kernel-helper
proxmox-mail-forward
proxmox-mini-journalreader
proxmox-offline-mirror-docs
proxmox-offline-mirror-helper
proxmox-termproxy
proxmox-ve
proxmox-websocket-tunnel
proxmox-widget-toolkit
psmisc
pv
pve-cluster
pve-container
pve-docs
pve-edk2-firmware
pve-edk2-firmware-legacy
pve-edk2-firmware-ovmf
pve-esxi-import-tools
pve-firewall
pve-firmware
pve-ha-manager
pve-i18n
pve-lxc-syscalld
pve-manager
pve-qemu-kvm
pve-xtermjs
python3
python3.11
python3.11-minimal
python3.11-venv
python3-apt
python3-ceph-argparse
python3-ceph-common
python3-cephfs
python3-certifi
python3-chardet
python3-charset-normalizer
python3-debconf
python3-debian
python3-debianbts
python3-distutils
python3-httplib2
python3-idna
python3-jwt
python3-lib2to3
python3-minimal
python3-pip-whl
python3-pkg-resources
python3-prettytable
python3-protobuf
python3-pycurl
python3-pyparsing
python3-pysimplesoap
python3-pyvmomi
python3-rados
python3-rbd
python3-reportbug
python3-requests
python3-rgw
python3-setuptools
python3-setuptools-whl
python3-six
python3-systemd
python3-urllib3
python3-venv
python3-wcwidth
python3-yaml
python-apt-common
qemu-server
qrencode
readline-common
reportbug
rpcbind
rrdcached
rsync
runit-helper
samba-common
samba-libs
sed
sensible-utils
sgml-base
shared-mime-info
shim-helpers-amd64-signed
shim-signed
shim-signed-common
shim-unsigned
smartmontools
smbclient
socat
spiceterm
spl
sqlite3
ssh
ssl-cert
strace
swtpm
swtpm-libs
swtpm-tools
systemd
systemd-boot
systemd-boot-efi
systemd-sysv
sysvinit-utils
tar
tasksel
tasksel-data
tcpdump
thin-provisioning-tools
time
traceroute
tzdata
ucf
udev
uidmap
usbutils
usrmerge
util-linux
util-linux-extra
vim-common
vim-tiny
virtiofsd
vncterm
wamerican
wget
whiptail
xfsprogs
xkb-data
xsltproc
xz-utils
zfs-initramfs
zfsutils-linux
zfs-zed
zlib1g
zstd
+1 -1
View File
@@ -1 +1 @@
1.1.9.2
1.1.9.5
Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+12 -4
View File
@@ -821,14 +821,22 @@ install_normal_version() {
cp "./version.txt" "$LOCAL_VERSION_FILE"
cp "./install_proxmenux.sh" "$BASE_DIR/install_proxmenux.sh"
# Wipe the scripts tree before copying so any file removed upstream
# (renamed, consolidated, deprecated) disappears from the user install.
# Only $BASE_DIR/scripts/ is cleared; config.json, cache.json,
# components_status.json, version.txt, beta_version.txt, monitor.db,
# smart/, oci/ and the AppImage live outside this path and are preserved.
rm -rf "$BASE_DIR/scripts"
mkdir -p "$BASE_DIR/scripts"
cp -r "./scripts/"* "$BASE_DIR/scripts/"
chmod -R +x "$BASE_DIR/scripts/"
# Only .sh files need the executable bit. Applying +x recursively would
# also flag README.md, .json, .py etc. as executable for no reason.
find "$BASE_DIR/scripts" -type f -name '*.sh' -exec chmod +x {} +
chmod +x "$BASE_DIR/install_proxmenux.sh"
msg_ok "Necessary files created."
chmod +x "$INSTALL_DIR/$MENU_SCRIPT"
((current_step++))
show_progress $current_step $total_steps "Installing ProxMenux Monitor"
@@ -955,13 +963,13 @@ install_translation_version() {
cp "./menu" "$INSTALL_DIR/$MENU_SCRIPT"
cp "./version.txt" "$LOCAL_VERSION_FILE"
cp "./install_proxmenux.sh" "$BASE_DIR/install_proxmenux.sh"
mkdir -p "$BASE_DIR/scripts"
cp -r "./scripts/"* "$BASE_DIR/scripts/"
chmod -R +x "$BASE_DIR/scripts/"
chmod +x "$BASE_DIR/install_proxmenux.sh"
msg_ok "Necessary files created."
chmod +x "$INSTALL_DIR/$MENU_SCRIPT"
((current_step++))
-591
View File
@@ -1,591 +0,0 @@
#!/bin/bash
# ==========================================================
# ProxMenux Monitor - Beta Program Installer
# ==========================================================
# Author : MacRimi
# Subproject : ProxMenux Monitor Beta
# Copyright : (c) 2024-2025 MacRimi
# License : GPL-3.0 (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : Beta
# Branch : develop
# ==========================================================
# Description:
# This script installs the BETA version of ProxMenux Monitor
# from the develop branch on GitHub.
#
# Beta testers are expected to:
# - Report bugs and unexpected behavior via GitHub Issues
# - Provide feedback to help improve the final release
#
# Installs:
# • dialog, curl, jq, git (system dependencies)
# • ProxMenux core files (/usr/local/share/proxmenux)
# • ProxMenux Monitor AppImage (Web dashboard on port 8008)
# • Systemd service (auto-start on boot)
#
# Notes:
# - Clones from the 'develop' branch
# - Beta version file: beta_version.txt in the repository
# - Transition to stable: re-run the official installer
# ==========================================================
# ── Configuration ──────────────────────────────────────────
INSTALL_DIR="/usr/local/bin"
BASE_DIR="/usr/local/share/proxmenux"
CONFIG_FILE="$BASE_DIR/config.json"
CACHE_FILE="$BASE_DIR/cache.json"
UTILS_FILE="$BASE_DIR/utils.sh"
LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
BETA_VERSION_FILE="$BASE_DIR/beta_version.txt"
MENU_SCRIPT="menu"
MONITOR_INSTALL_DIR="$BASE_DIR"
MONITOR_SERVICE_FILE="/etc/systemd/system/proxmenux-monitor.service"
MONITOR_PORT=8008
REPO_URL="https://github.com/MacRimi/ProxMenux.git"
REPO_BRANCH="develop"
TEMP_DIR="/tmp/proxmenux-beta-install-$$"
# ── Colors ─────────────────────────────────────────────────
RESET="\033[0m"
BOLD="\033[1m"
WHITE="\033[38;5;15m"
NEON_PURPLE_BLUE="\033[38;5;99m"
DARK_GRAY="\033[38;5;244m"
ORANGE="\033[38;5;208m"
GN="\033[1;92m"
YW="\033[33m"
YWB="\033[1;33m"
RD="\033[01;31m"
BL="\033[36m"
CL="\033[m"
BGN="\e[1;32m"
TAB=" "
BFR="\\r\\033[K"
HOLD="-"
BOR=" | "
CM="${GN}${CL}"
SPINNER_PID=""
# ── Spinner ────────────────────────────────────────────────
spinner() {
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
local spin_i=0
printf "\e[?25l"
while true; do
printf "\r ${YW}%s${CL}" "${frames[spin_i]}"
spin_i=$(( (spin_i + 1) % ${#frames[@]} ))
sleep 0.1
done
}
type_text() {
local text="$1"
local delay=0.04
for ((i=0; i<${#text}; i++)); do
echo -n "${text:$i:1}"
sleep $delay
done
echo
}
msg_info() {
local msg="$1"
echo -ne "${TAB}${YW}${HOLD}${msg}"
spinner &
SPINNER_PID=$!
}
msg_ok() {
if [ -n "$SPINNER_PID" ] && ps -p $SPINNER_PID > /dev/null 2>&1; then
kill $SPINNER_PID > /dev/null 2>&1
SPINNER_PID=""
fi
printf "\e[?25h"
echo -e "${BFR}${TAB}${CM}${GN}${1}${CL}"
}
msg_error() {
if [ -n "$SPINNER_PID" ] && ps -p $SPINNER_PID > /dev/null 2>&1; then
kill $SPINNER_PID > /dev/null 2>&1
SPINNER_PID=""
fi
printf "\e[?25h"
echo -e "${BFR}${TAB}${RD}[ERROR] ${1}${CL}"
}
msg_warn() {
if [ -n "$SPINNER_PID" ] && ps -p $SPINNER_PID > /dev/null 2>&1; then
kill $SPINNER_PID > /dev/null 2>&1
SPINNER_PID=""
fi
printf "\e[?25h"
echo -e "${BFR}${TAB}${YWB}${1}${CL}"
}
msg_title() {
echo -e "\n"
echo -e "${TAB}${BOLD}${HOLD}${BOR}${1}${BOR}${HOLD}${CL}"
echo -e "\n"
}
show_progress() {
echo -e "\n${BOLD}${BL}${TAB}Installing ProxMenux Beta: Step ${1} of ${2}${CL}"
echo
echo -e "${TAB}${BOLD}${YW}${HOLD}${3}${CL}"
}
# ── Cleanup ────────────────────────────────────────────────
cleanup() {
if [ -d "$TEMP_DIR" ]; then
rm -rf "$TEMP_DIR"
fi
}
trap cleanup EXIT
# ── Logo ───────────────────────────────────────────────────
show_proxmenux_logo() {
clear
if [[ -z "$SSH_TTY" && -z "$(who am i | awk '{print $NF}' | grep -E '([0-9]{1,3}\.){3}[0-9]{1,3}')" ]]; then
LOGO=$(cat << "EOF"
\e[0m\e[38;2;61;61;61m▆\e[38;2;60;60;60m▄\e[38;2;54;54;54m▂\e[0m \e[38;2;0;0;0m \e[0m \e[38;2;54;54;54m▂\e[38;2;60;60;60m▄\e[38;2;61;61;61m▆\e[0m
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[38;2;61;61;61;48;2;37;37;37m▇\e[0m\e[38;2;60;60;60m▅\e[38;2;56;56;56m▃\e[38;2;37;37;37m▁ \e[38;2;36;36;36m▁\e[38;2;56;56;56m▃\e[38;2;60;60;60m▅\e[38;2;61;61;61;48;2;37;37;37m▇\e[48;2;62;62;62m \e[0m\e[7m\e[38;2;60;60;60m▁\e[0m
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[7m\e[38;2;61;61;61m▂\e[0m\e[38;2;62;62;62;48;2;61;61;61m┈\e[48;2;62;62;62m \e[48;2;61;61;61m┈\e[0m\e[38;2;60;60;60m▆\e[38;2;57;57;57m▄\e[38;2;48;48;48m▂\e[0m \e[38;2;47;47;47m▂\e[38;2;57;57;57m▄\e[38;2;60;60;60m▆\e[38;2;62;62;62;48;2;61;61;61m┈\e[48;2;62;62;62m \e[48;2;61;61;61m┈\e[0m\e[7m\e[38;2;60;60;60m▂\e[38;2;57;57;57m▄\e[38;2;47;47;47m▆\e[0m \e[0m
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏\e[7m\e[38;2;39;39;39m▇\e[38;2;57;57;57m▅\e[38;2;60;60;60m▃\e[0m\e[38;2;40;40;40;48;2;61;61;61m▁\e[48;2;62;62;62m \e[38;2;54;54;54;48;2;61;61;61m┊\e[48;2;62;62;62m \e[38;2;39;39;39;48;2;61;61;61m▁\e[0m\e[7m\e[38;2;60;60;60m▃\e[38;2;57;57;57m▅\e[38;2;38;38;38m▇\e[0m \e[38;2;193;60;2m▃\e[38;2;217;67;2m▅\e[38;2;225;70;2m▇\e[0m
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏\e[0m \e[38;2;203;63;2m▄\e[38;2;147;45;1m▂\e[0m \e[7m\e[38;2;55;55;55m▆\e[38;2;60;60;60m▄\e[38;2;61;61;61m▂\e[38;2;60;60;60m▄\e[38;2;55;55;55m▆\e[0m \e[38;2;144;44;1m▂\e[38;2;202;62;2m▄\e[38;2;219;68;2m▆\e[38;2;231;72;3;48;2;226;70;2m┈\e[48;2;231;72;3m \e[48;2;225;70;2m▉\e[0m
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏\e[7m\e[38;2;121;37;1m▉\e[0m\e[38;2;0;0;0;48;2;231;72;3m \e[0m\e[38;2;221;68;2m▇\e[38;2;208;64;2m▅\e[38;2;212;66;2m▂\e[38;2;123;37;0m▁\e[38;2;211;65;2m▂\e[38;2;207;64;2m▅\e[38;2;220;68;2m▇\e[48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m┈\e[0m\e[7m\e[38;2;221;68;2m▂\e[0m\e[38;2;44;13;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m▉\e[0m
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏\e[0m \e[7m\e[38;2;190;59;2m▅\e[38;2;216;67;2m▃\e[38;2;225;70;2m▁\e[0m\e[38;2;95;29;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;230;71;2m┈\e[48;2;231;72;3m \e[0m\e[7m\e[38;2;225;70;2m▁\e[38;2;216;67;2m▃\e[38;2;191;59;2m▅\e[0m \e[38;2;0;0;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m▉\e[0m
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏ \e[0m \e[7m\e[38;2;172;53;1m▆\e[38;2;213;66;2m▄\e[38;2;219;68;2m▂\e[38;2;213;66;2m▄\e[38;2;174;54;2m▆\e[0m \e[38;2;0;0;0m \e[0m \e[38;2;0;0;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m▉\e[0m
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏ \e[0m \e[38;2;0;0;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m▉\e[0m
\e[7m\e[38;2;52;52;52m▆\e[38;2;59;59;59m▄\e[38;2;61;61;61m▂\e[0m\e[38;2;31;31;31m▏ \e[0m \e[7m\e[38;2;228;71;2m▂\e[38;2;221;69;2m▄\e[38;2;196;60;2m▆\e[0m
EOF
)
TEXT=(
""
""
"${BOLD}ProxMenux${RESET}"
""
"${BOLD}${NEON_PURPLE_BLUE}An Interactive Menu for${RESET}"
"${BOLD}${NEON_PURPLE_BLUE}Proxmox VE management${RESET}"
""
"${BOLD}${YW} ★ BETA PROGRAM ★${RESET}"
""
""
)
mapfile -t logo_lines <<< "$LOGO"
for i in {0..9}; do
echo -e "${TAB}${logo_lines[i]} ${WHITE}${RESET} ${TEXT[i]}"
done
echo -e
else
TEXT=(
"" "" "" ""
"${BOLD}ProxMenux${RESET}"
""
"${BOLD}${NEON_PURPLE_BLUE}An Interactive Menu for${RESET}"
"${BOLD}${NEON_PURPLE_BLUE}Proxmox VE management${RESET}"
""
"${BOLD}${YW} ★ BETA PROGRAM ★${RESET}"
"" "" ""
)
LOGO=(
"${DARK_GRAY}░░░░ ░░░░${RESET}"
"${DARK_GRAY}░░░░░░░ ░░░░░░ ${RESET}"
"${DARK_GRAY}░░░░░░░░░░░ ░░░░░░░ ${RESET}"
"${DARK_GRAY}░░░░ ░░░░░░ ░░░░░░ ${ORANGE}░░${RESET}"
"${DARK_GRAY}░░░░ ░░░░░░░ ${ORANGE}░░▒▒▒${RESET}"
"${DARK_GRAY}░░░░ ░░░ ${ORANGE}░▒▒▒▒▒▒▒${RESET}"
"${DARK_GRAY}░░░░ ${ORANGE}▒▒▒░ ░▒▒▒▒▒▒▒▒▒▒${RESET}"
"${DARK_GRAY}░░░░ ${ORANGE}░▒▒▒▒▒ ▒▒▒▒▒░░ ▒▒▒▒${RESET}"
"${DARK_GRAY}░░░░ ${ORANGE}░░▒▒▒▒▒▒▒░░ ▒▒▒▒${RESET}"
"${DARK_GRAY}░░░░ ${ORANGE}░░░ ▒▒▒▒${RESET}"
"${DARK_GRAY}░░░░ ${ORANGE}▒▒▒▒${RESET}"
"${DARK_GRAY}░░░░ ${ORANGE}▒▒▒░${RESET}"
"${DARK_GRAY} ░░ ${ORANGE}░░ ${RESET}"
)
for i in {0..12}; do
echo -e "${TAB}${LOGO[i]}${RESET} ${TEXT[i]}"
done
echo -e
fi
}
# ── Beta welcome message ───────────────────────────────────
show_beta_welcome() {
local width=62
local line
line=$(printf '─%.0s' $(seq 1 $width))
echo -e "${TAB}${BOLD}${YW}${line}${CL}"
echo -e "${TAB}${BOLD}${YW}${CL}${BOLD} Welcome to the ProxMenux Monitor Beta Program ${YW}${CL}"
echo -e "${TAB}${BOLD}${YW}${line}${CL}"
echo
echo -e "${TAB}${WHITE}You are about to install a ${BOLD}pre-release (beta)${RESET}${WHITE} version of${CL}"
echo -e "${TAB}${WHITE}ProxMenux Monitor, built from the ${BOLD}develop${RESET}${WHITE} branch.${CL}"
echo
echo -e "${TAB}${BOLD}${GN}What this means for you:${CL}"
echo -e "${TAB} ${GN}${CL} You'll get the latest features before the official release."
echo -e "${TAB} ${GN}${CL} Some things may not work perfectly — that's expected."
echo -e "${TAB} ${GN}${CL} Your feedback is what makes the final version better."
echo
echo -e "${TAB}${BOLD}${YW}How to report issues:${CL}"
echo -e "${TAB} ${YW}${CL} Open a GitHub Issue at:"
echo -e "${TAB} ${BL}https://github.com/MacRimi/ProxMenux/issues${CL}"
echo -e "${TAB} ${YW}${CL} Describe what happened, what you expected, and any"
echo -e "${TAB} error messages you saw. Logs help a lot:"
echo -e "${TAB} ${DARK_GRAY}journalctl -u proxmenux-monitor -n 50${CL}"
echo
echo -e "${TAB}${BOLD}${NEON_PURPLE_BLUE}Thank you for being part of the beta program!${CL}"
echo -e "${TAB}${DARK_GRAY}Your help is essential to deliver a stable and polished release.${CL}"
echo
echo -e "${TAB}${BOLD}${YW}${line}${CL}"
echo -e "${TAB}${BOLD}${YW}${CL} ${YW}${CL}"
echo -e "${TAB}${BOLD}${YW}${CL} Press ${BOLD}${GN}[Enter]${CL} to continue with the beta installation, ${YW}${CL}"
echo -e "${TAB}${BOLD}${YW}${CL} or ${BOLD}${RD}[Ctrl+C]${CL} to cancel and exit. ${YW}${CL}"
echo -e "${TAB}${BOLD}${YW}${CL} ${YW}${CL}"
echo -e "${TAB}${BOLD}${YW}${line}${CL}"
echo
read -r -p ""
echo
}
# ── Helpers ────────────────────────────────────────────────
get_server_ip() {
local ip
ip=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+')
[ -z "$ip" ] && ip=$(hostname -I | awk '{print $1}')
[ -z "$ip" ] && ip="localhost"
echo "$ip"
}
update_config() {
local component="$1"
local status="$2"
local timestamp
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
mkdir -p "$(dirname "$CONFIG_FILE")"
[ ! -f "$CONFIG_FILE" ] || ! jq empty "$CONFIG_FILE" >/dev/null 2>&1 && echo '{}' > "$CONFIG_FILE"
local tmp_file
tmp_file=$(mktemp)
if jq --arg comp "$component" --arg stat "$status" --arg time "$timestamp" \
'.[$comp] = {status: $stat, timestamp: $time}' "$CONFIG_FILE" > "$tmp_file" 2>/dev/null; then
mv "$tmp_file" "$CONFIG_FILE"
else
echo '{}' > "$CONFIG_FILE"
fi
[ -f "$tmp_file" ] && rm -f "$tmp_file"
}
cleanup_corrupted_files() {
if [ -f "$CONFIG_FILE" ] && ! jq empty "$CONFIG_FILE" >/dev/null 2>&1; then
rm -f "$CONFIG_FILE"
fi
if [ -f "$CACHE_FILE" ] && ! jq empty "$CACHE_FILE" >/dev/null 2>&1; then
rm -f "$CACHE_FILE"
fi
}
detect_latest_appimage() {
local appimage_dir="$TEMP_DIR/AppImage"
[ ! -d "$appimage_dir" ] && return 1
local latest
latest=$(find "$appimage_dir" -name "ProxMenux-*.AppImage" -type f | sort -V | tail -1)
[ -z "$latest" ] && return 1
echo "$latest"
}
get_appimage_version() {
local filename
filename=$(basename "$1")
echo "$filename" | grep -oP 'ProxMenux-\K[0-9]+\.[0-9]+\.[0-9]+'
}
# ── Monitor install ────────────────────────────────────────
install_proxmenux_monitor() {
local appimage_source
appimage_source=$(detect_latest_appimage)
if [ -z "$appimage_source" ] || [ ! -f "$appimage_source" ]; then
msg_error "ProxMenux Monitor AppImage not found in $TEMP_DIR/AppImage/"
msg_warn "Make sure the AppImage directory exists in the develop branch."
update_config "proxmenux_monitor" "appimage_not_found"
return 1
fi
local appimage_version
appimage_version=$(get_appimage_version "$appimage_source")
systemctl is-active --quiet proxmenux-monitor.service 2>/dev/null && \
systemctl stop proxmenux-monitor.service
local service_exists=false
[ -f "$MONITOR_SERVICE_FILE" ] && service_exists=true
local sha256_file="$TEMP_DIR/AppImage/ProxMenux-Monitor.AppImage.sha256"
if [ -f "$sha256_file" ]; then
msg_info "Verifying AppImage integrity..."
local expected_hash actual_hash
expected_hash=$(grep -Eo '^[a-f0-9]+' "$sha256_file" | tr -d '\n')
actual_hash=$(sha256sum "$appimage_source" | awk '{print $1}')
if [ "$expected_hash" != "$actual_hash" ]; then
msg_error "SHA256 verification failed! The AppImage may be corrupted."
return 1
fi
msg_ok "SHA256 verification passed."
else
msg_warn "SHA256 checksum file not found. Skipping verification."
fi
msg_info "Installing ProxMenux Monitor (beta)..."
mkdir -p "$MONITOR_INSTALL_DIR"
local target_path="$MONITOR_INSTALL_DIR/ProxMenux-Monitor.AppImage"
cp "$appimage_source" "$target_path"
chmod +x "$target_path"
msg_ok "ProxMenux Monitor beta v${appimage_version} installed."
if [ "$service_exists" = false ]; then
return 0
else
systemctl start proxmenux-monitor.service
sleep 2
if systemctl is-active --quiet proxmenux-monitor.service; then
update_config "proxmenux_monitor" "beta_updated"
return 2
else
msg_warn "Service failed to restart. Check: journalctl -u proxmenux-monitor"
update_config "proxmenux_monitor" "failed"
return 1
fi
fi
}
create_monitor_service() {
msg_info "Creating ProxMenux Monitor service..."
local exec_path="$MONITOR_INSTALL_DIR/ProxMenux-Monitor.AppImage"
if [ -f "$TEMP_DIR/systemd/proxmenux-monitor.service" ]; then
sed "s|ExecStart=.*|ExecStart=$exec_path|g" \
"$TEMP_DIR/systemd/proxmenux-monitor.service" > "$MONITOR_SERVICE_FILE"
msg_ok "Service file loaded from repository."
else
cat > "$MONITOR_SERVICE_FILE" << EOF
[Unit]
Description=ProxMenux Monitor - Web Dashboard (Beta)
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=$MONITOR_INSTALL_DIR
ExecStart=$exec_path
Restart=on-failure
RestartSec=10
Environment="PORT=$MONITOR_PORT"
[Install]
WantedBy=multi-user.target
EOF
msg_ok "Default service file created."
fi
systemctl daemon-reload
systemctl enable proxmenux-monitor.service > /dev/null 2>&1
systemctl start proxmenux-monitor.service > /dev/null 2>&1
sleep 3
if systemctl is-active --quiet proxmenux-monitor.service; then
msg_ok "ProxMenux Monitor service started successfully."
update_config "proxmenux_monitor" "beta_installed"
return 0
else
msg_warn "ProxMenux Monitor service failed to start."
echo -e "${TAB}${DARK_GRAY}Check logs : journalctl -u proxmenux-monitor -n 20${CL}"
echo -e "${TAB}${DARK_GRAY}Check status: systemctl status proxmenux-monitor${CL}"
update_config "proxmenux_monitor" "failed"
return 1
fi
}
# ── Main install ───────────────────────────────────────────
install_beta() {
local total_steps=4
local current_step=1
# ── Step 1: Dependencies ──────────────────────────────
show_progress $current_step $total_steps "Installing system dependencies"
if ! command -v jq > /dev/null 2>&1; then
apt-get update > /dev/null 2>&1
if apt-get install -y jq > /dev/null 2>&1 && command -v jq > /dev/null 2>&1; then
update_config "jq" "installed"
else
local jq_url="https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64"
if wget -q -O /usr/local/bin/jq "$jq_url" 2>/dev/null && chmod +x /usr/local/bin/jq \
&& command -v jq > /dev/null 2>&1; then
update_config "jq" "installed_from_github"
else
msg_error "Failed to install jq. Please install it manually and re-run."
update_config "jq" "failed"
return 1
fi
fi
else
update_config "jq" "already_installed"
fi
local BASIC_DEPS=("dialog" "curl" "git")
if [ -z "${APT_UPDATED:-}" ]; then
apt-get update -y > /dev/null 2>&1 || true
APT_UPDATED=1
fi
for pkg in "${BASIC_DEPS[@]}"; do
if ! dpkg -l | grep -qw "$pkg"; then
if apt-get install -y "$pkg" > /dev/null 2>&1; then
update_config "$pkg" "installed"
else
msg_error "Failed to install $pkg. Please install it manually."
update_config "$pkg" "failed"
return 1
fi
else
update_config "$pkg" "already_installed"
fi
done
msg_ok "Dependencies installed: jq, dialog, curl, git."
# ── Step 2: Clone develop branch ─────────────────────
((current_step++))
show_progress $current_step $total_steps "Cloning ProxMenux develop branch"
msg_info "Cloning branch '${REPO_BRANCH}' from repository..."
if ! git clone --depth 1 --branch "$REPO_BRANCH" "$REPO_URL" "$TEMP_DIR" 2>/dev/null; then
msg_error "Failed to clone branch '$REPO_BRANCH' from $REPO_URL"
exit 1
fi
msg_ok "Repository cloned successfully (branch: ${REPO_BRANCH})."
# Read beta version if available
local beta_version="unknown"
if [ -f "$TEMP_DIR/beta_version.txt" ]; then
beta_version=$(cat "$TEMP_DIR/beta_version.txt" | tr -d '[:space:]')
fi
cd "$TEMP_DIR"
# ── Step 3: Files ─────────────────────────────────────
((current_step++))
show_progress $current_step $total_steps "Creating directories and copying files"
mkdir -p "$BASE_DIR" "$INSTALL_DIR"
[ ! -f "$CONFIG_FILE" ] && echo '{}' > "$CONFIG_FILE"
# Preserve user/runtime directories that must never be overwritten
mkdir -p "$BASE_DIR/oci"
cp "./scripts/utils.sh" "$UTILS_FILE"
cp "./menu" "$INSTALL_DIR/$MENU_SCRIPT"
cp "./version.txt" "$LOCAL_VERSION_FILE" 2>/dev/null || true
# Store beta version marker
if [ -f "$TEMP_DIR/beta_version.txt" ]; then
cp "$TEMP_DIR/beta_version.txt" "$BETA_VERSION_FILE"
else
echo "$beta_version" > "$BETA_VERSION_FILE"
fi
cp "./install_proxmenux.sh" "$BASE_DIR/install_proxmenux.sh" 2>/dev/null || true
cp "./install_proxmenux_beta.sh" "$BASE_DIR/install_proxmenux_beta.sh" 2>/dev/null || true
mkdir -p "$BASE_DIR/scripts"
cp -r "./scripts/"* "$BASE_DIR/scripts/"
chmod -R +x "$BASE_DIR/scripts/"
if [ -d "./oci" ]; then
mkdir -p "$BASE_DIR/oci"
cp -r "./oci/"* "$BASE_DIR/oci/" 2>/dev/null || true
fi
chmod +x "$INSTALL_DIR/$MENU_SCRIPT"
[ -f "$BASE_DIR/install_proxmenux.sh" ] && chmod +x "$BASE_DIR/install_proxmenux.sh"
[ -f "$BASE_DIR/install_proxmenux_beta.sh" ] && chmod +x "$BASE_DIR/install_proxmenux_beta.sh"
# Store beta flag in config
update_config "beta_program" "active"
update_config "beta_version" "$beta_version"
update_config "install_branch" "$REPO_BRANCH"
msg_ok "Files installed. Beta version: ${beta_version}."
# ── Step 4: Monitor ───────────────────────────────────
((current_step++))
show_progress $current_step $total_steps "Installing ProxMenux Monitor (beta)"
install_proxmenux_monitor
local monitor_status=$?
if [ $monitor_status -eq 0 ]; then
create_monitor_service
elif [ $monitor_status -eq 2 ]; then
msg_ok "ProxMenux Monitor beta updated successfully."
fi
msg_ok "Beta installation completed."
}
# ── Stable transition notice ───────────────────────────────
check_stable_available() {
# Called if a stable version is detected (future use by update logic)
# When main's version.txt > beta_version.txt, the menu/updater can call this
echo -e "\n${TAB}${BOLD}${GN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}"
echo -e "${TAB}${BOLD}${GN} A stable release is now available!${CL}"
echo -e "${TAB}${WHITE} To leave the beta program and switch to the stable version,${CL}"
echo -e "${TAB}${WHITE} run the official installer:${CL}"
echo -e ""
echo -e "${TAB} ${YWB}bash -c \"\$(wget -qLO - https://raw.githubusercontent.com/MacRimi/ProxMenux/main/install_proxmenux.sh)\"${CL}"
echo -e ""
echo -e "${TAB}${DARK_GRAY} This will cleanly replace your beta install with the stable release.${CL}"
echo -e "${TAB}${BOLD}${GN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}\n"
}
# ── Entry point ────────────────────────────────────────────
if [ "$(id -u)" -ne 0 ]; then
echo -e "${RD}[ERROR] This script must be run as root.${CL}"
exit 1
fi
cleanup_corrupted_files
show_proxmenux_logo
show_beta_welcome
msg_title "Installing ProxMenux Beta — branch: develop"
install_beta
# Load utils if available
[ -f "$UTILS_FILE" ] && source "$UTILS_FILE"
msg_title "ProxMenux Beta installed successfully"
if systemctl is-active --quiet proxmenux-monitor.service; then
local_ip=$(get_server_ip)
echo -e "${GN}🌐 ProxMenux Monitor (beta) is running${CL}: ${BL}http://${local_ip}:${MONITOR_PORT}${CL}"
echo
fi
echo -ne "${GN}"
type_text "To run ProxMenux, execute this command in your terminal:"
echo -e "${YWB} menu${CL}"
echo
echo -e "${TAB}${DARK_GRAY}Report issues at: https://github.com/MacRimi/ProxMenux/issues${CL}"
echo
exit 0
+12534 -2662
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21 -54
View File
@@ -80,72 +80,39 @@ check_updates_stable() {
if curl -fsSL "$INSTALL_URL" -o "$INSTALL_SCRIPT"; then
chmod +x "$INSTALL_SCRIPT"
bash "$INSTALL_SCRIPT" --update
return 0
fi
fi
}
# ── Beta update check (develop branch) ────────────────────
# ── Beta-mode update check (main + develop) ───────────────
# When the beta program is active, check BOTH channels. The stable check
# is delegated to check_updates_stable (same prompt, same installer). After
# that we only need the beta-specific part: develop vs beta_version.txt.
check_updates_beta() {
local BETA_VERSION_URL="$REPO_DEVELOP/beta_version.txt"
local STABLE_VERSION_URL="$REPO_MAIN/version.txt"
local INSTALL_BETA_URL="$REPO_DEVELOP/install_proxmenux_beta.sh"
local INSTALL_STABLE_URL="$REPO_MAIN/install_proxmenux.sh"
local INSTALL_SCRIPT="$BASE_DIR/install_proxmenux_beta.sh"
# 1. Stable release on main — reuse the non-beta path.
check_updates_stable
# ── 1. Check if a stable release has superseded the beta ──
# If main's version.txt exists and is newer than local beta_version.txt,
# the beta cycle is over and we invite the user to switch to stable.
local STABLE_VERSION BETA_LOCAL_VERSION
STABLE_VERSION="$(curl -fsSL "$STABLE_VERSION_URL" 2>/dev/null | head -n 1)"
BETA_LOCAL_VERSION="$(head -n 1 "$BETA_VERSION_FILE" 2>/dev/null)"
if [[ -n "$STABLE_VERSION" && -n "$BETA_LOCAL_VERSION" ]]; then
# Simple string comparison is enough if versions follow semver x.y.z
if [[ "$STABLE_VERSION" != "$BETA_LOCAL_VERSION" ]] && \
printf '%s\n' "$BETA_LOCAL_VERSION" "$STABLE_VERSION" | sort -V | tail -1 | grep -qx "$STABLE_VERSION"; then
# Stable is newer — offer migration out of beta
if whiptail --title "🎉 Stable Release Available" \
--yesno "A stable release of ProxMenux is now available!\n\nStable version : $STABLE_VERSION\nYour beta : $BETA_LOCAL_VERSION\n\nThe beta program for this cycle is complete.\nWould you like to switch to the stable release now?\n\n(Choosing 'No' keeps you on the beta for now.)" \
16 68; then
msg_warn "Switching to stable release $STABLE_VERSION ..."
local tmp_installer="/tmp/install_proxmenux_stable_$$.sh"
if curl -fsSL "$INSTALL_STABLE_URL" -o "$tmp_installer"; then
chmod +x "$tmp_installer"
bash "$tmp_installer"
rm -f "$tmp_installer"
else
msg_error "Could not download the stable installer. Try manually:"
echo
echo " bash -c \"\$(wget -qLO - $INSTALL_STABLE_URL)\""
echo
fi
return 0
fi
# User chose to stay on beta — continue normally
return 0
fi
fi
# ── 2. Check for a newer beta build on develop ─────────────
# 2. Beta build on develop.
[[ ! -f "$BETA_VERSION_FILE" ]] && return 0
local REMOTE_BETA_VERSION
REMOTE_BETA_VERSION="$(curl -fsSL "$BETA_VERSION_URL" 2>/dev/null | head -n 1)"
[[ -z "$REMOTE_BETA_VERSION" ]] && return 0
[[ "$BETA_LOCAL_VERSION" = "$REMOTE_BETA_VERSION" ]] && return 0
local REMOTE_BETA LOCAL_BETA
REMOTE_BETA="$(curl -fsSL "$REPO_DEVELOP/beta_version.txt" 2>/dev/null | head -n 1)"
LOCAL_BETA="$(head -n 1 "$BETA_VERSION_FILE" 2>/dev/null)"
[[ -z "$REMOTE_BETA" || -z "$LOCAL_BETA" || "$LOCAL_BETA" = "$REMOTE_BETA" ]] && return 0
[[ "$(printf '%s\n%s\n' "$LOCAL_BETA" "$REMOTE_BETA" | sort -V | tail -1)" = "$REMOTE_BETA" ]] || return 0
if whiptail --title "Beta Update Available" \
--yesno "A new beta build is available!\n\nInstalled beta : $BETA_LOCAL_VERSION\nNew beta build : $REMOTE_BETA_VERSION\n\nThis is a pre-release build from the develop branch.\nDo you want to update now?" \
13 64 --defaultno; then
--yesno "A new beta build is available!\n\nInstalled beta : $LOCAL_BETA\nNew beta build : $REMOTE_BETA\n\nDo you want to update now?" \
12 64 --defaultno; then
msg_warn "Updating to beta build $REMOTE_BETA_VERSION ..."
msg_warn "Updating to beta build $REMOTE_BETA ..."
if curl -fsSL "$INSTALL_BETA_URL" -o "$INSTALL_SCRIPT"; then
chmod +x "$INSTALL_SCRIPT"
bash "$INSTALL_SCRIPT" --update
local INSTALL_BETA_SCRIPT="$BASE_DIR/install_proxmenux_beta.sh"
if curl -fsSL "$REPO_DEVELOP/install_proxmenux_beta.sh" -o "$INSTALL_BETA_SCRIPT"; then
chmod +x "$INSTALL_BETA_SCRIPT"
bash "$INSTALL_BETA_SCRIPT" --update
return 0
else
msg_error "Could not download the beta installer from the develop branch."
fi
@@ -0,0 +1,166 @@
#!/bin/bash
# ==========================================================
# ProxMenux - Apply Pending Restore On Boot
# ==========================================================
PENDING_BASE="${PMX_RESTORE_PENDING_BASE:-/var/lib/proxmenux/restore-pending}"
CURRENT_LINK="${PENDING_BASE}/current"
LOG_DIR="${PMX_RESTORE_LOG_DIR:-/var/log/proxmenux}"
DEST_PREFIX="${PMX_RESTORE_DEST_PREFIX:-/}"
PRE_BACKUP_BASE="${PMX_RESTORE_PRE_BACKUP_BASE:-/root/proxmenux-pre-restore}"
RECOVERY_BASE="${PMX_RESTORE_RECOVERY_BASE:-/root/proxmenux-recovery}"
mkdir -p "$LOG_DIR" "$PENDING_BASE/completed" >/dev/null 2>&1 || true
LOG_FILE="${LOG_DIR}/proxmenux-restore-onboot-$(date +%Y%m%d_%H%M%S).log"
exec >>"$LOG_FILE" 2>&1
echo "=== ProxMenux pending restore started at $(date -Iseconds) ==="
if [[ ! -e "$CURRENT_LINK" ]]; then
echo "No pending restore link found. Nothing to do."
exit 0
fi
PENDING_DIR="$(readlink -f "$CURRENT_LINK" 2>/dev/null || echo "$CURRENT_LINK")"
if [[ ! -d "$PENDING_DIR" ]]; then
echo "Pending restore directory not found: $PENDING_DIR"
rm -f "$CURRENT_LINK" >/dev/null 2>&1 || true
exit 0
fi
APPLY_LIST="${PENDING_DIR}/apply-on-boot.list"
PLAN_ENV="${PENDING_DIR}/plan.env"
STATE_FILE="${PENDING_DIR}/state"
if [[ -f "$PLAN_ENV" ]]; then
# shellcheck source=/dev/null
source "$PLAN_ENV"
fi
: "${HB_RESTORE_INCLUDE_ZFS:=0}"
if [[ ! -f "$APPLY_LIST" ]]; then
echo "Apply list missing: $APPLY_LIST"
echo "failed" >"$STATE_FILE"
exit 1
fi
echo "Pending dir: $PENDING_DIR"
echo "Apply list: $APPLY_LIST"
echo "Include ZFS: $HB_RESTORE_INCLUDE_ZFS"
echo "running" >"$STATE_FILE"
backup_root="${PRE_BACKUP_BASE}/$(date +%Y%m%d_%H%M%S)-onboot"
mkdir -p "$backup_root" >/dev/null 2>&1 || true
cluster_recovery_root=""
applied=0
skipped=0
failed=0
while IFS= read -r rel; do
[[ -z "$rel" ]] && continue
src="${PENDING_DIR}/rootfs/${rel}"
dst="${DEST_PREFIX%/}/${rel}"
if [[ ! -e "$src" ]]; then
((skipped++))
continue
fi
# Never restore cluster virtual filesystem data live.
if [[ "$rel" == etc/pve* ]] || [[ "$rel" == var/lib/pve-cluster* ]]; then
if [[ -z "$cluster_recovery_root" ]]; then
cluster_recovery_root="${RECOVERY_BASE}/$(date +%Y%m%d_%H%M%S)-onboot"
mkdir -p "$cluster_recovery_root" >/dev/null 2>&1 || true
fi
mkdir -p "$cluster_recovery_root/$(dirname "$rel")" >/dev/null 2>&1 || true
cp -a "$src" "$cluster_recovery_root/$rel" >/dev/null 2>&1 || true
((skipped++))
continue
fi
# /etc/zfs is opt-in.
if [[ "$rel" == etc/zfs || "$rel" == etc/zfs/* ]]; then
if [[ "$HB_RESTORE_INCLUDE_ZFS" != "1" ]]; then
((skipped++))
continue
fi
fi
if [[ -e "$dst" ]]; then
mkdir -p "$backup_root/$(dirname "$rel")" >/dev/null 2>&1 || true
cp -a "$dst" "$backup_root/$rel" >/dev/null 2>&1 || true
fi
if [[ -d "$src" ]]; then
mkdir -p "$dst" >/dev/null 2>&1 || true
if rsync -aAXH --delete "$src/" "$dst/" >/dev/null 2>&1; then
((applied++))
else
((failed++))
fi
else
mkdir -p "$(dirname "$dst")" >/dev/null 2>&1 || true
if cp -a "$src" "$dst" >/dev/null 2>&1; then
((applied++))
else
((failed++))
fi
fi
done <"$APPLY_LIST"
systemctl daemon-reload >/dev/null 2>&1 || true
command -v update-initramfs >/dev/null 2>&1 && update-initramfs -u -k all >/dev/null 2>&1 || true
command -v update-grub >/dev/null 2>&1 && update-grub >/dev/null 2>&1 || true
echo "Applied: $applied"
echo "Skipped: $skipped"
echo "Failed: $failed"
echo "Backup before restore: $backup_root"
if [[ -n "$cluster_recovery_root" ]]; then
helper="${cluster_recovery_root}/apply-cluster-restore.sh"
cat > "$helper" <<EOF
#!/bin/bash
set -euo pipefail
RECOVERY_ROOT="${cluster_recovery_root}"
echo "Cluster recovery helper"
echo "Source: \$RECOVERY_ROOT"
echo
echo "WARNING: run this only in a maintenance window."
echo
read -r -p "Type YES to continue: " ans
[[ "\$ans" == "YES" ]] || { echo "Aborted."; exit 1; }
systemctl stop pve-cluster || true
[[ -d "\$RECOVERY_ROOT/etc/pve" ]] && mkdir -p /etc/pve && cp -a "\$RECOVERY_ROOT/etc/pve/." /etc/pve/ || true
[[ -d "\$RECOVERY_ROOT/var/lib/pve-cluster" ]] && mkdir -p /var/lib/pve-cluster && cp -a "\$RECOVERY_ROOT/var/lib/pve-cluster/." /var/lib/pve-cluster/ || true
systemctl start pve-cluster || true
echo "Cluster recovery finished."
EOF
chmod +x "$helper" >/dev/null 2>&1 || true
echo "Cluster paths extracted to: $cluster_recovery_root"
echo "Cluster recovery helper: $helper"
fi
if [[ "$failed" -eq 0 ]]; then
echo "completed" >"$STATE_FILE"
else
echo "completed_with_errors" >"$STATE_FILE"
fi
restore_id="$(basename "$PENDING_DIR")"
mv "$PENDING_DIR" "${PENDING_BASE}/completed/${restore_id}" >/dev/null 2>&1 || true
rm -f "$CURRENT_LINK" >/dev/null 2>&1 || true
systemctl disable proxmenux-restore-onboot.service >/dev/null 2>&1 || true
echo "=== ProxMenux pending restore finished at $(date -Iseconds) ==="
echo "Log file: $LOG_FILE"
exit 0
File diff suppressed because it is too large Load Diff
+387
View File
@@ -0,0 +1,387 @@
#!/bin/bash
# ==========================================================
# ProxMenux - Scheduled Backup Jobs
# ==========================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)"
LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts"
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
elif [[ ! -f "$UTILS_FILE" ]]; then
UTILS_FILE="$BASE_DIR/utils.sh"
fi
if [[ -f "$UTILS_FILE" ]]; then
# shellcheck source=/dev/null
source "$UTILS_FILE"
else
echo "ERROR: utils.sh not found." >&2
exit 1
fi
LIB_FILE="$SCRIPT_DIR/lib_host_backup_common.sh"
[[ ! -f "$LIB_FILE" ]] && LIB_FILE="$LOCAL_SCRIPTS_DEFAULT/backup_restore/lib_host_backup_common.sh"
if [[ -f "$LIB_FILE" ]]; then
# shellcheck source=/dev/null
source "$LIB_FILE"
else
msg_error "$(translate "Cannot load backup library: lib_host_backup_common.sh")"
exit 1
fi
load_language
initialize_cache
JOBS_DIR="/var/lib/proxmenux/backup-jobs"
LOG_DIR="/var/log/proxmenux/backup-jobs"
mkdir -p "$JOBS_DIR" "$LOG_DIR" >/dev/null 2>&1 || true
_job_file() { echo "${JOBS_DIR}/$1.env"; }
_job_paths_file() { echo "${JOBS_DIR}/$1.paths"; }
_service_file() { echo "/etc/systemd/system/proxmenux-backup-$1.service"; }
_timer_file() { echo "/etc/systemd/system/proxmenux-backup-$1.timer"; }
_normalize_uint() {
local v="${1:-0}"
[[ "$v" =~ ^[0-9]+$ ]] || v=0
echo "$v"
}
_write_job_env() {
local file="$1"
shift
{
echo "# ProxMenux scheduled backup job"
local kv key val
for kv in "$@"; do
key="${kv%%=*}"
val="${kv#*=}"
printf '%s=%q\n' "$key" "$val"
done
} > "$file"
}
_list_jobs() {
local f
for f in "$JOBS_DIR"/*.env; do
[[ -f "$f" ]] || continue
basename "$f" .env
done | sort
}
_show_job_status() {
local id="$1"
local timer_state="disabled"
local service_state="unknown"
systemctl is-enabled --quiet "proxmenux-backup-${id}.timer" >/dev/null 2>&1 && timer_state="enabled"
service_state=$(systemctl is-active "proxmenux-backup-${id}.service" 2>/dev/null || echo "inactive")
echo "${timer_state}/${service_state}"
}
_write_job_units() {
local id="$1"
local on_calendar="$2"
local runner="$LOCAL_SCRIPTS/backup_restore/run_scheduled_backup.sh"
[[ ! -f "$runner" ]] && runner="$SCRIPT_DIR/run_scheduled_backup.sh"
cat > "$(_service_file "$id")" <<EOF
[Unit]
Description=ProxMenux Scheduled Backup Job (${id})
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=${runner} ${id}
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
EOF
cat > "$(_timer_file "$id")" <<EOF
[Unit]
Description=ProxMenux Scheduled Backup Timer (${id})
[Timer]
OnCalendar=${on_calendar}
Persistent=true
RandomizedDelaySec=120
Unit=proxmenux-backup-${id}.service
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload >/dev/null 2>&1 || true
}
_prompt_retention() {
local __out_var="$1"
local last hourly daily weekly monthly yearly
last=$(dialog --backtitle "ProxMenux" --title "$(translate "Retention")" \
--inputbox "$(translate "keep-last (0 disables)")" 9 60 "7" 3>&1 1>&2 2>&3) || return 1
hourly=$(dialog --backtitle "ProxMenux" --title "$(translate "Retention")" \
--inputbox "$(translate "keep-hourly (0 disables)")" 9 60 "0" 3>&1 1>&2 2>&3) || return 1
daily=$(dialog --backtitle "ProxMenux" --title "$(translate "Retention")" \
--inputbox "$(translate "keep-daily (0 disables)")" 9 60 "7" 3>&1 1>&2 2>&3) || return 1
weekly=$(dialog --backtitle "ProxMenux" --title "$(translate "Retention")" \
--inputbox "$(translate "keep-weekly (0 disables)")" 9 60 "4" 3>&1 1>&2 2>&3) || return 1
monthly=$(dialog --backtitle "ProxMenux" --title "$(translate "Retention")" \
--inputbox "$(translate "keep-monthly (0 disables)")" 9 60 "3" 3>&1 1>&2 2>&3) || return 1
yearly=$(dialog --backtitle "ProxMenux" --title "$(translate "Retention")" \
--inputbox "$(translate "keep-yearly (0 disables)")" 9 60 "0" 3>&1 1>&2 2>&3) || return 1
last=$(_normalize_uint "$last")
hourly=$(_normalize_uint "$hourly")
daily=$(_normalize_uint "$daily")
weekly=$(_normalize_uint "$weekly")
monthly=$(_normalize_uint "$monthly")
yearly=$(_normalize_uint "$yearly")
local -n out="$__out_var"
out=(
"KEEP_LAST=$last"
"KEEP_HOURLY=$hourly"
"KEEP_DAILY=$daily"
"KEEP_WEEKLY=$weekly"
"KEEP_MONTHLY=$monthly"
"KEEP_YEARLY=$yearly"
)
}
_create_job() {
local id backend on_calendar profile_mode
id=$(dialog --backtitle "ProxMenux" --title "$(translate "New backup job")" \
--inputbox "$(translate "Job ID (letters, numbers, - _)")" 9 68 "hostcfg-daily" 3>&1 1>&2 2>&3) || return 1
[[ -z "$id" ]] && return 1
id=$(echo "$id" | tr -cs '[:alnum:]_-' '-' | sed 's/^-*//; s/-*$//')
[[ -z "$id" ]] && return 1
[[ -f "$(_job_file "$id")" ]] && {
dialog --backtitle "ProxMenux" --title "$(translate "Error")" \
--msgbox "$(translate "A job with this ID already exists.")" 8 62
return 1
}
backend=$(dialog --backtitle "ProxMenux" --title "$(translate "Backend")" \
--menu "\n$(translate "Select backup backend:")" 14 70 6 \
"local" "Local archive" \
"borg" "Borg repository" \
"pbs" "Proxmox Backup Server" \
3>&1 1>&2 2>&3) || return 1
on_calendar=$(dialog --backtitle "ProxMenux" --title "$(translate "Schedule")" \
--inputbox "$(translate "systemd OnCalendar expression")"$'\n'"$(translate "Example: daily or Mon..Fri 03:00")" \
11 72 "daily" 3>&1 1>&2 2>&3) || return 1
[[ -z "$on_calendar" ]] && return 1
profile_mode=$(dialog --backtitle "ProxMenux" --title "$(translate "Profile")" \
--menu "\n$(translate "Select backup profile:")" 12 68 4 \
"default" "Default critical paths" \
"custom" "Custom selected paths" \
3>&1 1>&2 2>&3) || return 1
local -a paths=()
hb_select_profile_paths "$profile_mode" paths || return 1
local -a retention=()
_prompt_retention retention || return 1
local -a lines=(
"JOB_ID=$id"
"BACKEND=$backend"
"ON_CALENDAR=$on_calendar"
"PROFILE_MODE=$profile_mode"
"ENABLED=1"
)
lines+=("${retention[@]}")
case "$backend" in
local)
local dest_dir ext
dest_dir=$(hb_prompt_dest_dir) || return 1
ext=$(dialog --backtitle "ProxMenux" --title "$(translate "Archive format")" \
--menu "\n$(translate "Select local archive format:")" 12 62 4 \
"tar.zst" "tar + zstd (preferred)" \
"tar.gz" "tar + gzip" \
3>&1 1>&2 2>&3) || return 1
lines+=("LOCAL_DEST_DIR=$dest_dir" "LOCAL_ARCHIVE_EXT=$ext")
;;
borg)
local repo passphrase
hb_select_borg_repo repo || return 1
hb_prepare_borg_passphrase || return 1
passphrase="${BORG_PASSPHRASE:-}"
lines+=(
"BORG_REPO=$repo"
"BORG_PASSPHRASE=$passphrase"
"BORG_ENCRYPT_MODE=${BORG_ENCRYPT_MODE:-none}"
)
;;
pbs)
hb_select_pbs_repository || return 1
hb_ask_pbs_encryption
local bid
bid="hostcfg-$(hostname)"
bid=$(dialog --backtitle "ProxMenux" --title "PBS" \
--inputbox "$(translate "Backup ID for this job:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "$bid" 3>&1 1>&2 2>&3) || return 1
bid=$(echo "$bid" | tr -cs '[:alnum:]_-' '-' | sed 's/-*$//')
lines+=(
"PBS_REPOSITORY=${HB_PBS_REPOSITORY}"
"PBS_PASSWORD=${HB_PBS_SECRET}"
"PBS_BACKUP_ID=${bid}"
"PBS_KEYFILE=${HB_PBS_KEYFILE:-}"
"PBS_ENCRYPTION_PASSWORD=${HB_PBS_ENC_PASS:-}"
)
;;
esac
_write_job_env "$(_job_file "$id")" "${lines[@]}"
: > "$(_job_paths_file "$id")"
local p
for p in "${paths[@]}"; do
echo "$p" >> "$(_job_paths_file "$id")"
done
_write_job_units "$id" "$on_calendar"
systemctl enable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || true
show_proxmenux_logo
msg_title "$(translate "Scheduled backup job created")"
echo -e ""
echo -e "${TAB}${BGN}$(translate "Job ID:")${CL} ${BL}${id}${CL}"
echo -e "${TAB}${BGN}$(translate "Backend:")${CL} ${BL}${backend}${CL}"
echo -e "${TAB}${BGN}$(translate "Schedule:")${CL} ${BL}${on_calendar}${CL}"
echo -e "${TAB}${BGN}$(translate "Status:")${CL} ${BL}$(_show_job_status "$id")${CL}"
echo -e ""
msg_success "$(translate "Press Enter to continue...")"
read -r
return 0
}
_pick_job() {
local title="$1"
local __out_var="$2"
local -a ids=()
mapfile -t ids < <(_list_jobs)
if [[ ${#ids[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "$(translate "No jobs")" \
--msgbox "$(translate "No scheduled backup jobs found.")" 8 62
return 1
fi
local -a menu=()
local i=1 id
for id in "${ids[@]}"; do
menu+=("$i" "$id [$(_show_job_status "$id")]")
((i++))
done
local sel
sel=$(dialog --backtitle "ProxMenux" --title "$title" \
--menu "\n$(translate "Select a job:")" "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"${menu[@]}" 3>&1 1>&2 2>&3) || return 1
local picked="${ids[$((sel-1))]}"
local -n out="$__out_var"
out="$picked"
return 0
}
_job_run_now() {
local id=""
_pick_job "$(translate "Run job now")" id || return 1
local runner="$LOCAL_SCRIPTS/backup_restore/run_scheduled_backup.sh"
[[ ! -f "$runner" ]] && runner="$SCRIPT_DIR/run_scheduled_backup.sh"
if "$runner" "$id"; then
msg_ok "$(translate "Job executed successfully.")"
else
msg_warn "$(translate "Job execution finished with errors. Check logs.")"
fi
msg_success "$(translate "Press Enter to continue...")"
read -r
}
_job_toggle() {
local id=""
_pick_job "$(translate "Enable/Disable job")" id || return 1
if systemctl is-enabled --quiet "proxmenux-backup-${id}.timer" >/dev/null 2>&1; then
systemctl disable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || true
msg_warn "$(translate "Job timer disabled:") $id"
else
systemctl enable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || true
msg_ok "$(translate "Job timer enabled:") $id"
fi
msg_success "$(translate "Press Enter to continue...")"
read -r
}
_job_delete() {
local id=""
_pick_job "$(translate "Delete job")" id || return 1
if ! whiptail --title "$(translate "Confirm delete")" \
--yesno "$(translate "Delete scheduled backup job?")"$'\n\n'"ID: ${id}" 10 66; then
return 1
fi
systemctl disable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || true
rm -f "$(_service_file "$id")" "$(_timer_file "$id")" "$(_job_file "$id")" "$(_job_paths_file "$id")"
systemctl daemon-reload >/dev/null 2>&1 || true
msg_ok "$(translate "Job deleted:") $id"
msg_success "$(translate "Press Enter to continue...")"
read -r
}
_show_jobs() {
local tmp
tmp=$(mktemp) || return
{
echo "=== $(translate "Scheduled backup jobs") ==="
echo ""
local id
while IFS= read -r id; do
[[ -z "$id" ]] && continue
echo "$id [$(_show_job_status "$id")]"
if [[ -f "${LOG_DIR}/${id}-last.status" ]]; then
sed 's/^/ /' "${LOG_DIR}/${id}-last.status"
fi
echo ""
done < <(_list_jobs)
} > "$tmp"
dialog --backtitle "ProxMenux" --title "$(translate "Scheduled backup jobs")" \
--textbox "$tmp" 28 100 || true
rm -f "$tmp"
}
main_menu() {
while true; do
local choice
choice=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Backup scheduler and retention")" \
--menu "\n$(translate "Choose action:")" "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
1 "$(translate "Create scheduled backup job")" \
2 "$(translate "Show jobs and last run status")" \
3 "$(translate "Run a job now")" \
4 "$(translate "Enable / disable job timer")" \
5 "$(translate "Delete job")" \
0 "$(translate "Return")" \
3>&1 1>&2 2>&3) || return 0
case "$choice" in
1) _create_job ;;
2) _show_jobs ;;
3) _job_run_now ;;
4) _job_toggle ;;
5) _job_delete ;;
0) return 0 ;;
esac
done
}
main_menu
@@ -0,0 +1,770 @@
#!/bin/bash
# ==========================================================
# ProxMenux - Host Config Backup/Restore - Shared Library
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : MIT
# Version : 1.0
# Last Updated: 08/04/2026
# ==========================================================
# Do not execute directly — source from backup_host.sh
# Library guard
[[ "${BASH_SOURCE[0]}" == "$0" ]] && {
echo "This file is a library. Source it, do not run it directly." >&2; exit 1
}
HB_STATE_DIR="/usr/local/share/proxmenux"
HB_BORG_VERSION="1.2.8"
HB_BORG_LINUX64_SHA256="cfa50fb704a93d3a4fa258120966345fddb394f960dca7c47fcb774d0172f40b"
HB_BORG_LINUX64_URL="https://github.com/borgbackup/borg/releases/download/${HB_BORG_VERSION}/borg-linux64"
# Translation wrapper — safe fallback if translate not yet loaded
hb_translate() {
declare -f translate >/dev/null 2>&1 && translate "$1" || echo "$1"
}
# ==========================================================
# UI SIZE CONSTANTS
# ==========================================================
HB_UI_MENU_H=22
HB_UI_MENU_W=84
HB_UI_MENU_LIST=10
HB_UI_INPUT_H=10
HB_UI_INPUT_W=72
HB_UI_PASS_H=10
HB_UI_PASS_W=72
HB_UI_YESNO_H=10
HB_UI_YESNO_W=78
# ==========================================================
# DEFAULT PROFILE PATHS
# ==========================================================
hb_default_profile_paths() {
local paths=(
"/etc/pve"
"/etc/network"
"/etc/hosts"
"/etc/hostname"
"/etc/ssh"
"/etc/systemd/system"
"/etc/modules"
"/etc/modules-load.d"
"/etc/modprobe.d"
"/etc/udev/rules.d"
"/etc/default/grub"
"/etc/fstab"
"/etc/kernel"
"/etc/apt"
"/etc/vzdump.conf"
"/etc/postfix"
"/etc/resolv.conf"
"/etc/timezone"
"/etc/iscsi"
"/etc/multipath"
"/usr/local/bin"
"/usr/local/share/proxmenux"
"/root"
"/etc/cron.d"
"/etc/cron.daily"
"/etc/cron.hourly"
"/etc/cron.weekly"
"/etc/cron.monthly"
"/etc/cron.allow"
"/etc/cron.deny"
"/var/spool/cron/crontabs"
"/var/lib/pve-cluster"
)
if [[ -d /etc/zfs ]] || command -v zpool >/dev/null 2>&1; then
paths+=("/etc/zfs")
fi
printf '%s\n' "${paths[@]}"
}
# ==========================================================
# PATH CLASSIFICATION (restore safety)
# Returns: dangerous | reboot | hot
# ==========================================================
hb_classify_path() {
local rel="$1" # without leading /
case "$rel" in
etc/pve|etc/pve/*|\
var/lib/pve-cluster|var/lib/pve-cluster/*|\
etc/network|etc/network/*)
echo "dangerous" ;;
etc/modules|etc/modules/*|\
etc/modules-load.d|etc/modules-load.d/*|\
etc/modprobe.d|etc/modprobe.d/*|\
etc/udev/rules.d|etc/udev/rules.d/*|\
etc/default/grub|\
etc/fstab|\
etc/kernel|etc/kernel/*|\
etc/iscsi|etc/iscsi/*|\
etc/multipath|etc/multipath/*|\
etc/zfs|etc/zfs/*)
echo "reboot" ;;
*)
echo "hot" ;;
esac
}
hb_path_warning() {
local rel="$1"
case "$rel" in
etc/pve|etc/pve/*)
hb_translate "/etc/pve is managed by pmxcfs (cluster filesystem). Applying this on a running node can corrupt cluster state. Use 'Export to file' and apply it manually during a maintenance window." ;;
var/lib/pve-cluster|var/lib/pve-cluster/*)
hb_translate "/var/lib/pve-cluster is live cluster data. Never restore this while the node is running. Use 'Export to file' for manual recovery only." ;;
etc/network|etc/network/*)
hb_translate "/etc/network controls active interfaces. Applying may immediately change or drop network connectivity, including active SSH sessions." ;;
esac
}
# ==========================================================
# PROFILE PATH SELECTION
# ==========================================================
hb_select_profile_paths() {
local mode="$1"
local __out_var="$2"
local -n __out_ref="$__out_var"
mapfile -t __defaults < <(hb_default_profile_paths)
if [[ "$mode" == "default" ]]; then
__out_ref=("${__defaults[@]}")
return 0
fi
local options=() idx=1 path
for path in "${__defaults[@]}"; do
options+=("$idx" "$path" "off")
((idx++))
done
local selected
selected=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Custom backup profile")" \
--separate-output --checklist \
"$(hb_translate "Select paths to include:")" \
26 86 18 "${options[@]}" 3>&1 1>&2 2>&3) || return 1
__out_ref=()
local choice
while read -r choice; do
[[ -z "$choice" ]] && continue
__out_ref+=("${__defaults[$((choice-1))]}")
done <<< "$selected"
if [[ ${#__out_ref[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Error")" \
--msgbox "$(hb_translate "No paths selected. Select at least one path.")" 8 60
return 1
fi
}
# ==========================================================
# STAGING OPERATIONS
# ==========================================================
hb_prepare_staging() {
local staging_root="$1"; shift
local paths=("$@")
rm -rf "$staging_root"
mkdir -p "$staging_root/rootfs" "$staging_root/metadata"
local selected_file="$staging_root/metadata/selected_paths.txt"
local missing_file="$staging_root/metadata/missing_paths.txt"
: > "$selected_file"
: > "$missing_file"
local p rel target
for p in "${paths[@]}"; do
rel="${p#/}"
echo "$rel" >> "$selected_file"
[[ -e "$p" ]] || { echo "$p" >> "$missing_file"; continue; }
target="$staging_root/rootfs/$rel"
if [[ -d "$p" ]]; then
mkdir -p "$target"
local -a rsync_opts=(
-aAXH --numeric-ids
--exclude "images/"
--exclude "dump/"
--exclude "tmp/"
--exclude "*.log"
)
# /root is included by default for easier recovery, but avoid volatile/sensitive noise.
if [[ "$rel" == "root" || "$rel" == "root/"* ]]; then
rsync_opts+=(
--exclude ".bash_history"
--exclude ".cache/"
--exclude "tmp/"
--exclude ".local/share/Trash/"
)
fi
# Runtime pending-restore data belongs in /var/lib/proxmenux, never in app code tree.
if [[ "$rel" == "usr/local/share/proxmenux" || "$rel" == "usr/local/share/proxmenux/"* ]]; then
rsync_opts+=(
--exclude "restore-pending/"
)
fi
rsync "${rsync_opts[@]}" "$p/" "$target/" 2>/dev/null || true
else
mkdir -p "$(dirname "$target")"
cp -a "$p" "$target" 2>/dev/null || true
fi
done
# Metadata snapshot
local meta="$staging_root/metadata"
{
echo "generated_at=$(date -Iseconds)"
echo "hostname=$(hostname)"
echo "kernel=$(uname -r)"
} > "$meta/run_info.env"
command -v pveversion >/dev/null 2>&1 && pveversion -v > "$meta/pveversion.txt" 2>&1 || true
command -v lsblk >/dev/null 2>&1 && lsblk -f > "$meta/lsblk.txt" 2>&1 || true
command -v qm >/dev/null 2>&1 && qm list > "$meta/qm-list.txt" 2>&1 || true
command -v pct >/dev/null 2>&1 && pct list > "$meta/pct-list.txt" 2>&1 || true
command -v zpool >/dev/null 2>&1 && zpool status > "$meta/zpool.txt" 2>&1 || true
# Manifest + checksums
(
cd "$staging_root/rootfs" || return 1
find . -mindepth 1 -print | sort > "$meta/manifest.txt"
find . -type f -print0 | sort -z | xargs -0 sha256sum 2>/dev/null \
> "$meta/checksums.sha256" || true
)
}
hb_load_restore_paths() {
local restore_root="$1"
local __out_var="$2"
local -n __out="$__out_var"
__out=()
local selected="$restore_root/metadata/selected_paths.txt"
if [[ -f "$selected" ]]; then
while IFS= read -r line; do
[[ -n "$line" ]] && __out+=("$line")
done < "$selected"
fi
# Fallback: scan rootfs
if [[ ${#__out[@]} -eq 0 ]]; then
local p
while IFS= read -r p; do
[[ -n "$p" && -e "$restore_root/rootfs/${p#/}" ]] && __out+=("${p#/}")
done < <(hb_default_profile_paths)
fi
}
# ==========================================================
# PBS CONFIG — auto-detect from storage.cfg + manual
# ==========================================================
hb_collect_pbs_configs() {
HB_PBS_NAMES=()
HB_PBS_REPOS=()
HB_PBS_SECRETS=()
HB_PBS_SOURCES=()
if [[ -f /etc/pve/storage.cfg ]]; then
local current="" server="" datastore="" username="" pw_file pw_val
while IFS= read -r line; do
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" ]] && continue
if [[ $line =~ ^pbs:[[:space:]]*(.+)$ ]]; then
if [[ -n "$current" && -n "$server" && -n "$datastore" && -n "$username" ]]; then
pw_file="/etc/pve/priv/storage/${current}.pw"
pw_val="$([[ -f "$pw_file" ]] && cat "$pw_file" || echo "")"
HB_PBS_NAMES+=("$current")
HB_PBS_REPOS+=("${username}@${server}:${datastore}")
HB_PBS_SECRETS+=("$pw_val")
HB_PBS_SOURCES+=("proxmox")
fi
current="${BASH_REMATCH[1]}"; server="" datastore="" username=""
elif [[ -n "$current" ]]; then
[[ $line =~ ^[[:space:]]+server[[:space:]]+(.+)$ ]] && server="${BASH_REMATCH[1]}"
[[ $line =~ ^[[:space:]]+datastore[[:space:]]+(.+)$ ]] && datastore="${BASH_REMATCH[1]}"
[[ $line =~ ^[[:space:]]+username[[:space:]]+(.+)$ ]] && username="${BASH_REMATCH[1]}"
if [[ $line =~ ^[a-zA-Z]+:[[:space:]] &&
-n "$server" && -n "$datastore" && -n "$username" ]]; then
pw_file="/etc/pve/priv/storage/${current}.pw"
pw_val="$([[ -f "$pw_file" ]] && cat "$pw_file" || echo "")"
HB_PBS_NAMES+=("$current")
HB_PBS_REPOS+=("${username}@${server}:${datastore}")
HB_PBS_SECRETS+=("$pw_val")
HB_PBS_SOURCES+=("proxmox")
current="" server="" datastore="" username=""
fi
fi
done < /etc/pve/storage.cfg
# Last stanza
if [[ -n "$current" && -n "$server" && -n "$datastore" && -n "$username" ]]; then
pw_file="/etc/pve/priv/storage/${current}.pw"
pw_val="$([[ -f "$pw_file" ]] && cat "$pw_file" || echo "")"
HB_PBS_NAMES+=("$current")
HB_PBS_REPOS+=("${username}@${server}:${datastore}")
HB_PBS_SECRETS+=("$pw_val")
HB_PBS_SOURCES+=("proxmox")
fi
fi
# Manual configs
local manual_cfg="$HB_STATE_DIR/pbs-manual-configs.txt"
if [[ -f "$manual_cfg" ]]; then
local line name repo sf
while IFS= read -r line; do
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" ]] && continue
name="${line%%|*}"; repo="${line##*|}"
sf="$HB_STATE_DIR/pbs-pass-${name}.txt"
HB_PBS_NAMES+=("$name"); HB_PBS_REPOS+=("$repo")
HB_PBS_SECRETS+=("$([[ -f "$sf" ]] && cat "$sf" || echo "")")
HB_PBS_SOURCES+=("manual")
done < "$manual_cfg"
fi
}
hb_configure_pbs_manual() {
local name user host datastore repo secret
name=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
--inputbox "$(hb_translate "Configuration name:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "PBS-$(date +%m%d)" 3>&1 1>&2 2>&3) || return 1
[[ -z "$name" ]] && return 1
user=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
--inputbox "$(hb_translate "Username (e.g. root@pam or user@pbs!token):")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "root@pam" 3>&1 1>&2 2>&3) || return 1
host=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
--inputbox "$(hb_translate "PBS host or IP address:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
[[ -z "$host" ]] && return 1
datastore=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
--inputbox "$(hb_translate "Datastore name:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
[[ -z "$datastore" ]] && return 1
secret=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
--insecure --passwordbox "$(hb_translate "Password or API token secret:")" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
repo="${user}@${host}:${datastore}"
mkdir -p "$HB_STATE_DIR"
local cfg_line="${name}|${repo}"
local manual_cfg="$HB_STATE_DIR/pbs-manual-configs.txt"
touch "$manual_cfg"
grep -Fxq "$cfg_line" "$manual_cfg" || echo "$cfg_line" >> "$manual_cfg"
printf '%s' "$secret" > "$HB_STATE_DIR/pbs-pass-${name}.txt"
chmod 600 "$HB_STATE_DIR/pbs-pass-${name}.txt"
HB_PBS_NAME="$name"; HB_PBS_REPOSITORY="$repo"; HB_PBS_SECRET="$secret"
}
hb_select_pbs_repository() {
hb_collect_pbs_configs
local menu=() i=1 idx
for idx in "${!HB_PBS_NAMES[@]}"; do
local src="${HB_PBS_SOURCES[$idx]}"
local label="${HB_PBS_NAMES[$idx]}${HB_PBS_REPOS[$idx]} [$src]"
[[ -z "${HB_PBS_SECRETS[$idx]}" ]] && label+="$(hb_translate "no password")"
menu+=("$i" "$label"); ((i++))
done
menu+=("$i" "$(hb_translate "+ Add new PBS manually")")
local choice
choice=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Select PBS repository")" \
--menu "\n$(hb_translate "Available PBS repositories:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
if [[ "$choice" == "$i" ]]; then
hb_configure_pbs_manual || return 1
else
local sel=$((choice-1))
HB_PBS_NAME="${HB_PBS_NAMES[$sel]}"
export HB_PBS_REPOSITORY="${HB_PBS_REPOS[$sel]}"
HB_PBS_SECRET="${HB_PBS_SECRETS[$sel]}"
if [[ -z "$HB_PBS_SECRET" ]]; then
HB_PBS_SECRET=$(dialog --backtitle "ProxMenux" --title "PBS" \
--insecure --passwordbox \
"$(hb_translate "Password for:") $HB_PBS_NAME" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
mkdir -p "$HB_STATE_DIR"
printf '%s' "$HB_PBS_SECRET" > "$HB_STATE_DIR/pbs-pass-${HB_PBS_NAME}.txt"
chmod 600 "$HB_STATE_DIR/pbs-pass-${HB_PBS_NAME}.txt"
fi
fi
}
hb_ask_pbs_encryption() {
local key_file="$HB_STATE_DIR/pbs-key.conf"
local enc_pass_file="$HB_STATE_DIR/pbs-encryption-pass.txt"
export HB_PBS_KEYFILE_OPT=""
export HB_PBS_ENC_PASS=""
dialog --backtitle "ProxMenux" --title "$(hb_translate "Encryption")" \
--yesno "$(hb_translate "Encrypt this backup with a keyfile?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 0
if [[ -f "$key_file" ]]; then
export HB_PBS_KEYFILE_OPT="--keyfile $key_file"
if [[ -f "$enc_pass_file" ]]; then
HB_PBS_ENC_PASS="$(<"$enc_pass_file")"
export HB_PBS_ENC_PASS
fi
msg_ok "$(hb_translate "Using existing encryption key:") $key_file"
return 0
fi
# No key — offer to create one
dialog --backtitle "ProxMenux" --title "$(hb_translate "Encryption")" \
--yesno "$(hb_translate "No encryption key found. Create one now?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 0
local pass1 pass2
while true; do
pass1=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
"$(hb_translate "Encryption passphrase (separate from PBS password):")" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 0
pass2=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
"$(hb_translate "Confirm encryption passphrase:")" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 0
[[ "$pass1" == "$pass2" ]] && break
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "Passphrases do not match. Try again.")" 8 50
done
msg_info "$(hb_translate "Creating PBS encryption key...")"
if PBS_ENCRYPTION_PASSWORD="$pass1" \
proxmox-backup-client key create "$key_file" >/dev/null 2>&1; then
printf '%s' "$pass1" > "$enc_pass_file"
chmod 600 "$enc_pass_file"
msg_ok "$(hb_translate "Encryption key created:") $key_file"
HB_PBS_KEYFILE_OPT="--keyfile $key_file"
HB_PBS_ENC_PASS="$pass1"
local key_warn_msg
key_warn_msg="$(hb_translate "IMPORTANT: Back up this key file. Without it the backup cannot be restored.")"$'\n\n'"$(hb_translate "Key:") $key_file"
dialog --backtitle "ProxMenux" --msgbox \
"$key_warn_msg" \
10 74
else
msg_error "$(hb_translate "Failed to create encryption key. Backup will proceed without encryption.")"
fi
}
# ==========================================================
# BORG
# ==========================================================
hb_ensure_borg() {
command -v borg >/dev/null 2>&1 && { echo "borg"; return 0; }
local appimage="$HB_STATE_DIR/borg"
local tmp_file
[[ -x "$appimage" ]] && { echo "$appimage"; return 0; }
command -v sha256sum >/dev/null 2>&1 || {
msg_error "$(hb_translate "sha256sum not found. Cannot verify Borg binary.")"
return 1
}
msg_info "$(hb_translate "Borg not found. Downloading borg") ${HB_BORG_VERSION}..."
mkdir -p "$HB_STATE_DIR"
tmp_file=$(mktemp "$HB_STATE_DIR/.borg-download.XXXXXX") || return 1
if wget -qO "$tmp_file" "$HB_BORG_LINUX64_URL"; then
if echo "${HB_BORG_LINUX64_SHA256} $tmp_file" | sha256sum -c - >/dev/null 2>&1; then
mv -f "$tmp_file" "$appimage"
else
rm -f "$tmp_file"
msg_error "$(hb_translate "Borg binary checksum verification failed.")"
return 1
fi
chmod +x "$appimage"
msg_ok "$(hb_translate "Borg ready.")"
echo "$appimage"; return 0
fi
rm -f "$tmp_file"
msg_error "$(hb_translate "Failed to download Borg.")"
return 1
}
hb_borg_init_if_needed() {
local borg_bin="$1" repo="$2" encrypt_mode="$3"
"$borg_bin" list "$repo" >/dev/null 2>&1 && return 0
if "$borg_bin" help repo-create >/dev/null 2>&1; then
"$borg_bin" repo-create -e "$encrypt_mode" "$repo"
else
"$borg_bin" init --encryption="$encrypt_mode" "$repo"
fi
}
hb_prepare_borg_passphrase() {
local pass_file="$HB_STATE_DIR/borg-pass.txt"
BORG_ENCRYPT_MODE="none"
unset BORG_PASSPHRASE
if [[ -f "$pass_file" ]]; then
export BORG_PASSPHRASE
BORG_PASSPHRASE="$(<"$pass_file")"
BORG_ENCRYPT_MODE="repokey"
return 0
fi
dialog --backtitle "ProxMenux" --title "$(hb_translate "Borg encryption")" \
--yesno "$(hb_translate "Encrypt this Borg repository?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 0
local pass1 pass2
while true; do
pass1=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
"$(hb_translate "Borg passphrase:")" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
pass2=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
"$(hb_translate "Confirm Borg passphrase:")" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
[[ "$pass1" == "$pass2" ]] && break
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "Passphrases do not match.")" 8 50
done
mkdir -p "$HB_STATE_DIR"
printf '%s' "$pass1" > "$pass_file"
chmod 600 "$pass_file"
export BORG_PASSPHRASE="$pass1"
export BORG_ENCRYPT_MODE="repokey"
}
hb_select_borg_repo() {
local _borg_repo_var="$1"
local -n _borg_repo_ref="$_borg_repo_var"
local type
type=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Borg repository location")" \
--menu "\n$(hb_translate "Select repository destination:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"local" "$(hb_translate 'Local directory')" \
"usb" "$(hb_translate 'Mounted external disk')" \
"remote" "$(hb_translate 'Remote server via SSH')" \
3>&1 1>&2 2>&3) || return 1
unset BORG_RSH
case "$type" in
local)
_borg_repo_ref=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Borg repository path:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
3>&1 1>&2 2>&3) || return 1
mkdir -p "$_borg_repo_ref" 2>/dev/null || true
;;
usb)
local mnt
mnt=$(hb_prompt_mounted_path "/mnt/backup") || return 1
_borg_repo_ref="$mnt/borgbackup"
mkdir -p "$_borg_repo_ref" 2>/dev/null || true
;;
remote)
local user host rpath ssh_key
user=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH user:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "root" 3>&1 1>&2 2>&3) || return 1
host=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH host or IP:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
rpath=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Remote repository path:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
3>&1 1>&2 2>&3) || return 1
if dialog --backtitle "ProxMenux" \
--yesno "$(hb_translate "Use a custom SSH key?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W"; then
ssh_key=$(dialog --backtitle "ProxMenux" \
--fselect "$HOME/.ssh/" 12 70 3>&1 1>&2 2>&3) || return 1
export BORG_RSH="ssh -i $ssh_key -o StrictHostKeyChecking=accept-new"
fi
_borg_repo_ref="ssh://$user@$host/$rpath"
;;
esac
}
# ==========================================================
# COMMON PROMPTS
# ==========================================================
hb_trim_dialog_value() {
local value="$1"
value="${value//$'\r'/}"
value="${value//$'\n'/}"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
hb_prompt_mounted_path() {
local default_path="${1:-/mnt/backup}"
local out
out=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Mounted disk path")" \
--inputbox "$(hb_translate "Path where the external disk is mounted:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "$default_path" 3>&1 1>&2 2>&3) || return 1
out=$(hb_trim_dialog_value "$out")
[[ -n "$out" && -d "$out" ]] || { msg_error "$(hb_translate "Path does not exist.")"; return 1; }
if ! mountpoint -q "$out" 2>/dev/null; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Warning")" \
--yesno "$(hb_translate "This path is not a registered mount point. Use it anyway?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 1
fi
echo "$out"
}
hb_prompt_dest_dir() {
local selection out
selection=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Select destination")" \
--menu "\n$(hb_translate "Choose where to save the backup:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"vzdump" "$(hb_translate '/var/lib/vz/dump (Proxmox default vzdump path)')" \
"backup" "$(hb_translate '/backup')" \
"local" "$(hb_translate 'Custom local directory')" \
"usb" "$(hb_translate 'Mounted external disk')" \
3>&1 1>&2 2>&3) || return 1
case "$selection" in
vzdump) out="/var/lib/vz/dump" ;;
backup) out="/backup" ;;
local)
out=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Enter directory path:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup" 3>&1 1>&2 2>&3) || return 1
;;
usb) out=$(hb_prompt_mounted_path "/mnt/backup") || return 1 ;;
esac
out=$(hb_trim_dialog_value "$out")
[[ -n "$out" ]] || return 1
mkdir -p "$out" || { msg_error "$(hb_translate "Cannot create:") $out"; return 1; }
echo "$out"
}
hb_prompt_restore_source_dir() {
local choice out
choice=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Restore source location")" \
--menu "\n$(hb_translate "Where are the backup archives stored?")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"vzdump" "$(hb_translate '/var/lib/vz/dump (Proxmox default)')" \
"backup" "$(hb_translate '/backup')" \
"usb" "$(hb_translate 'Mounted external disk')" \
"custom" "$(hb_translate 'Custom path')" \
3>&1 1>&2 2>&3) || return 1
case "$choice" in
vzdump) out="/var/lib/vz/dump" ;;
backup) out="/backup" ;;
usb) out=$(hb_prompt_mounted_path "/mnt/backup") || return 1 ;;
custom)
out=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Enter path:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup" 3>&1 1>&2 2>&3) || return 1
;;
esac
out=$(hb_trim_dialog_value "$out")
[[ -n "$out" && -d "$out" ]] || {
msg_error "$(hb_translate "Directory does not exist.")"
return 1
}
echo "$out"
}
hb_prompt_local_archive() {
local base_dir="$1"
local title="${2:-$(hb_translate "Select backup archive")}"
local -a rows=() files=() menu=()
# Single find pass using -printf: no per-file stat subprocesses.
# maxdepth 6 catches nested backup layouts commonly used in /var/lib/vz/dump.
mapfile -t rows < <(
find "$base_dir" -maxdepth 6 -type f \
\( -name '*.tar.zst' -o -name '*.tar.gz' -o -name '*.tar' \) \
-printf '%T@|%s|%p\n' 2>/dev/null \
| sort -t'|' -k1,1nr \
| head -200
)
if [[ ${#rows[@]} -eq 0 ]]; then
local no_backups_msg
no_backups_msg="$(hb_translate "No backup archives were found in:") $base_dir"$'\n\n'"$(hb_translate "Select another source path and try again.")"
dialog --backtitle "ProxMenux" \
--title "$(hb_translate "No backups found")" \
--msgbox "$no_backups_msg" \
10 78 || true
return 1
fi
local i=1 row epoch size path date_str size_str label
for row in "${rows[@]}"; do
epoch="${row%%|*}"; row="${row#*|}"
size="${row%%|*}"; path="${row#*|}"
epoch="${epoch%%.*}" # drop sub-second fraction from %T@
date_str=$(date -d "@$epoch" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "-")
size_str=$(numfmt --to=iec-i --suffix=B "$size" 2>/dev/null || echo "${size}B")
label="${path#$base_dir/} $date_str $size_str"
files+=("$path"); menu+=("$i" "$label"); ((i++))
done
local choice
choice=$(dialog --backtitle "ProxMenux" --title "$title" \
--menu "\n$(hb_translate "Detected backups — newest first:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
echo "${files[$((choice-1))]}"
}
# ==========================================================
# UTILITIES
# ==========================================================
hb_human_elapsed() {
local secs="$1"
if (( secs < 60 )); then printf '%ds' "$secs"
elif (( secs < 3600 )); then printf '%dm %ds' "$((secs/60))" "$((secs%60))"
else printf '%dh %dm' "$((secs/3600))" "$(( (secs%3600)/60 ))"
fi
}
hb_file_size() {
local path="$1"
if [[ -f "$path" ]]; then
numfmt --to=iec-i --suffix=B "$(stat -c %s "$path" 2>/dev/null || echo 0)" 2>/dev/null \
|| du -sh "$path" 2>/dev/null | awk '{print $1}'
elif [[ -d "$path" ]]; then
du -sh "$path" 2>/dev/null | awk '{print $1}'
else
echo "-"
fi
}
hb_show_log() {
local logfile="$1" title="${2:-$(hb_translate "Operation log")}"
[[ -f "$logfile" && -s "$logfile" ]] || return 0
dialog --backtitle "ProxMenux" --exit-label "OK" \
--title "$title" --textbox "$logfile" 26 110 || true
}
hb_require_cmd() {
local cmd="$1" pkg="${2:-$1}"
command -v "$cmd" >/dev/null 2>&1 && return 0
if command -v apt-get >/dev/null 2>&1; then
msg_warn "$(hb_translate "Installing dependency:") $pkg"
apt-get update -qq >/dev/null 2>&1 && apt-get install -y "$pkg" >/dev/null 2>&1
fi
command -v "$cmd" >/dev/null 2>&1
}
@@ -0,0 +1,243 @@
#!/bin/bash
# ==========================================================
# ProxMenux - Run Scheduled Host Backup Job
# ==========================================================
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)"
LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts"
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
elif [[ ! -f "$UTILS_FILE" ]]; then
UTILS_FILE="$BASE_DIR/utils.sh"
fi
if [[ -f "$UTILS_FILE" ]]; then
# shellcheck source=/dev/null
source "$UTILS_FILE"
else
echo "ERROR: utils.sh not found" >&2
exit 1
fi
LIB_FILE="$SCRIPT_DIR/lib_host_backup_common.sh"
[[ ! -f "$LIB_FILE" ]] && LIB_FILE="$LOCAL_SCRIPTS_DEFAULT/backup_restore/lib_host_backup_common.sh"
if [[ -f "$LIB_FILE" ]]; then
# shellcheck source=/dev/null
source "$LIB_FILE"
else
echo "ERROR: lib_host_backup_common.sh not found" >&2
exit 1
fi
JOBS_DIR="${PMX_BACKUP_JOBS_DIR:-/var/lib/proxmenux/backup-jobs}"
LOG_DIR="${PMX_BACKUP_LOG_DIR:-/var/log/proxmenux/backup-jobs}"
LOCK_DIR="${PMX_BACKUP_LOCK_DIR:-/var/lock}"
mkdir -p "$JOBS_DIR" "$LOG_DIR" >/dev/null 2>&1 || true
_sb_prune_local() {
local job_id="$1"
local dest_dir="$2"
local ext="$3" # tar.zst or tar.gz
local keep_last="${KEEP_LAST:-0}"
local -a files=()
mapfile -t files < <(find "$dest_dir" -maxdepth 1 -type f -name "${job_id}-*.${ext}" | sort -r)
[[ ${#files[@]} -eq 0 ]] && return 0
if [[ "$keep_last" =~ ^[0-9]+$ ]] && (( keep_last > 0 )); then
local idx=0
for f in "${files[@]}"; do
idx=$((idx+1))
(( idx <= keep_last )) && continue
rm -f "$f" || true
done
fi
}
_sb_run_local() {
local stage_root="$1"
local job_id="$2"
local ts="$3"
local dest_dir="$4"
local archive_ext="${LOCAL_ARCHIVE_EXT:-tar.zst}"
local archive="${dest_dir}/${job_id}-${ts}.${archive_ext}"
mkdir -p "$dest_dir" || return 1
if [[ "$archive_ext" == "tar.zst" ]] && command -v zstd >/dev/null 2>&1; then
tar --zstd -cf "$archive" -C "$stage_root" . >/dev/null 2>&1 || return 1
else
archive="${dest_dir}/${job_id}-${ts}.tar.gz"
tar -czf "$archive" -C "$stage_root" . >/dev/null 2>&1 || return 1
archive_ext="tar.gz"
fi
_sb_prune_local "$job_id" "$dest_dir" "$archive_ext"
echo "LOCAL_ARCHIVE=$archive"
return 0
}
_sb_run_borg() {
local stage_root="$1"
local archive_name="$2"
local borg_bin repo passphrase
borg_bin=$(hb_ensure_borg) || return 1
repo="${BORG_REPO:-}"
passphrase="${BORG_PASSPHRASE:-}"
[[ -z "$repo" || -z "$passphrase" ]] && return 1
export BORG_PASSPHRASE="$passphrase"
if ! hb_borg_init_if_needed "$borg_bin" "$repo" "${BORG_ENCRYPT_MODE:-none}" >/dev/null 2>&1; then
return 1
fi
(cd "$stage_root" && "$borg_bin" create --stats \
"${repo}::${archive_name}" rootfs metadata) >/dev/null 2>&1 || return 1
"$borg_bin" prune -v --list "$repo" \
${KEEP_LAST:+--keep-last "$KEEP_LAST"} \
${KEEP_HOURLY:+--keep-hourly "$KEEP_HOURLY"} \
${KEEP_DAILY:+--keep-daily "$KEEP_DAILY"} \
${KEEP_WEEKLY:+--keep-weekly "$KEEP_WEEKLY"} \
${KEEP_MONTHLY:+--keep-monthly "$KEEP_MONTHLY"} \
${KEEP_YEARLY:+--keep-yearly "$KEEP_YEARLY"} \
>/dev/null 2>&1 || true
echo "BORG_ARCHIVE=${archive_name}"
return 0
}
_sb_run_pbs() {
local stage_root="$1"
local backup_id="$2"
local epoch="$3"
local -a cmd=(
proxmox-backup-client backup
"hostcfg.pxar:${stage_root}/rootfs"
--repository "$PBS_REPOSITORY"
--backup-type host
--backup-id "$backup_id"
--backup-time "$epoch"
)
[[ -z "${PBS_REPOSITORY:-}" || -z "${PBS_PASSWORD:-}" ]] && return 1
if [[ -n "${PBS_KEYFILE:-}" ]]; then
cmd+=(--keyfile "$PBS_KEYFILE")
fi
env PBS_PASSWORD="$PBS_PASSWORD" PBS_ENCRYPTION_PASSWORD="${PBS_ENCRYPTION_PASSWORD:-}" \
"${cmd[@]}" >/dev/null 2>&1 || return 1
# Best effort prune for PBS group.
proxmox-backup-client prune "host/${backup_id}" --repository "$PBS_REPOSITORY" \
${KEEP_LAST:+--keep-last "$KEEP_LAST"} \
${KEEP_HOURLY:+--keep-hourly "$KEEP_HOURLY"} \
${KEEP_DAILY:+--keep-daily "$KEEP_DAILY"} \
${KEEP_WEEKLY:+--keep-weekly "$KEEP_WEEKLY"} \
${KEEP_MONTHLY:+--keep-monthly "$KEEP_MONTHLY"} \
${KEEP_YEARLY:+--keep-yearly "$KEEP_YEARLY"} \
>/dev/null 2>&1 || true
echo "PBS_SNAPSHOT=host/${backup_id}/${epoch}"
return 0
}
main() {
local job_id="${1:-}"
[[ -z "$job_id" ]] && { echo "Usage: $0 <job_id>" >&2; exit 1; }
local job_file="${JOBS_DIR}/${job_id}.env"
[[ -f "$job_file" ]] || { echo "Job not found: $job_id" >&2; exit 1; }
# shellcheck source=/dev/null
source "$job_file"
local lock_file="${LOCK_DIR}/proxmenux-backup-${job_id}.lock"
if command -v flock >/dev/null 2>&1; then
exec 9>"$lock_file" || exit 1
if ! flock -n 9; then
echo "Another run is active for job ${job_id}" >&2
exit 1
fi
fi
local ts log_file stage_root summary_file
ts="$(date +%Y%m%d_%H%M%S)"
log_file="${LOG_DIR}/${job_id}-${ts}.log"
summary_file="${LOG_DIR}/${job_id}-last.status"
stage_root="$(mktemp -d /tmp/proxmenux-sched-stage.XXXXXX)"
{
echo "JOB_ID=${job_id}"
echo "RUN_AT=$(date -Iseconds)"
echo "BACKEND=${BACKEND:-}"
echo "PROFILE_MODE=${PROFILE_MODE:-default}"
} >"$summary_file"
{
echo "=== Scheduled backup job ${job_id} started at $(date -Iseconds) ==="
echo "Backend: ${BACKEND:-}"
} >"$log_file"
local -a paths=()
if [[ "${PROFILE_MODE:-default}" == "custom" && -f "${JOBS_DIR}/${job_id}.paths" ]]; then
mapfile -t paths < "${JOBS_DIR}/${job_id}.paths"
else
mapfile -t paths < <(hb_default_profile_paths)
fi
if [[ ${#paths[@]} -eq 0 ]]; then
echo "No paths configured for job" >>"$log_file"
echo "RESULT=failed" >>"$summary_file"
rm -rf "$stage_root"
exit 1
fi
hb_prepare_staging "$stage_root" "${paths[@]}" >>"$log_file" 2>&1
local rc=1
case "${BACKEND:-}" in
local)
_sb_run_local "$stage_root" "$job_id" "$ts" "${LOCAL_DEST_DIR:-/var/lib/vz/dump}" >>"$log_file" 2>&1
rc=$?
;;
borg)
_sb_run_borg "$stage_root" "${job_id}-${ts}" >>"$log_file" 2>&1
rc=$?
;;
pbs)
_sb_run_pbs "$stage_root" "${PBS_BACKUP_ID:-hostcfg-$(hostname)}" "$(date +%s)" >>"$log_file" 2>&1
rc=$?
;;
*)
echo "Unknown backend: ${BACKEND:-}" >>"$log_file"
rc=1
;;
esac
rm -rf "$stage_root"
if [[ $rc -eq 0 ]]; then
echo "RESULT=ok" >>"$summary_file"
echo "LOG_FILE=${log_file}" >>"$summary_file"
echo "=== Job finished OK at $(date -Iseconds) ===" >>"$log_file"
exit 0
else
echo "RESULT=failed" >>"$summary_file"
echo "LOG_FILE=${log_file}" >>"$summary_file"
echo "=== Job finished with errors at $(date -Iseconds) ===" >>"$log_file"
exit 1
fi
}
main "$@"
@@ -0,0 +1,284 @@
#!/bin/bash
# ==========================================================
# ProxMenux - Backup/Restore Test Matrix (non-destructive)
# ==========================================================
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RUNNER="${SCRIPT_DIR}/run_scheduled_backup.sh"
APPLY_ONBOOT="${SCRIPT_DIR}/apply_pending_restore.sh"
HOST_SCRIPT="${SCRIPT_DIR}/backup_host.sh"
LIB_SCRIPT="${SCRIPT_DIR}/lib_host_backup_common.sh"
SCHED_SCRIPT="${SCRIPT_DIR}/backup_scheduler.sh"
KEEP_TMP=0
if [[ "${1:-}" == "--keep-tmp" ]]; then
KEEP_TMP=1
fi
TMP_ROOT="$(mktemp -d /tmp/proxmenux-brtest.XXXXXX)"
REPORT_FILE="/tmp/proxmenux-backup-restore-test-$(date +%Y%m%d_%H%M%S).log"
PASS=0
FAIL=0
SKIP=0
log() {
echo "$*" | tee -a "$REPORT_FILE"
}
pass() {
PASS=$((PASS + 1))
log "[PASS] $*"
}
fail() {
FAIL=$((FAIL + 1))
log "[FAIL] $*"
}
skip() {
SKIP=$((SKIP + 1))
log "[SKIP] $*"
}
cleanup() {
if [[ "$KEEP_TMP" -eq 0 ]]; then
rm -rf "$TMP_ROOT"
else
log "[INFO] Temp root preserved: $TMP_ROOT"
fi
}
trap cleanup EXIT
assert_file_contains() {
local file="$1"
local needle="$2"
if [[ -f "$file" ]] && grep -q "$needle" "$file"; then
return 0
fi
return 1
}
run_cmd_expect_ok() {
local desc="$1"
shift
if "$@" >>"$REPORT_FILE" 2>&1; then
pass "$desc"
return 0
fi
fail "$desc"
return 1
}
run_cmd_expect_fail() {
local desc="$1"
shift
if "$@" >>"$REPORT_FILE" 2>&1; then
fail "$desc"
return 1
fi
pass "$desc"
return 0
}
syntax_tests() {
log "\n=== Syntax checks ==="
run_cmd_expect_ok "bash -n backup_host.sh" bash -n "$HOST_SCRIPT"
run_cmd_expect_ok "bash -n lib_host_backup_common.sh" bash -n "$LIB_SCRIPT"
run_cmd_expect_ok "bash -n backup_scheduler.sh" bash -n "$SCHED_SCRIPT"
run_cmd_expect_ok "bash -n run_scheduled_backup.sh" bash -n "$RUNNER"
run_cmd_expect_ok "bash -n apply_pending_restore.sh" bash -n "$APPLY_ONBOOT"
}
scheduler_e2e_tests() {
log "\n=== Scheduler E2E (sandbox) ==="
if ! help mapfile >/dev/null 2>&1; then
skip "Scheduler E2E skipped: current bash does not provide mapfile (requires bash >= 4)."
return
fi
local jobs_dir="$TMP_ROOT/backup-jobs"
local logs_dir="$TMP_ROOT/backup-jobs-logs"
local lock_dir="$TMP_ROOT/locks"
local archives_dir="$TMP_ROOT/archives"
mkdir -p "$jobs_dir" "$logs_dir" "$lock_dir" "$archives_dir"
cat > "$jobs_dir/t1.env" <<EOJ
JOB_ID=t1
BACKEND=local
PROFILE_MODE=custom
LOCAL_DEST_DIR=${archives_dir}
LOCAL_ARCHIVE_EXT=tar.gz
KEEP_LAST=2
KEEP_HOURLY=0
KEEP_DAILY=0
KEEP_WEEKLY=0
KEEP_MONTHLY=0
KEEP_YEARLY=0
EOJ
cat > "$jobs_dir/t1.paths" <<EOP
/etc/hosts
/etc/resolv.conf
EOP
local i
for i in 1 2 3; do
if PMX_BACKUP_JOBS_DIR="$jobs_dir" PMX_BACKUP_LOG_DIR="$logs_dir" PMX_BACKUP_LOCK_DIR="$lock_dir" \
bash "$RUNNER" t1 >>"$REPORT_FILE" 2>&1; then
:
else
fail "Runner execution #$i for t1"
return
fi
sleep 1
done
local archive_count
archive_count="$(find "$archives_dir" -maxdepth 1 -type f -name 't1-*.tar.gz' | wc -l | tr -d ' ')"
if [[ "$archive_count" == "2" ]]; then
pass "Retention KEEP_LAST=2 keeps exactly 2 archives"
else
fail "Retention expected 2 archives, got $archive_count"
fi
if assert_file_contains "$logs_dir/t1-last.status" "RESULT=ok"; then
pass "t1-last.status reports RESULT=ok"
else
fail "t1-last.status does not report RESULT=ok"
fi
cat > "$jobs_dir/tbad.env" <<EOJ
JOB_ID=tbad
BACKEND=invalid
PROFILE_MODE=custom
KEEP_LAST=1
EOJ
echo "/etc/hosts" > "$jobs_dir/tbad.paths"
run_cmd_expect_fail "Invalid backend fails" \
env PMX_BACKUP_JOBS_DIR="$jobs_dir" PMX_BACKUP_LOG_DIR="$logs_dir" PMX_BACKUP_LOCK_DIR="$lock_dir" \
bash "$RUNNER" tbad
if assert_file_contains "$logs_dir/tbad-last.status" "RESULT=failed"; then
pass "tbad-last.status reports RESULT=failed"
else
fail "tbad-last.status does not report RESULT=failed"
fi
cat > "$jobs_dir/tempty.env" <<EOJ
JOB_ID=tempty
BACKEND=local
PROFILE_MODE=custom
LOCAL_DEST_DIR=${archives_dir}
LOCAL_ARCHIVE_EXT=tar.gz
KEEP_LAST=1
EOJ
: > "$jobs_dir/tempty.paths"
run_cmd_expect_fail "Empty paths fails" \
env PMX_BACKUP_JOBS_DIR="$jobs_dir" PMX_BACKUP_LOG_DIR="$logs_dir" PMX_BACKUP_LOCK_DIR="$lock_dir" \
bash "$RUNNER" tempty
if assert_file_contains "$logs_dir/tempty-last.status" "RESULT=failed"; then
pass "tempty-last.status reports RESULT=failed"
else
fail "tempty-last.status does not report RESULT=failed"
fi
}
pending_restore_tests() {
log "\n=== Pending restore E2E (sandbox) ==="
local pending_base="$TMP_ROOT/restore-pending"
local logs_dir="$TMP_ROOT/restore-logs"
local target_root="$TMP_ROOT/target"
local pre_backup_base="$TMP_ROOT/pre-restore"
local recovery_base="$TMP_ROOT/recovery"
mkdir -p "$pending_base/r1/rootfs/etc/pve" "$pending_base/r1/rootfs/etc/zfs" "$pending_base/r1/rootfs/etc" "$target_root/etc"
echo "new-value" > "$pending_base/r1/rootfs/etc/test.conf"
echo "cluster-data" > "$pending_base/r1/rootfs/etc/pve/cluster.cfg"
echo "zfs-data" > "$pending_base/r1/rootfs/etc/zfs/zpool.cache"
echo "old-value" > "$target_root/etc/test.conf"
cat > "$pending_base/r1/apply-on-boot.list" <<EOL
etc/test.conf
etc/pve/cluster.cfg
etc/zfs/zpool.cache
EOL
cat > "$pending_base/r1/plan.env" <<EOP
HB_RESTORE_INCLUDE_ZFS=0
EOP
ln -sfn "$pending_base/r1" "$pending_base/current"
if PMX_RESTORE_PENDING_BASE="$pending_base" PMX_RESTORE_LOG_DIR="$logs_dir" \
PMX_RESTORE_DEST_PREFIX="$target_root" PMX_RESTORE_PRE_BACKUP_BASE="$pre_backup_base" \
PMX_RESTORE_RECOVERY_BASE="$recovery_base" \
bash "$APPLY_ONBOOT" >>"$REPORT_FILE" 2>&1; then
pass "apply_pending_restore completes"
else
fail "apply_pending_restore completes"
return
fi
if assert_file_contains "$target_root/etc/test.conf" "new-value"; then
pass "Regular file restored into target prefix"
else
fail "Regular file was not restored"
fi
if [[ -e "$target_root/etc/pve/cluster.cfg" ]]; then
fail "Cluster file should not be restored live"
else
pass "Cluster file skipped from live restore"
fi
if find "$recovery_base" -type f -name cluster.cfg 2>/dev/null | grep -q .; then
pass "Cluster file extracted to recovery directory"
else
fail "Cluster file not found in recovery directory"
fi
if assert_file_contains "$pending_base/completed/r1/state" "completed"; then
pass "Pending restore state marked completed"
else
fail "Pending restore state not marked completed"
fi
if [[ -e "$pending_base/current" ]]; then
fail "current symlink should be removed"
else
pass "current symlink removed"
fi
}
main() {
log "ProxMenux backup/restore test matrix"
log "Report: $REPORT_FILE"
log "Temp root: $TMP_ROOT"
syntax_tests
scheduler_e2e_tests
pending_restore_tests
log "\n=== Summary ==="
log "PASS=$PASS"
log "FAIL=$FAIL"
log "SKIP=$SKIP"
if [[ "$FAIL" -eq 0 ]]; then
log "RESULT=OK"
exit 0
else
log "RESULT=FAILED"
exit 1
fi
}
main "$@"
-204
View File
@@ -1,204 +0,0 @@
#!/bin/bash
# ==========================================================
# ProxMenux - A menu-driven script for Proxmox VE management
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.1
# Last Updated: 17/08/2025
# ==========================================================
# Description:
# This script automates the process of enabling and configuring Intel Integrated GPU (iGPU) support in Proxmox VE LXC containers.
# Its goal is to simplify the configuration of hardware-accelerated graphical capabilities within containers, allowing for efficient
# use of Intel iGPUs for tasks such as transcoding, rendering, and accelerating graphics-intensive applications.
# ==========================================================
# Configuration ============================================
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
# ==========================================================
select_container() {
CONTAINERS=$(pct list | awk 'NR>1 {print $1, $3}' | xargs -n2)
if [ -z "$CONTAINERS" ]; then
msg_error "$(translate 'No containers available in Proxmox.')"
exit 1
fi
CONTAINER_ID=$(whiptail --title "$(translate 'Select Container')" \
--menu "$(translate 'Select the LXC container:')" 20 70 10 $CONTAINERS 3>&1 1>&2 2>&3)
if [ -z "$CONTAINER_ID" ]; then
msg_error "$(translate 'No container selected. Exiting.')"
exit 1
fi
if ! pct list | awk 'NR>1 {print $1}' | grep -qw "$CONTAINER_ID"; then
msg_error "$(translate 'Container with ID') $CONTAINER_ID $(translate 'does not exist. Exiting.')"
exit 1
fi
msg_ok "$(translate 'Container selected:') $CONTAINER_ID"
}
validate_container_id() {
if [ -z "$CONTAINER_ID" ]; then
msg_error "$(translate 'Container ID not defined. Make sure to select a container first.')"
exit 1
fi
if pct status "$CONTAINER_ID" | grep -q "running"; then
msg_info "$(translate 'Stopping the container before applying configuration...')"
pct stop "$CONTAINER_ID"
msg_ok "$(translate 'Container stopped.')"
fi
}
configure_lxc_for_igpu() {
validate_container_id
CONFIG_FILE="/etc/pve/lxc/${CONTAINER_ID}.conf"
[[ -f "$CONFIG_FILE" ]] || { msg_error "$(translate 'Configuration file for container') $CONTAINER_ID $(translate 'not found.')"; exit 1; }
if [[ ! -d /dev/dri ]]; then
modprobe i915 2>/dev/null || true
for _ in {1..5}; do
[[ -d /dev/dri ]] && break
sleep 1
done
fi
CT_TYPE=$(pct config "$CONTAINER_ID" | awk '/^unprivileged:/ {print $2}')
[[ -z "$CT_TYPE" ]] && CT_TYPE="0"
msg_info "$(translate 'Configuring Intel iGPU passthrough for container...')"
for rn in /dev/dri/renderD*; do
[[ -e "$rn" ]] || continue
chmod 660 "$rn" 2>/dev/null || true
chgrp render "$rn" 2>/dev/null || true
done
mapfile -t RENDER_NODES < <(find /dev/dri -maxdepth 1 -type c -name 'renderD*' 2>/dev/null || true)
mapfile -t CARD_NODES < <(find /dev/dri -maxdepth 1 -type c -name 'card*' 2>/dev/null || true)
FB_NODE=""
[[ -e /dev/fb0 ]] && FB_NODE="/dev/fb0"
if [[ ${#RENDER_NODES[@]} -eq 0 && ${#CARD_NODES[@]} -eq 0 && -z "$FB_NODE" ]]; then
msg_warn "$(translate 'No VA-API devices found on host (/dev/dri*, /dev/fb0). Is i915 loaded?')"
return 0
fi
if grep -q '^features:' "$CONFIG_FILE"; then
grep -Eq '^features:.*(^|,)\s*nesting=1(\s|,|$)' "$CONFIG_FILE" || sed -i 's/^features:\s*/&nesting=1, /' "$CONFIG_FILE"
else
echo "features: nesting=1" >> "$CONFIG_FILE"
fi
if [[ "$CT_TYPE" == "0" ]]; then
sed -i '/^lxc\.cgroup2\.devices\.allow:\s*c\s*226:/d' "$CONFIG_FILE"
sed -i '\|^lxc\.mount\.entry:\s*/dev/dri|d' "$CONFIG_FILE"
sed -i '\|^lxc\.mount\.entry:\s*/dev/fb0|d' "$CONFIG_FILE"
echo "lxc.cgroup2.devices.allow: c 226:* rwm" >> "$CONFIG_FILE"
echo "lxc.mount.entry: /dev/dri dev/dri none bind,optional,create=dir" >> "$CONFIG_FILE"
[[ -n "$FB_NODE" ]] && echo "lxc.mount.entry: /dev/fb0 dev/fb0 none bind,optional,create=file" >> "$CONFIG_FILE"
else
sed -i '/^dev[0-9]\+:/d' "$CONFIG_FILE"
idx=0
for c in "${CARD_NODES[@]}"; do
echo "dev${idx}: $c,gid=44" >> "$CONFIG_FILE"
idx=$((idx+1))
done
for r in "${RENDER_NODES[@]}"; do
echo "dev${idx}: $r,gid=104" >> "$CONFIG_FILE"
idx=$((idx+1))
done
fi
msg_ok "$(translate 'iGPU configuration added to container') $CONTAINER_ID."
}
install_igpu_in_container() {
msg_info2 "$(translate 'Installing iGPU drivers inside the container...')"
tput sc
LOG_FILE=$(mktemp)
pct start "$CONTAINER_ID" >/dev/null 2>&1
script -q -c "pct exec \"$CONTAINER_ID\" -- bash -c '
set -e
getent group video >/dev/null || groupadd -g 44 video
getent group render >/dev/null || groupadd -g 104 render
usermod -aG video,render root || true
apt-get update >/dev/null 2>&1
apt-get install -y va-driver-all ocl-icd-libopencl1 intel-opencl-icd vainfo intel-gpu-tools
chgrp video /dev/dri 2>/dev/null || true
chmod 755 /dev/dri 2>/dev/null || true
'" "$LOG_FILE"
if [ $? -eq 0 ]; then
tput rc
tput ed
rm -f "$LOG_FILE"
msg_ok "$(translate 'iGPU drivers installed inside the container.')"
else
tput rc
tput ed
msg_error "$(translate 'Failed to install iGPU drivers inside the container.')"
cat "$LOG_FILE"
rm -f "$LOG_FILE"
exit 1
fi
}
select_container
show_proxmenux_logo
msg_title "$(translate "Add HW iGPU acceleration to an LXC")"
configure_lxc_for_igpu
install_igpu_in_container
msg_success "$(translate 'iGPU configuration completed in container') $CONTAINER_ID."
echo -e
msg_success "$(translate "Press Enter to return to menu...")"
read -r
-368
View File
@@ -1,368 +0,0 @@
#!/bin/bash
# ==========================================================
# ProxMenux - A menu-driven script for Proxmox VE management
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.0
# Last Updated: 28/01/2025
# ==========================================================
# Description:
# This script allows users to assign physical disks to existing
# Proxmox virtual machines (VMs) through an interactive menu.
# - Detects the system disk and excludes it from selection.
# - Lists all available VMs for the user to choose from.
# - Identifies and displays unassigned physical disks.
# - Allows the user to select multiple disks and attach them to a VM.
# - Supports interface types: SATA, SCSI, VirtIO, and IDE.
# - Ensures that disks are not already assigned to active VMs.
# - Warns about disk sharing between multiple VMs to avoid data corruption.
# - Configures the selected disks for the VM and verifies the assignment.
#
# The goal of this script is to simplify the process of assigning
# physical disks to Proxmox VMs, reducing manual configurations
# and preventing potential errors.
# ==========================================================
# Configuration ============================================
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
show_proxmenux_logo
# ==========================================================
get_disk_info() {
local disk=$1
MODEL=$(lsblk -dn -o MODEL "$disk" | xargs)
SIZE=$(lsblk -dn -o SIZE "$disk" | xargs)
echo "$MODEL" "$SIZE"
}
VM_LIST=$(qm list | awk 'NR>1 {print $1, $2}')
if [ -z "$VM_LIST" ]; then
whiptail --title "$(translate "Error")" --msgbox "$(translate "No VMs available in the system.")" 8 40
exit 1
fi
VMID=$(whiptail --title "$(translate "Select VM")" --menu "$(translate "Select the VM to which you want to add disks:")" 15 60 8 $VM_LIST 3>&1 1>&2 2>&3)
if [ -z "$VMID" ]; then
whiptail --title "$(translate "Error")" --msgbox "$(translate "No VM was selected.")" 8 40
exit 1
fi
VMID=$(echo "$VMID" | tr -d '"')
msg_ok "$(translate "VM selected successfully.")"
VM_STATUS=$(qm status "$VMID" | awk '{print $2}')
if [ "$VM_STATUS" == "running" ]; then
whiptail --title "$(translate "Warning")" --msgbox "$(translate "The VM is powered on. Turn it off before adding disks.")" 12 60
exit 1
fi
##########################################
msg_info "$(translate "Detecting available disks...")"
USED_DISKS=$(lsblk -n -o PKNAME,TYPE | grep 'lvm' | awk '{print "/dev/" $1}')
MOUNTED_DISKS=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}')
ZFS_DISKS=""
ZFS_RAW=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror')
for entry in $ZFS_RAW; do
path=""
if [[ "$entry" == wwn-* || "$entry" == ata-* ]]; then
if [ -e "/dev/disk/by-id/$entry" ]; then
path=$(readlink -f "/dev/disk/by-id/$entry")
fi
elif [[ "$entry" == /dev/* ]]; then
path="$entry"
fi
if [ -n "$path" ]; then
base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null)
if [ -n "$base_disk" ]; then
ZFS_DISKS+="/dev/$base_disk"$'\n'
fi
fi
done
ZFS_DISKS=$(echo "$ZFS_DISKS" | sort -u)
is_disk_in_use() {
local disk="$1"
while read -r part fstype; do
case "$fstype" in
zfs_member|linux_raid_member)
return 0 ;;
esac
if echo "$MOUNTED_DISKS" | grep -q "/dev/$part"; then
return 0
fi
done < <(lsblk -ln -o NAME,FSTYPE "$disk" | tail -n +2)
if echo "$USED_DISKS" | grep -q "$disk" || echo "$ZFS_DISKS" | grep -q "$disk"; then
return 0
fi
return 1
}
FREE_DISKS=()
LVM_DEVICES=$(pvs --noheadings -o pv_name 2> >(grep -v 'File descriptor .* leaked') | xargs -n1 readlink -f | sort -u)
RAID_ACTIVE=$(grep -Po 'md\d+\s*:\s*active\s+raid[0-9]+' /proc/mdstat | awk '{print $1}' | sort -u)
while read -r DISK; do
[[ "$DISK" =~ /dev/zd ]] && continue
INFO=($(get_disk_info "$DISK"))
MODEL="${INFO[@]::${#INFO[@]}-1}"
SIZE="${INFO[-1]}"
LABEL=""
SHOW_DISK=true
IS_MOUNTED=false
IS_RAID=false
IS_ZFS=false
IS_LVM=false
while read -r part fstype; do
[[ "$fstype" == "zfs_member" ]] && IS_ZFS=true
[[ "$fstype" == "linux_raid_member" ]] && IS_RAID=true
[[ "$fstype" == "LVM2_member" ]] && IS_LVM=true
if grep -q "/dev/$part" <<< "$MOUNTED_DISKS"; then
IS_MOUNTED=true
fi
done < <(lsblk -ln -o NAME,FSTYPE "$DISK" | tail -n +2)
REAL_PATH=$(readlink -f "$DISK")
if echo "$LVM_DEVICES" | grep -qFx "$REAL_PATH"; then
IS_MOUNTED=true
fi
USED_BY=""
REAL_PATH=$(readlink -f "$DISK")
CONFIG_DATA=$(grep -vE '^\s*#' /etc/pve/qemu-server/*.conf /etc/pve/lxc/*.conf 2>/dev/null)
if grep -Fq "$REAL_PATH" <<< "$CONFIG_DATA"; then
USED_BY="$(translate "In use")"
else
for SYMLINK in /dev/disk/by-id/*; do
if [[ "$(readlink -f "$SYMLINK")" == "$REAL_PATH" ]]; then
if grep -Fq "$SYMLINK" <<< "$CONFIG_DATA"; then
USED_BY="$(translate "In use")"
break
fi
fi
done
fi
if $IS_RAID && grep -q "$DISK" <<< "$(cat /proc/mdstat)"; then
if grep -q "active raid" /proc/mdstat; then
SHOW_DISK=false
fi
fi
if $IS_ZFS; then
SHOW_DISK=false
fi
if $IS_MOUNTED; then
SHOW_DISK=false
fi
if qm config "$VMID" | grep -vE '^\s*#|^description:' | grep -q "$DISK"; then
SHOW_DISK=false
fi
if $SHOW_DISK; then
[[ -n "$USED_BY" ]] && LABEL+=" [$USED_BY]"
[[ "$IS_RAID" == true ]] && LABEL+=" ⚠ RAID"
[[ "$IS_LVM" == true ]] && LABEL+=" ⚠ LVM"
[[ "$IS_ZFS" == true ]] && LABEL+=" ⚠ ZFS"
DESCRIPTION=$(printf "%-30s %10s%s" "$MODEL" "$SIZE" "$LABEL")
FREE_DISKS+=("$DISK" "$DESCRIPTION" "OFF")
fi
done < <(lsblk -dn -e 7,11 -o PATH)
if [ "${#FREE_DISKS[@]}" -eq 0 ]; then
cleanup
whiptail --title "$(translate "Error")" --msgbox "$(translate "No disks available for this VM.")" 8 40
clear
exit 1
fi
msg_ok "$(translate "Available disks detected.")"
######################################################
MAX_WIDTH=$(printf "%s\n" "${FREE_DISKS[@]}" | awk '{print length}' | sort -nr | head -n1)
TOTAL_WIDTH=$((MAX_WIDTH + 20))
if [ $TOTAL_WIDTH -lt 50 ]; then
TOTAL_WIDTH=50
fi
SELECTED=$(whiptail --title "$(translate "Select Disks")" --checklist \
"$(translate "Select the disks you want to add:")" 20 $TOTAL_WIDTH 10 "${FREE_DISKS[@]}" 3>&1 1>&2 2>&3)
if [ -z "$SELECTED" ]; then
whiptail --title "$(translate "Error")" --msgbox "$(translate "No disks were selected.")" 10 64
clear
exit 1
fi
msg_ok "$(translate "Disks selected successfully.")"
INTERFACE=$(whiptail --title "$(translate "Interface Type")" --menu "$(translate "Select the interface type for all disks:")" 15 40 4 \
"sata" "$(translate "Add as SATA")" \
"scsi" "$(translate "Add as SCSI")" \
"virtio" "$(translate "Add as VirtIO")" \
"ide" "$(translate "Add as IDE")" 3>&1 1>&2 2>&3)
if [ -z "$INTERFACE" ]; then
whiptail --title "$(translate "Error")" --msgbox "$(translate "No interface type was selected for the disks.")" 8 40
clear
exit 1
fi
msg_ok "$(translate "Interface type selected: $INTERFACE")"
DISKS_ADDED=0
ERROR_MESSAGES=""
SUCCESS_MESSAGES=""
msg_info "$(translate "Processing selected disks...")"
for DISK in $SELECTED; do
DISK=$(echo "$DISK" | tr -d '"')
DISK_INFO=$(get_disk_info "$DISK")
ASSIGNED_TO=""
RUNNING_VMS=""
RUNNING_CTS=""
while read -r VM_ID VM_NAME; do
if [[ "$VM_ID" =~ ^[0-9]+$ ]] && qm config "$VM_ID" | grep -q "$DISK"; then
ASSIGNED_TO+="VM $VM_ID $VM_NAME\n"
VM_STATUS=$(qm status "$VM_ID" | awk '{print $2}')
if [ "$VM_STATUS" == "running" ]; then
RUNNING_VMS+="VM $VM_ID $VM_NAME\n"
fi
fi
done < <(qm list | awk 'NR>1 {print $1, $2}')
while read -r CT_ID CT_NAME; do
if [[ "$CT_ID" =~ ^[0-9]+$ ]] && pct config "$CT_ID" | grep -q "$DISK"; then
ASSIGNED_TO+="CT $CT_ID $CT_NAME\n"
CT_STATUS=$(pct status "$CT_ID" | awk '{print $2}')
if [ "$CT_STATUS" == "running" ]; then
RUNNING_CTS+="CT $CT_ID $CT_NAME\n"
fi
fi
done < <(pct list | awk 'NR>1 {print $1, $2}')
if [ -n "$RUNNING_VMS" ] || [ -n "$RUNNING_CTS" ]; then
ERROR_MESSAGES+="$(translate "The disk") $DISK_INFO $(translate "is currently in use by the following running VM(s) or CT(s):")\\n$RUNNING_VMS$RUNNING_CTS\\n\\n$(translate "You cannot add this disk while the VM or CT is running.")\\n$(translate "Please shut it down first and run this script again to add the disk.")\\n\\n"
continue
fi
if [ -n "$ASSIGNED_TO" ]; then
cleanup
whiptail --title "$(translate "Disk Already Assigned")" --yesno "$(translate "The disk") $DISK_INFO $(translate "is already assigned to the following VM(s) or CT(s):")\\n$ASSIGNED_TO\\n\\n$(translate "Do you want to continue anyway?")" 15 70
if [ $? -ne 0 ]; then
sleep 1
exec "$0"
fi
fi
INDEX=0
while qm config "$VMID" | grep -q "${INTERFACE}${INDEX}"; do
((INDEX++))
done
RESULT=$(qm set "$VMID" -${INTERFACE}${INDEX} "$DISK" 2>&1)
if [ $? -eq 0 ]; then
MESSAGE="$(translate "The disk") $DISK_INFO $(translate "has been successfully added to VM") $VMID."
if [ -n "$ASSIGNED_TO" ]; then
MESSAGE+="\\n\\n$(translate "WARNING: This disk is also assigned to the following VM(s):")\\n$ASSIGNED_TO"
MESSAGE+="\\n$(translate "Make sure not to start VMs that share this disk at the same time to avoid data corruption.")"
fi
SUCCESS_MESSAGES+="$MESSAGE\\n\\n"
((DISKS_ADDED++))
else
ERROR_MESSAGES+="$(translate "Could not add disk") $DISK_INFO $(translate "to VM") $VMID.\\n$(translate "Error:") $RESULT\\n\\n"
fi
done
msg_ok "$(translate "Disk processing completed.")"
if [ -n "$SUCCESS_MESSAGES" ]; then
MSG_LINES=$(echo "$SUCCESS_MESSAGES" | wc -l)
whiptail --title "$(translate "Successful Operations")" --msgbox "$SUCCESS_MESSAGES" 16 70
fi
if [ -n "$ERROR_MESSAGES" ]; then
whiptail --title "$(translate "Warnings and Errors")" --msgbox "$ERROR_MESSAGES" 16 70
fi
exit 0
-537
View File
@@ -1,537 +0,0 @@
#!/bin/bash
# ==========================================================
# ProxMenux - A menu-driven script for Proxmox VE management
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.0
# Last Updated: 28/01/2025
# ==========================================================
# Description:
# This script allows users to assign physical disks to existing
# Proxmox containers (CTs) through an interactive menu.
# - Detects the system disk and excludes it from selection.
# - Lists all available CTs for the user to choose from.
# - Identifies and displays unassigned physical disks.
# - Allows the user to select multiple disks and attach them to a CT.
# - Configures the selected disks for the CT and verifies the assignment.
# ==========================================================
# Configuration ============================================
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
# ==========================================================
get_disk_info() {
local disk=$1
MODEL=$(lsblk -dn -o MODEL "$disk" | xargs)
SIZE=$(lsblk -dn -o SIZE "$disk" | xargs)
echo "$MODEL" "$SIZE"
}
CT_LIST=$(pct list | awk 'NR>1 {print $1, $3}')
if [ -z "$CT_LIST" ]; then
whiptail --title "$(translate "Error")" --msgbox "$(translate "No CTs available in the system.")" 8 40
exit 1
fi
CTID=$(whiptail --title "$(translate "Select CT")" --menu "$(translate "Select the CT to which you want to add disks:")" 15 60 8 $CT_LIST 3>&1 1>&2 2>&3)
if [ -z "$CTID" ]; then
whiptail --title "$(translate "Error")" --msgbox "$(translate "No CT was selected.")" 8 40
exit 1
fi
CTID=$(echo "$CTID" | tr -d '"')
msg_ok "$(translate "CT selected successfully.")"
CT_STATUS=$(pct status "$CTID" | awk '{print $2}')
if [ "$CT_STATUS" != "running" ]; then
msg_info "$(translate "Starting CT") $CTID..."
pct start "$CTID"
sleep 2
if [ "$(pct status "$CTID" | awk '{print $2}')" != "running" ]; then
msg_error "$(translate "Failed to start the CT.")"
exit 1
fi
msg_ok "$(translate "CT started successfully.")"
fi
CONF_FILE="/etc/pve/lxc/$CTID.conf"
if grep -q '^unprivileged: 1' "$CONF_FILE"; then
if whiptail --title "$(translate "Privileged Container")" \
--yesno "$(translate "The selected container is unprivileged. A privileged container is required for direct device passthrough.")\\n\\n$(translate "Do you want to convert it to a privileged container now?")" 12 70; then
msg_info "$(translate "Stopping container") $CTID..."
pct shutdown "$CTID" &
for i in {1..10}; do
sleep 1
if [ "$(pct status "$CTID" | awk '{print $2}')" != "running" ]; then
break
fi
done
if [ "$(pct status "$CTID" | awk '{print $2}')" == "running" ]; then
msg_error "$(translate "Failed to stop the container.")"
exit 1
fi
msg_ok "$(translate "Container stopped.")"
cp "$CONF_FILE" "$CONF_FILE.bak"
sed -i '/^unprivileged: 1/d' "$CONF_FILE"
echo "unprivileged: 0" >> "$CONF_FILE"
msg_ok "$(translate "Container successfully converted to privileged.")"
msg_info "$(translate "Starting container") $CTID..."
pct start "$CTID"
sleep 2
if [ "$(pct status "$CTID" | awk '{print $2}')" != "running" ]; then
msg_error "$(translate "Failed to start the container.")"
exit 1
fi
msg_ok "$(translate "Container started successfully.")"
else
whiptail --title "$(translate "Aborted")" \
--msgbox "$(translate "Operation cancelled. Cannot continue with an unprivileged container.")" 10 60
exit 1
fi
fi
##########################################
msg_info "$(translate "Detecting available disks...")"
USED_DISKS=$(lsblk -n -o PKNAME,TYPE | grep 'lvm' | awk '{print "/dev/" $1}')
MOUNTED_DISKS=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}')
ZFS_DISKS=""
ZFS_RAW=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror')
for entry in $ZFS_RAW; do
path=""
if [[ "$entry" == wwn-* || "$entry" == ata-* ]]; then
if [ -e "/dev/disk/by-id/$entry" ]; then
path=$(readlink -f "/dev/disk/by-id/$entry")
fi
elif [[ "$entry" == /dev/* ]]; then
path="$entry"
fi
if [ -n "$path" ]; then
base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null)
if [ -n "$base_disk" ]; then
ZFS_DISKS+="/dev/$base_disk"$'\n'
fi
fi
done
ZFS_DISKS=$(echo "$ZFS_DISKS" | sort -u)
is_disk_in_use() {
local disk="$1"
while read -r part fstype; do
case "$fstype" in
zfs_member|linux_raid_member)
return 0 ;;
esac
if echo "$MOUNTED_DISKS" | grep -q "/dev/$part"; then
return 0
fi
done < <(lsblk -ln -o NAME,FSTYPE "$disk" | tail -n +2)
if echo "$USED_DISKS" | grep -q "$disk" || echo "$ZFS_DISKS" | grep -q "$disk"; then
return 0
fi
return 1
}
FREE_DISKS=()
LVM_DEVICES=$(pvs --noheadings -o pv_name 2> >(grep -v 'File descriptor .* leaked') | xargs -r -n1 readlink -f | sort -u)
if [[ -n "$LVM_DEVICES" ]] && echo "$LVM_DEVICES" | grep -qFx "$REAL_PATH"; then
IS_MOUNTED=true
fi
RAID_ACTIVE=$(grep -Po 'md\d+\s*:\s*active\s+raid[0-9]+' /proc/mdstat | awk '{print $1}' | sort -u)
while read -r DISK; do
[[ "$DISK" =~ /dev/zd ]] && continue
INFO=($(get_disk_info "$DISK"))
MODEL="${INFO[@]::${#INFO[@]}-1}"
SIZE="${INFO[-1]}"
LABEL=""
SHOW_DISK=true
IS_MOUNTED=false
IS_RAID=false
IS_ZFS=false
IS_LVM=false
while read -r part fstype; do
[[ "$fstype" == "zfs_member" ]] && IS_ZFS=true
[[ "$fstype" == "linux_raid_member" ]] && IS_RAID=true
[[ "$fstype" == "LVM2_member" ]] && IS_LVM=true
if grep -q "/dev/$part" <<< "$MOUNTED_DISKS"; then
IS_MOUNTED=true
fi
done < <(lsblk -ln -o NAME,FSTYPE "$DISK" | tail -n +2)
REAL_PATH=$(readlink -f "$DISK")
if echo "$LVM_DEVICES" | grep -qFx "$REAL_PATH"; then
IS_MOUNTED=true
fi
USED_BY=""
REAL_PATH=$(readlink -f "$DISK")
CONFIG_DATA=$(grep -vE '^\s*#' /etc/pve/qemu-server/*.conf /etc/pve/lxc/*.conf 2>/dev/null)
if grep -Fq "$REAL_PATH" <<< "$CONFIG_DATA"; then
USED_BY="$(translate "In use")"
else
for SYMLINK in /dev/disk/by-id/*; do
if [[ "$(readlink -f "$SYMLINK")" == "$REAL_PATH" ]]; then
if grep -Fq "$SYMLINK" <<< "$CONFIG_DATA"; then
USED_BY="$(translate "In use")"
break
fi
fi
done
fi
if $IS_RAID && grep -q "$DISK" <<< "$(cat /proc/mdstat)"; then
if grep -q "active raid" /proc/mdstat; then
SHOW_DISK=false
fi
fi
if $IS_ZFS; then
SHOW_DISK=false
fi
if $IS_MOUNTED; then
SHOW_DISK=false
fi
if pct config "$CTID" | grep -vE '^\s*#|^description:' | grep -q "$DISK"; then
SHOW_DISK=false
fi
if $SHOW_DISK; then
[[ -n "$USED_BY" ]] && LABEL+=" [$USED_BY]"
[[ "$IS_RAID" == true ]] && LABEL+=" ⚠ RAID"
[[ "$IS_LVM" == true ]] && LABEL+=" ⚠ LVM"
[[ "$IS_ZFS" == true ]] && LABEL+=" ⚠ ZFS"
DESCRIPTION=$(printf "%-30s %10s%s" "$MODEL" "$SIZE" "$LABEL")
FREE_DISKS+=("$DISK" "$DESCRIPTION" "OFF")
fi
done < <(lsblk -dn -e 7,11 -o PATH)
if [ "${#FREE_DISKS[@]}" -eq 0 ]; then
cleanup
whiptail --title "$(translate "Error")" --msgbox "$(translate "No disks available for this CT.")" 8 40
clear
exit 1
fi
msg_ok "$(translate "Available disks detected.")"
######################################################
MAX_WIDTH=$(printf "%s\n" "${FREE_DISKS[@]}" | awk '{print length}' | sort -nr | head -n1)
TOTAL_WIDTH=$((MAX_WIDTH + 20))
if [ $TOTAL_WIDTH -lt 50 ]; then
TOTAL_WIDTH=50
fi
SELECTED=$(whiptail --title "$(translate "Select Disks")" --radiolist \
"$(translate "Select the disks you want to add:")" 20 $TOTAL_WIDTH 10 "${FREE_DISKS[@]}" 3>&1 1>&2 2>&3)
if [ -z "$SELECTED" ]; then
whiptail --title "$(translate "Error")" --msgbox "$(translate "No disks were selected.")" 10 64
clear
exit 1
fi
msg_ok "$(translate "Disks selected successfully.")"
DISKS_ADDED=0
ERROR_MESSAGES=""
SUCCESS_MESSAGES=""
msg_info "$(translate "Processing selected disks...")"
for DISK in $SELECTED; do
DISK=$(echo "$DISK" | tr -d '"')
DISK_INFO=$(get_disk_info "$DISK")
ASSIGNED_TO=""
RUNNING_CTS=""
RUNNING_VMS=""
# Comprobar CTs
while read -r CT_ID CT_NAME; do
if [[ "$CT_ID" =~ ^[0-9]+$ ]] && pct config "$CT_ID" | grep -q "$DISK"; then
ASSIGNED_TO+="CT $CT_ID $CT_NAME\n"
CT_STATUS=$(pct status "$CT_ID" | awk '{print $2}')
if [ "$CT_STATUS" == "running" ]; then
RUNNING_CTS+="CT $CT_ID $CT_NAME\n"
fi
fi
done < <(pct list | awk 'NR>1 {print $1, $3}')
# Comprobar VMs
while read -r VM_ID VM_NAME; do
if [[ "$VM_ID" =~ ^[0-9]+$ ]] && qm config "$VM_ID" | grep -q "$DISK"; then
ASSIGNED_TO+="VM $VM_ID $VM_NAME\n"
VM_STATUS=$(qm status "$VM_ID" | awk '{print $2}')
if [ "$VM_STATUS" == "running" ]; then
RUNNING_VMS+="VM $VM_ID $VM_NAME\n"
fi
fi
done < <(qm list | awk 'NR>1 {print $1, $2}')
if [ -n "$RUNNING_CTS" ] || [ -n "$RUNNING_VMS" ]; then
ERROR_MESSAGES+="$(translate "The disk") $DISK_INFO $(translate "is in use by the following running VM(s) or CT(s):")\\n$RUNNING_CTS$RUNNING_VMS\\n\\n"
continue
fi
if [ -n "$ASSIGNED_TO" ]; then
cleanup
whiptail --title "$(translate "Disk Already Assigned")" --yesno "$(translate "The disk") $DISK_INFO $(translate "is already assigned to the following VM(s) or CT(s):")\\n$ASSIGNED_TO\\n\\n$(translate "Do you want to continue anyway?")" 15 70
if [ $? -ne 0 ]; then
sleep 1
exec "$0"
fi
fi
cleanup
if lsblk "$DISK" | grep -q "raid" || grep -q "${DISK##*/}" /proc/mdstat; then
whiptail --title "$(translate "RAID Detected")" --msgbox "$(translate "The disk") $DISK_INFO $(translate "appears to be part of a") RAID. $(translate "For security reasons, the system cannot format it.")\\n\\n$(translate "If you are sure you want to use it, please remove the") RAID metadata $(translate "or format it manually using external tools.")\\n\\n$(translate "After that, run this script again to add it.")" 18 70
exit
fi
MOUNT_POINT=$(whiptail --title "$(translate "Mount Point")" --inputbox "$(translate "Enter the mount point for the disk (e.g., /mnt/disk_passthrough):")" 10 60 "/mnt/disk_passthrough" 3>&1 1>&2 2>&3)
if [ -z "$MOUNT_POINT" ]; then
whiptail --title "$(translate "Error")" --msgbox "$(translate "No mount point was specified.")" 8 40
continue
fi
msg_ok "$(translate "Mount point specified: $MOUNT_POINT")"
PARTITION=$(lsblk -rno NAME "$DISK" | awk -v disk="$(basename "$DISK")" '$1 != disk {print $1; exit}')
SKIP_FORMAT=false
if [ -n "$PARTITION" ]; then
PARTITION="/dev/$PARTITION"
CURRENT_FS=$(lsblk -no FSTYPE "$PARTITION" | xargs)
if [[ "$CURRENT_FS" == "ext4" || "$CURRENT_FS" == "xfs" || "$CURRENT_FS" == "btrfs" ]]; then
SKIP_FORMAT=true
msg_ok "$(translate "Detected existing filesystem") $CURRENT_FS $(translate "on") $PARTITION."
else
whiptail --title "$(translate "Unsupported Filesystem")" --yesno "$(translate "The partition") $PARTITION $(translate "has an unsupported filesystem ($CURRENT_FS).\\nDo you want to format it?")" 10 70
if [ $? -ne 0 ]; then
continue
fi
fi
else
CURRENT_FS=$(lsblk -no FSTYPE "$DISK" | xargs)
if [[ "$CURRENT_FS" == "ext4" || "$CURRENT_FS" == "xfs" || "$CURRENT_FS" == "btrfs" ]]; then
SKIP_FORMAT=true
PARTITION="$DISK"
msg_ok "$(translate "Detected filesystem") $CURRENT_FS $(translate "directly on disk") $DISK.)"
else
whiptail --title "$(translate "No Valid Partitions")" --yesno "$(translate "The disk has no partitions and no valid filesystem. Do you want to create a new partition and format it?")" 10 70
if [ $? -ne 0 ]; then
continue
fi
echo -e "$(translate "Creating partition table and partition...")"
parted -s "$DISK" mklabel gpt
parted -s "$DISK" mkpart primary 0% 100%
sleep 2
partprobe "$DISK"
sleep 2
PARTITION=$(lsblk -rno NAME "$DISK" | awk -v disk="$(basename "$DISK")" '$1 != disk {print $1; exit}')
if [ -n "$PARTITION" ]; then
PARTITION="/dev/$PARTITION"
else
whiptail --title "$(translate "Partition Error")" --msgbox "$(translate "Failed to create partition on disk") $DISK_INFO." 8 70
continue
fi
fi
fi
if [ "$SKIP_FORMAT" != true ]; then
CURRENT_FS=$(lsblk -no FSTYPE "$PARTITION" | xargs)
if [[ "$CURRENT_FS" == "ext4" || "$CURRENT_FS" == "xfs" || "$CURRENT_FS" == "btrfs" ]]; then
SKIP_FORMAT=true
msg_ok "$(translate "Detected existing filesystem") $CURRENT_FS $(translate "on") $PARTITION. $(translate "Skipping format.")"
else
FORMAT_TYPE=$(whiptail --title "$(translate "Select Format Type")" --menu "$(translate "Select the filesystem type for") $DISK_INFO:" 15 60 6 \
"ext4" "$(translate "Extended Filesystem 4 (recommended)")" \
"xfs" "$(translate "XFS Filesystem")" \
"btrfs" "$(translate "Btrfs Filesystem")" 3>&1 1>&2 2>&3)
if [ -z "$FORMAT_TYPE" ]; then
whiptail --title "$(translate "Format Cancelled")" --msgbox "$(translate "Format operation cancelled. The disk will not be added.")" 8 60
continue
fi
whiptail --title "$(translate "WARNING")" --yesno "$(translate "WARNING: This operation will FORMAT the disk") $DISK_INFO $(translate "with") $FORMAT_TYPE.\\n\\n$(translate "ALL DATA ON THIS DISK WILL BE PERMANENTLY LOST!")\\n\\n$(translate "Are you sure you want to continue")" 15 70
if [ $? -ne 0 ]; then
whiptail --title "$(translate "Format Cancelled")" --msgbox "$(translate "Format operation cancelled. The disk will not be added.")" 8 60
continue
fi
fi
fi
if [ "$SKIP_FORMAT" != true ]; then
echo -e "$(translate "Formatting partition") $PARTITION $(translate "with") $FORMAT_TYPE..."
case "$FORMAT_TYPE" in
"ext4") mkfs.ext4 -F "$PARTITION" ;;
"xfs") mkfs.xfs -f "$PARTITION" ;;
"btrfs") mkfs.btrfs -f "$PARTITION" ;;
esac
if [ $? -ne 0 ]; then
whiptail --title "$(translate "Format Failed")" --msgbox "$(translate "Failed to format partition") $PARTITION $(translate "with") $FORMAT_TYPE.\\n\\n$(translate "The disk may be in use by the system or have hardware issues.")" 12 70
continue
else
msg_ok "$(translate "Partition") $PARTITION $(translate "successfully formatted with") $FORMAT_TYPE."
partprobe "$DISK"
sleep 2
fi
fi
INDEX=0
while pct config "$CTID" | grep -q "mp${INDEX}:"; do
((INDEX++))
done
##############################################################################
RESULT=$(pct set "$CTID" -mp${INDEX} "$PARTITION,mp=$MOUNT_POINT,backup=0,ro=0,acl=1" 2>&1)
pct exec "$CTID" -- chmod -R 775 "$MOUNT_POINT"
##############################################################################
if [ $? -eq 0 ]; then
MESSAGE="$(translate "The disk") $DISK_INFO $(translate "has been successfully added to CT") $CTID $(translate "as a mount point at") $MOUNT_POINT."
if [ -n "$ASSIGNED_TO" ]; then
MESSAGE+="\\n\\n$(translate "WARNING: This disk is also assigned to the following CT(s):")\\n$ASSIGNED_TO"
MESSAGE+="\\n$(translate "Make sure not to start CTs that share this disk at the same time to avoid data corruption.")"
fi
SUCCESS_MESSAGES+="$MESSAGE\\n\\n"
((DISKS_ADDED++))
else
ERROR_MESSAGES+="$(translate "Could not add disk") $DISK_INFO $(translate "to CT") $CTID.\\n$(translate "Error:") $RESULT\\n\\n"
fi
done
msg_ok "$(translate "Disk processing completed.")"
if [ -n "$SUCCESS_MESSAGES" ]; then
MSG_LINES=$(echo "$SUCCESS_MESSAGES" | wc -l)
whiptail --title "$(translate "Successful Operations")" --msgbox "$SUCCESS_MESSAGES" 16 70
fi
if [ -n "$ERROR_MESSAGES" ]; then
whiptail --title "$(translate "Warnings and Errors")" --msgbox "$ERROR_MESSAGES" 16 70
fi
exit 0
+385
View File
@@ -0,0 +1,385 @@
#!/usr/bin/env bash
# ==========================================================
# ProxMenux - Disk Operations Helpers
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : MIT
# Version : 1.0
# Last Updated: 11/04/2026
# ==========================================================
# Shared low-level disk operations: wipe, partition, format.
# Consumed by format-disk.sh, disk_host.sh and future scripts.
#
# Output variables (set by helpers, read by callers):
# DOH_CREATED_PARTITION — partition path set by doh_create_partition()
# DOH_PARTITION_ERROR_DETAIL — error detail set by doh_create_partition()
# ==========================================================
if [[ -n "${__PROXMENUX_DISK_OPS_HELPERS__}" ]]; then
return 0
fi
__PROXMENUX_DISK_OPS_HELPERS__=1
# shellcheck disable=SC2034 # these are output variables read by callers (format-disk.sh, disk_host.sh)
DOH_CREATED_PARTITION=""
DOH_PARTITION_ERROR_DETAIL=""
DOH_FORMAT_ERROR_DETAIL=""
DOH_WIPE_ERROR_DETAIL=""
# Internal: print progress lines only when explicitly enabled by caller.
# Enabled with: export DOH_SHOW_PROGRESS=1
_doh_progress() {
[[ "${DOH_SHOW_PROGRESS:-0}" == "1" ]] || return 0
echo -e "${TAB}${YW}${HOLD}$*${CL}"
}
# Internal: collect command stdout with timeout protection (best-effort).
# Usage: _doh_collect_cmd <seconds> <cmd> [args...]
_doh_collect_cmd() {
local seconds="$1"
shift
if command -v timeout >/dev/null 2>&1; then
timeout --kill-after=2 "${seconds}s" "$@" 2>/dev/null || true
else
"$@" 2>/dev/null || true
fi
}
# Internal: run a command with a timeout, suppressing all output including
# the bash "Killed" job notification that leaks when --kill-after re-raises
# SIGKILL. Plain SIGTERM is not enough for processes stuck in kernel D-state
# (uninterruptible I/O wait on a busy ZFS/LVM disk), so --kill-after=2 is
# needed. The notification is suppressed by temporarily redirecting the
# current shell's stderr with exec before the call and restoring it after.
# Usage: _doh_run_quick_cmd <seconds> <cmd> [args...]
_doh_run_quick_cmd() {
local seconds="$1"
shift
if command -v timeout >/dev/null 2>&1; then
local _saved_stderr
exec {_saved_stderr}>&2 2>/dev/null
timeout --kill-after=2 "${seconds}s" "$@" >/dev/null 2>&1
local rc=$?
exec 2>&"${_saved_stderr}" {_saved_stderr}>&-
return $rc
fi
"$@" >/dev/null 2>&1
}
# Internal: unmount all ZFS datasets then export (or destroy) any ZFS pools
# whose vdevs live on <disk>. Called at the very start of doh_wipe_disk so
# ZFS fully releases the device before wipefs/sgdisk/partprobe touch it.
# If the pool is still held after export, processes on it will be in D-state
# and --kill-after in _doh_run_quick_cmd handles the force-kill.
_doh_release_zfs_pools() {
local disk="$1"
command -v zpool >/dev/null 2>&1 || return 0
local pool_name dev resolved base parent
while read -r pool_name; do
[[ -z "$pool_name" ]] && continue
local found=false
while read -r dev; do
[[ -z "$dev" ]] && continue
if [[ "$dev" == /dev/* ]]; then
resolved=$(readlink -f "$dev" 2>/dev/null)
elif [[ -e "/dev/disk/by-id/$dev" ]]; then
resolved=$(readlink -f "/dev/disk/by-id/$dev" 2>/dev/null)
elif [[ -e "/dev/$dev" ]]; then
resolved=$(readlink -f "/dev/$dev" 2>/dev/null)
else
continue
fi
[[ -z "$resolved" ]] && continue
base=$(lsblk -no PKNAME "$resolved" 2>/dev/null)
parent="${base:+/dev/$base}"
[[ -z "$parent" ]] && parent="$resolved"
if [[ "$parent" == "$disk" || "$resolved" == "$disk" ]]; then
found=true; break
fi
done < <(_doh_collect_cmd 12 zpool list -v -H "$pool_name" | awk '{print $1}' | \
grep -v '^-' | grep -v '^mirror' | grep -v '^raidz' | \
grep -v "^${pool_name}$")
if $found; then
_doh_progress "- Releasing active ZFS pool: $pool_name"
# Unmount all datasets (reverse order: deepest first)
if command -v zfs >/dev/null 2>&1; then
while read -r ds; do
[[ -z "$ds" ]] && continue
timeout 10s zfs unmount -f "$ds" >/dev/null 2>&1 || true
done < <(_doh_collect_cmd 10 zfs list -H -o name -r "$pool_name" | sort -r)
fi
# Export the pool so the kernel releases the block device
timeout 30s zpool export -f "$pool_name" >/dev/null 2>&1 || true
# Wait for udev to finish processing the device release
udevadm settle --timeout=5 >/dev/null 2>&1 || true
sleep 1
fi
done < <(_doh_collect_cmd 8 zpool list -H -o name)
}
# Internal: run a partitioning command with timeout, appending combined output to a file.
# Usage: _doh_part_cmd <seconds> <outfile> <cmd> [args...]
_doh_part_cmd() {
local secs="$1" outfile="$2"
shift 2
if command -v timeout >/dev/null 2>&1; then
timeout --kill-after=3 "${secs}s" "$@" >>"$outfile" 2>&1
else
"$@" >>"$outfile" 2>&1
fi
}
# doh_wipe_disk <disk>
# Unmounts all partitions, deactivates swap, wipes all filesystem metadata
# and partition tables (wipefs + sgdisk + dd first/last 16 MiB).
# Never fails — all sub-commands run with "|| true".
doh_wipe_disk() {
local disk="$1"
local node mountpoint total_sectors seek_sectors discard_max base
DOH_WIPE_ERROR_DETAIL=""
_doh_progress "[1/8] Preparing disk $disk"
# Optional heavy release flow (disabled by default to avoid hangs in busy hosts).
if [[ "${DOH_ENABLE_STACK_RELEASE:-0}" == "1" ]]; then
# Release any ZFS pools using this disk so the kernel lets go of it
_doh_release_zfs_pools "$disk"
# Deactivate any LVM VGs backed by this disk
if command -v vgchange >/dev/null 2>&1; then
local pv rp vg
while read -r pv; do
rp=$(readlink -f "$pv" 2>/dev/null)
base=$(lsblk -no PKNAME "${rp:-$pv}" 2>/dev/null)
if [[ "/dev/${base}" == "$disk" || "$rp" == "$disk" ]]; then
vg=$(_doh_collect_cmd 8 pvs --noheadings -o vg_name "${rp:-$pv}" | xargs)
[[ -n "$vg" ]] && _doh_run_quick_cmd 8 vgchange -an "$vg" || true
fi
done < <(_doh_collect_cmd 8 pvs --noheadings -o pv_name | xargs -r -n1)
fi
fi
# Unmount all partitions
_doh_progress "[2/8] Unmounting partitions"
while read -r node mountpoint; do
[[ -z "$node" || -z "$mountpoint" ]] && continue
_doh_run_quick_cmd 8 umount -f "$node" || true
done < <(lsblk -lnpo NAME,MOUNTPOINT "$disk" 2>/dev/null | awk 'NR>1 && $2!="" {print $1" "$2}')
# Deactivate swap
_doh_progress "[3/8] Disabling swap signatures"
while read -r node; do
[[ -z "$node" ]] && continue
_doh_run_quick_cmd 8 swapoff "$node" || true
done < <(lsblk -lnpo NAME "$disk" 2>/dev/null | awk 'NR>1 {print $1}')
# Wipe filesystem signatures and RAID superblocks on every node
_doh_progress "[4/8] Removing filesystem/RAID signatures"
while read -r node; do
[[ -z "$node" ]] && continue
_doh_run_quick_cmd 10 wipefs -a -f "$node" || true
if command -v mdadm >/dev/null 2>&1; then
_doh_run_quick_cmd 8 mdadm --zero-superblock --force "$node" || true
fi
done < <(lsblk -lnpo NAME "$disk" 2>/dev/null)
# Zap partition table
_doh_progress "[5/8] Resetting partition table"
_doh_run_quick_cmd 12 sgdisk --zap-all "$disk" || true
# TRIM/discard if device supports it
_doh_progress "[6/8] Attempting discard/TRIM when supported"
discard_max=$(lsblk -dn -o DISC-MAX "$disk" 2>/dev/null | xargs)
if [[ -n "$discard_max" && "$discard_max" != "0B" && "$discard_max" != "0" ]]; then
_doh_run_quick_cmd 15 blkdiscard -f "$disk" || true
fi
# Zero first 16 MiB (destroys partition table / filesystem headers)
_doh_progress "[7/8] Zeroing first metadata region"
_doh_run_quick_cmd 20 dd if=/dev/zero of="$disk" bs=1M count=16 conv=fsync status=none || true
# Zero last 16 MiB (destroys backup GPT header)
_doh_progress "[8/8] Zeroing backup GPT region"
total_sectors=$(blockdev --getsz "$disk" 2>/dev/null || echo 0)
if [[ "$total_sectors" =~ ^[0-9]+$ ]] && (( total_sectors > 32768 )); then
seek_sectors=$(( total_sectors - 32768 ))
_doh_run_quick_cmd 20 dd if=/dev/zero of="$disk" bs=512 seek="$seek_sectors" count=32768 conv=fsync status=none || true
fi
udevadm settle --timeout=10 >/dev/null 2>&1 || true
_doh_run_quick_cmd 8 partprobe "$disk" || true
sleep 1
}
# doh_create_partition <disk>
# Creates a single GPT partition spanning the whole disk.
# Tries parted → sgdisk → sfdisk in order; stops at first success.
#
# On success: sets DOH_CREATED_PARTITION to the new partition path, returns 0.
# On failure: sets DOH_PARTITION_ERROR_DETAIL with tool diagnostics, returns 1.
doh_create_partition() {
local disk="$1"
local created=false tmp_out err_snippet
DOH_CREATED_PARTITION=""
DOH_PARTITION_ERROR_DETAIL=""
_doh_run_quick_cmd 5 blockdev --setrw "$disk" || true
# --- attempt 1: parted ---
if command -v parted >/dev/null 2>&1; then
tmp_out=$(mktemp)
if _doh_part_cmd 15 "$tmp_out" parted -s -f "$disk" mklabel gpt; then
if _doh_part_cmd 20 "$tmp_out" parted -s -f "$disk" mkpart primary 1MiB 100%; then
created=true
else
err_snippet=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')
DOH_PARTITION_ERROR_DETAIL+="parted mkpart: ${err_snippet:-no details}"$'\n'
fi
else
err_snippet=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')
DOH_PARTITION_ERROR_DETAIL+="parted mklabel: ${err_snippet:-no details}"$'\n'
fi
rm -f "$tmp_out"
else
DOH_PARTITION_ERROR_DETAIL+="parted command not found"$'\n'
fi
# --- attempt 2: sgdisk ---
if [[ "$created" != "true" ]] && command -v sgdisk >/dev/null 2>&1; then
tmp_out=$(mktemp)
_doh_run_quick_cmd 10 sgdisk --zap-all "$disk" || true
# sgdisk does not accept "1MiB" notation — use sector 2048 (= 1 MiB at 512 B/sector)
if _doh_part_cmd 20 "$tmp_out" sgdisk -o -n 1:2048:0 -t 1:8300 "$disk"; then
created=true
else
err_snippet=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')
DOH_PARTITION_ERROR_DETAIL+="sgdisk create: ${err_snippet:-no details}"$'\n'
fi
rm -f "$tmp_out"
elif [[ "$created" != "true" ]]; then
DOH_PARTITION_ERROR_DETAIL+="sgdisk command not found"$'\n'
fi
# --- attempt 3: sfdisk ---
if [[ "$created" != "true" ]] && command -v sfdisk >/dev/null 2>&1; then
tmp_out=$(mktemp)
local sfdisk_ok=1
if command -v timeout >/dev/null 2>&1; then
printf 'label: gpt\n,;\n' | timeout --kill-after=3 20s sfdisk --wipe always "$disk" >>"$tmp_out" 2>&1
sfdisk_ok=$?
else
printf 'label: gpt\n,;\n' | sfdisk --wipe always "$disk" >>"$tmp_out" 2>&1
sfdisk_ok=$?
fi
if [[ $sfdisk_ok -eq 0 ]]; then
created=true
else
err_snippet=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')
DOH_PARTITION_ERROR_DETAIL+="sfdisk create: ${err_snippet:-no details}"$'\n'
fi
rm -f "$tmp_out"
elif [[ "$created" != "true" ]]; then
DOH_PARTITION_ERROR_DETAIL+="sfdisk command not found"$'\n'
fi
[[ "$created" == "true" ]] || return 1
# Wait for the kernel to expose the new partition node
udevadm settle --timeout=10 >/dev/null 2>&1 || true
_doh_run_quick_cmd 8 partprobe "$disk" || true
local part
for _ in {1..15}; do
sleep 0.3
part=$(lsblk -lnpo NAME "$disk" 2>/dev/null | awk 'NR==2{print; exit}')
if [[ -n "$part" && -b "$part" ]]; then
DOH_CREATED_PARTITION="$part"
return 0
fi
done
# Fallback: derive partition name from disk path (handles NVMe p-suffix)
local fallback
if [[ "$disk" =~ [0-9]$ ]]; then
fallback="${disk}p1"
else
fallback="${disk}1"
fi
if [[ -b "$fallback" ]]; then
DOH_CREATED_PARTITION="$fallback"
return 0
fi
DOH_PARTITION_ERROR_DETAIL+="partition node not detected after table refresh"$'\n'
return 1
}
# doh_format_partition <partition> <filesystem> [label] [zfs_pool_name] [zfs_mountpoint]
#
# Formats <partition> with <filesystem>.
# label : optional FS label for ext4/xfs/btrfs (ignored for ZFS)
# zfs_pool_name : required when filesystem=zfs; defaults to label if empty
# zfs_mountpoint : ZFS pool mountpoint (default: "none" — no automatic mount)
#
# On failure: sets DOH_FORMAT_ERROR_DETAIL with tool diagnostics.
# Returns 0 on success, 1 on failure.
doh_format_partition() {
local partition="$1"
local filesystem="$2"
local label="${3:-}"
local zfs_pool="${4:-}"
local zfs_mountpoint="${5:-none}"
local tmp_out rc=1
DOH_FORMAT_ERROR_DETAIL=""
tmp_out=$(mktemp)
case "$filesystem" in
ext4)
if [[ -n "$label" ]]; then
mkfs.ext4 -F -L "$label" "$partition" >"$tmp_out" 2>&1; rc=$?
else
mkfs.ext4 -F "$partition" >"$tmp_out" 2>&1; rc=$?
fi
;;
xfs)
if [[ -n "$label" ]]; then
mkfs.xfs -f -L "$label" "$partition" >"$tmp_out" 2>&1; rc=$?
else
mkfs.xfs -f "$partition" >"$tmp_out" 2>&1; rc=$?
fi
;;
exfat)
mkfs.exfat "$partition" >"$tmp_out" 2>&1; rc=$?
;;
btrfs)
if [[ -n "$label" ]]; then
mkfs.btrfs -f -L "$label" "$partition" >"$tmp_out" 2>&1; rc=$?
else
mkfs.btrfs -f "$partition" >"$tmp_out" 2>&1; rc=$?
fi
;;
zfs)
[[ -z "$zfs_pool" ]] && zfs_pool="${label:-pool}"
zpool labelclear -f "$partition" >/dev/null 2>&1 || true
zpool create -f -o ashift=12 \
-O compression=lz4 -O atime=off -O xattr=sa -O acltype=posixacl \
-m "$zfs_mountpoint" "$zfs_pool" "$partition" >"$tmp_out" 2>&1
rc=$?
;;
*)
echo "Unknown filesystem: $filesystem" >"$tmp_out"
rc=1
;;
esac
if [[ $rc -ne 0 ]]; then
DOH_FORMAT_ERROR_DETAIL=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')
fi
rm -f "$tmp_out"
return $rc
}
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env bash
if [[ -n "${__PROXMENUX_GPU_HOOK_GUARD_HELPERS__}" ]]; then
return 0
fi
__PROXMENUX_GPU_HOOK_GUARD_HELPERS__=1
PROXMENUX_GPU_HOOK_STORAGE_REF="local:snippets/proxmenux-gpu-guard.sh"
PROXMENUX_GPU_HOOK_ABS_PATH="/var/lib/vz/snippets/proxmenux-gpu-guard.sh"
_gpu_guard_msg_warn() {
if declare -F msg_warn >/dev/null 2>&1; then
msg_warn "$1"
else
echo "[WARN] $1" >&2
fi
}
_gpu_guard_msg_ok() {
if declare -F msg_ok >/dev/null 2>&1; then
msg_ok "$1"
else
echo "[OK] $1"
fi
}
_gpu_guard_has_vm_gpu() {
local vmid="$1"
qm config "$vmid" 2>/dev/null | grep -qE '^hostpci[0-9]+:'
}
_gpu_guard_has_lxc_gpu() {
local ctid="$1"
local conf="/etc/pve/lxc/${ctid}.conf"
[[ -f "$conf" ]] || return 1
grep -qE 'dev[0-9]+:.*(/dev/dri|/dev/nvidia|/dev/kfd)|lxc\.mount\.entry:.*dev/dri' "$conf" 2>/dev/null
}
ensure_proxmenux_gpu_guard_hookscript() {
mkdir -p /var/lib/vz/snippets 2>/dev/null || true
cat >"$PROXMENUX_GPU_HOOK_ABS_PATH" <<'HOOKEOF'
#!/usr/bin/env bash
set -u
arg1="${1:-}"
arg2="${2:-}"
case "$arg1" in
pre-start|post-start|pre-stop|post-stop)
phase="$arg1"
guest_id="$arg2"
;;
*)
guest_id="$arg1"
phase="$arg2"
;;
esac
[[ "$phase" == "pre-start" ]] || exit 0
vm_conf="/etc/pve/qemu-server/${guest_id}.conf"
ct_conf="/etc/pve/lxc/${guest_id}.conf"
if [[ -f "$vm_conf" ]]; then
mapfile -t hostpci_lines < <(grep -E '^hostpci[0-9]+:' "$vm_conf" 2>/dev/null || true)
[[ ${#hostpci_lines[@]} -eq 0 ]] && exit 0
# Build slot list used by this VM and block if any running VM already uses same slot.
slot_keys=()
for line in "${hostpci_lines[@]}"; do
val="${line#*: }"
[[ "$val" == *"mapping="* ]] && continue
first_field="${val%%,*}"
IFS=';' read -r -a ids <<< "$first_field"
for id in "${ids[@]}"; do
id="${id#host=}"
id="${id// /}"
[[ -z "$id" ]] && continue
if [[ "$id" =~ ^[0-9a-fA-F]{2}:[0-9a-fA-F]{2}$ ]]; then
key="${id,,}"
else
[[ "$id" =~ ^0000: ]] || id="0000:${id}"
key="${id#0000:}"
key="${key%.*}"
key="${key,,}"
fi
dup=0
for existing in "${slot_keys[@]}"; do
[[ "$existing" == "$key" ]] && dup=1 && break
done
[[ "$dup" -eq 0 ]] && slot_keys+=("$key")
done
done
if [[ ${#slot_keys[@]} -gt 0 ]]; then
conflict_details=""
for other_conf in /etc/pve/qemu-server/*.conf; do
[[ -f "$other_conf" ]] || continue
other_vmid="$(basename "$other_conf" .conf)"
[[ "$other_vmid" == "$guest_id" ]] && continue
qm status "$other_vmid" 2>/dev/null | grep -q "status: running" || continue
for key in "${slot_keys[@]}"; do
if grep -qE "^hostpci[0-9]+:.*(0000:)?${key}(\\.[0-7])?([,[:space:]]|$)" "$other_conf" 2>/dev/null; then
other_name="$(awk '/^name:/ {print $2}' "$other_conf" 2>/dev/null)"
[[ -z "$other_name" ]] && other_name="VM-${other_vmid}"
conflict_details+=$'\n'"- ${key} in use by VM ${other_vmid} (${other_name})"
break
fi
done
done
if [[ -n "$conflict_details" ]]; then
echo "ProxMenux GPU Guard: VM ${guest_id} blocked at pre-start." >&2
echo "A hostpci device slot is already in use by another running VM." >&2
printf '%s\n' "$conflict_details" >&2
echo "Stop the source VM or remove/move the shared hostpci assignment." >&2
exit 1
fi
fi
failed=0
details=""
for line in "${hostpci_lines[@]}"; do
val="${line#*: }"
[[ "$val" == *"mapping="* ]] && continue
first_field="${val%%,*}"
IFS=';' read -r -a ids <<< "$first_field"
for id in "${ids[@]}"; do
id="${id#host=}"
id="${id// /}"
[[ -z "$id" ]] && continue
# Slot-only syntax (e.g. 01:00 or 0000:01:00) is accepted by Proxmox.
if [[ "$id" =~ ^([0-9a-fA-F]{4}:)?[0-9a-fA-F]{2}:[0-9a-fA-F]{2}$ ]]; then
slot="${id,,}"
slot="${slot#0000:}"
slot_has_gpu=false
for dev in /sys/bus/pci/devices/0000:${slot}.*; do
[[ -e "$dev" ]] || continue
# SR-IOV: skip Virtual Functions when iterating a whole slot.
# VFs share the slot with their PF but carry their own driver
# state; their vfio-pci rebind is handled by Proxmox at VM
# start. Pre-flighting them would falsely block SR-IOV setups
# where the PF legitimately stays on the native driver.
[[ -L "${dev}/physfn" ]] && continue
class_hex="$(cat "$dev/class" 2>/dev/null | sed 's/^0x//')"
[[ "${class_hex:0:2}" != "03" ]] && continue
slot_has_gpu=true
drv="$(basename "$(readlink "$dev/driver" 2>/dev/null)" 2>/dev/null)"
if [[ "$drv" != "vfio-pci" ]]; then
failed=1
details+=$'\n'"- ${dev##*/}: driver=${drv:-none}"
fi
done
# If this slot does not include a display/3D controller, it is not GPU-guarded.
[[ "$slot_has_gpu" == "true" ]] || true
continue
fi
[[ "$id" =~ ^0000: ]] || id="0000:${id}"
dev_path="/sys/bus/pci/devices/${id}"
if [[ ! -d "$dev_path" ]]; then
failed=1
details+=$'\n'"- ${id}: PCI device not found"
continue
fi
# SR-IOV VF: do not pre-flight the driver. Proxmox rebinds the VF
# to vfio-pci as part of VM start; at pre-start time the VF may
# still be on its native driver (i915, etc.) — that is normal,
# not an error. Blocking here would prevent every SR-IOV VF
# passthrough from starting.
if [[ -L "${dev_path}/physfn" ]]; then
continue
fi
class_hex="$(cat "$dev_path/class" 2>/dev/null | sed 's/^0x//')"
# Enforce vfio only for display/3D devices (PCI class 03xx).
[[ "${class_hex:0:2}" == "03" ]] || continue
drv="$(basename "$(readlink "$dev_path/driver" 2>/dev/null)" 2>/dev/null)"
if [[ "$drv" != "vfio-pci" ]]; then
failed=1
details+=$'\n'"- ${id}: driver=${drv:-none}"
fi
done
done
if [[ "$failed" -eq 1 ]]; then
echo "ProxMenux GPU Guard: VM ${guest_id} blocked at pre-start." >&2
echo "GPU passthrough device is not ready for VM mode (vfio-pci required)." >&2
printf '%s\n' "$details" >&2
echo "Switch mode to GPU -> VM from ProxMenux: GPUs and Coral-TPU Menu." >&2
exit 1
fi
exit 0
fi
if [[ -f "$ct_conf" ]]; then
mapfile -t gpu_dev_paths < <(
{
grep -E '^dev[0-9]+:' "$ct_conf" 2>/dev/null | sed -E 's/^dev[0-9]+:[[:space:]]*([^,[:space:]]+).*/\1/'
grep -E '^lxc\.mount\.entry:' "$ct_conf" 2>/dev/null | sed -E 's/^lxc\.mount\.entry:[[:space:]]*([^[:space:]]+).*/\1/'
} | grep -E '^/dev/(dri|nvidia|kfd)' | sort -u
)
[[ ${#gpu_dev_paths[@]} -eq 0 ]] && exit 0
missing=""
for dev in "${gpu_dev_paths[@]}"; do
[[ -e "$dev" ]] || missing+=$'\n'"- ${dev} unavailable"
done
if [[ -n "$missing" ]]; then
echo "ProxMenux GPU Guard: LXC ${guest_id} blocked at pre-start." >&2
echo "Configured GPU devices are unavailable in host device nodes." >&2
printf '%s\n' "$missing" >&2
echo "Switch mode to GPU -> LXC from ProxMenux: GPUs and Coral-TPU Menu." >&2
exit 1
fi
exit 0
fi
exit 0
HOOKEOF
chmod 755 "$PROXMENUX_GPU_HOOK_ABS_PATH" 2>/dev/null || true
}
attach_proxmenux_gpu_guard_to_vm() {
local vmid="$1"
_gpu_guard_has_vm_gpu "$vmid" || return 0
local current
current=$(qm config "$vmid" 2>/dev/null | awk '/^hookscript:/ {print $2}')
if [[ "$current" == "$PROXMENUX_GPU_HOOK_STORAGE_REF" ]]; then
return 0
fi
if qm set "$vmid" --hookscript "$PROXMENUX_GPU_HOOK_STORAGE_REF" >/dev/null 2>&1; then
_gpu_guard_msg_ok "PCIe passthrough guard attached to VM ${vmid}"
else
_gpu_guard_msg_warn "Could not attach PCIe passthrough guard to VM ${vmid}. Ensure 'local' storage supports snippets."
fi
}
attach_proxmenux_gpu_guard_to_lxc() {
local ctid="$1"
_gpu_guard_has_lxc_gpu "$ctid" || return 0
local current
current=$(pct config "$ctid" 2>/dev/null | awk '/^hookscript:/ {print $2}')
if [[ "$current" == "$PROXMENUX_GPU_HOOK_STORAGE_REF" ]]; then
return 0
fi
if pct set "$ctid" -hookscript "$PROXMENUX_GPU_HOOK_STORAGE_REF" >/dev/null 2>&1; then
_gpu_guard_msg_ok "PCIe passthrough guard attached to LXC ${ctid}"
else
_gpu_guard_msg_warn "Could not attach PCIe passthrough guard to LXC ${ctid}. Ensure 'local' storage supports snippets."
fi
}
sync_proxmenux_gpu_guard_hooks() {
ensure_proxmenux_gpu_guard_hookscript
local vmid ctid
for conf in /etc/pve/qemu-server/*.conf; do
[[ -f "$conf" ]] || continue
vmid=$(basename "$conf" .conf)
_gpu_guard_has_vm_gpu "$vmid" && attach_proxmenux_gpu_guard_to_vm "$vmid"
done
for conf in /etc/pve/lxc/*.conf; do
[[ -f "$conf" ]] || continue
ctid=$(basename "$conf" .conf)
_gpu_guard_has_lxc_gpu "$ctid" && attach_proxmenux_gpu_guard_to_lxc "$ctid"
done
}
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env bash
if [[ -n "${__PROXMENUX_PCI_PASSTHROUGH_HELPERS__}" ]]; then
return 0
fi
__PROXMENUX_PCI_PASSTHROUGH_HELPERS__=1
function _pci_is_iommu_active() {
grep -qE 'intel_iommu=on|amd_iommu=on' /proc/cmdline 2>/dev/null || return 1
[[ -d /sys/kernel/iommu_groups ]] || return 1
find /sys/kernel/iommu_groups -mindepth 1 -maxdepth 1 -type d -print -quit 2>/dev/null | grep -q .
}
# Audio-companion cascade helpers (Part 2 of the SR-IOV / audio rework).
#
# When a GPU is detached from a VM (user chooses "Remove GPU from VM
# config" during a mode switch), the historic sed-based cleanup only
# removes hostpci lines that match the GPU's PCI slot (e.g. 00:02).
# That leaves any "companion" audio that lives at a different slot —
# typically the chipset audio at 00:1f.X, which add_gpu_vm.sh now adds
# alongside an Intel iGPU via the checklist from Part 1 — stranded in
# the VM config. On the next VM start, vfio-pci is no longer claiming
# that audio device (its vendor:device was pulled from vfio.conf
# during the switch-back) and either QEMU fails to rebind it or it
# breaks host audio.
#
# _vm_list_orphan_audio_hostpci reports those stranded entries; each
# caller uses its own UI (dialog, whiptail, hybrid_msgbox) to confirm
# removal and then calls _vm_remove_hostpci_index per selected entry.
# Usage: _vm_list_orphan_audio_hostpci <vmid> <gpu_slot_base>
# gpu_slot_base: the GPU's PCI slot WITHOUT function suffix, e.g. "00:02".
# Output: one line per orphan entry, in the form "idx|bdf|human_name".
# Empty output when the VM has no audio passthrough outside the GPU slot.
#
# A hostpci audio entry is reported as "orphan" ONLY if the same VM has
# no display/3D-class hostpci at the same slot base. Rationale: the
# audio at e.g. 02:00.1 is the HDMI codec of a dGPU at 02:00.0 — if
# that dGPU is still being passed through to this VM (as a separate
# hostpciN), the audio belongs to it and must not be touched when
# detaching an unrelated GPU (e.g. an Intel iGPU at 00:02.0) from the
# same VM. Without this filter we would strip the HDMI audio of every
# other GPU in the VM, leaving them silent on next start.
function _vm_list_orphan_audio_hostpci() {
local vmid="$1" gpu_slot="$2"
[[ -n "$vmid" && -n "$gpu_slot" ]] || return 1
local conf="/etc/pve/qemu-server/${vmid}.conf"
[[ -f "$conf" ]] || return 1
# ── Pass 1 ── collect the slot bases of hostpci entries whose target
# device is display/3D (class 03xx). These slots "own" any audio at
# the same slot base (the .1 HDMI codec pattern).
local -a display_slots=()
local line raw_bdf bdf class_hex slot_base
while IFS= read -r line; do
raw_bdf=$(printf '%s' "$line" \
| grep -oE '(0000:)?[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-7]' \
| head -1)
[[ -z "$raw_bdf" ]] && continue
bdf="$raw_bdf"
[[ "$bdf" =~ ^0000: ]] || bdf="0000:$bdf"
class_hex=$(cat "/sys/bus/pci/devices/${bdf}/class" 2>/dev/null | sed 's/^0x//')
if [[ "${class_hex:0:2}" == "03" ]]; then
slot_base="${bdf#0000:}"
slot_base="${slot_base%.*}"
display_slots+=("$slot_base")
fi
done < <(grep -E '^hostpci[0-9]+:' "$conf")
# ── Pass 2 ── classify audio entries.
local idx raw name
local has_display_sibling ds
while IFS= read -r line; do
idx=$(printf '%s' "$line" | sed -nE 's/^hostpci([0-9]+):.*/\1/p')
[[ -z "$idx" ]] && continue
raw=$(printf '%s' "$line" \
| grep -oE '(0000:)?[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-7]' \
| head -1)
[[ -z "$raw" ]] && continue
bdf="$raw"
[[ "$bdf" =~ ^0000: ]] || bdf="0000:$bdf"
slot_base="${bdf#0000:}"
slot_base="${slot_base%.*}"
# Skip entries that match the GPU slot — those go through the
# caller's primary sed/qm-set cleanup, not through this helper.
[[ "$slot_base" == "$gpu_slot" ]] && continue
# Only audio class devices (PCI class 04xx) are candidates.
class_hex=$(cat "/sys/bus/pci/devices/${bdf}/class" 2>/dev/null | sed 's/^0x//')
[[ "${class_hex:0:2}" == "04" ]] || continue
# Display-sibling guard: skip audio that is the HDMI/DP codec of a
# still-present dGPU in this VM.
has_display_sibling=false
for ds in "${display_slots[@]}"; do
if [[ "$ds" == "$slot_base" ]]; then
has_display_sibling=true
break
fi
done
$has_display_sibling && continue
name=$(lspci -nn -s "${bdf#0000:}" 2>/dev/null \
| sed 's/^[^ ]* //' \
| cut -c1-52)
[[ -z "$name" ]] && name="PCI audio device"
printf '%s|%s|%s\n' "$idx" "$bdf" "$name"
done < <(grep -E '^hostpci[0-9]+:' "$conf")
}
# Returns 0 if the given PCI BDF still appears as a hostpci passthrough
# target in any VM config, optionally excluding one or more VM IDs.
# Usage: _pci_bdf_in_any_vm <bdf> [excluded_vmid]...
#
# Used by the switch-mode cascade to decide whether a companion audio
# device's vendor:device pair is safe to remove from /etc/modprobe.d/
# vfio.conf (only if no other VM still references it).
function _pci_bdf_in_any_vm() {
local bdf="$1"; shift
[[ -n "$bdf" ]] || return 1
local short_bdf="${bdf#0000:}"
local conf vmid ex skip
for conf in /etc/pve/qemu-server/*.conf; do
[[ -f "$conf" ]] || continue
vmid=$(basename "$conf" .conf)
skip=false
for ex in "$@"; do
if [[ "$vmid" == "$ex" ]]; then
skip=true
break
fi
done
$skip && continue
if grep -qE "^hostpci[0-9]+:.*(0000:)?${short_bdf}([,[:space:]]|$)" "$conf" 2>/dev/null; then
return 0
fi
done
return 1
}
# Usage: _vm_remove_hostpci_index <vmid> <idx> [log_file]
# Removes hostpci<idx> from the VM config via `qm set --delete` so the
# change goes through Proxmox's own validation path (running VMs get a
# staged update). Returns the exit code of qm set.
function _vm_remove_hostpci_index() {
local vmid="$1" idx="$2"
local log="${3:-${LOG_FILE:-/dev/null}}"
[[ -n "$vmid" && -n "$idx" ]] || return 1
qm set "$vmid" --delete "hostpci${idx}" >>"$log" 2>&1
}
# Robust LXC stop for switch-mode / passthrough flows.
#
# A plain `pct stop` can hang indefinitely when:
# - the container has a stale lock from a previous aborted operation,
# - processes inside the container (Plex, Jellyfin, databases) ignore
# the initial TERM and sit in uninterruptible-sleep (D state) while
# the GPU they were using is being yanked out,
# - the host is under load and Proxmox's state polling stalls,
# - `pct shutdown --timeout` is not always enforced by pct itself
# (observed field reports of 5+ min waits despite --timeout 30).
#
# Strategy:
# 1) return 0 immediately if the container is not running,
# 2) clear any stale lock (most common cause of hangs),
# 3) try `pct shutdown --forceStop 1 --timeout 30`, wrapped in an
# external `timeout 45` as belt-and-braces in case pct itself
# blocks on backend I/O,
# 4) verify actual status via `pct status` — do not trust exit codes,
# pct can return non-zero while the container is actually stopped,
# 5) if still running, fall back to `pct stop` wrapped in `timeout 60`,
# 6) verify again and return 1 if the container is truly stuck
# (only happens when processes are in D state — requires manual
# intervention, but the wizard moves on instead of hanging).
#
# Usage: _pmx_stop_lxc <ctid> [log_file]
# log_file defaults to $LOG_FILE if set, otherwise /dev/null.
# Returns 0 on stopped / already-stopped, non-zero if every attempt failed.
function _pmx_stop_lxc() {
local ctid="$1"
local log="${2:-${LOG_FILE:-/dev/null}}"
_pmx_lxc_running() {
pct status "$1" 2>/dev/null | grep -q "status: running"
}
_pmx_lxc_running "$ctid" || return 0
# Best-effort unlock — silent on failure because most containers aren't
# actually locked; we only care about the cases where they are.
pct unlock "$ctid" >>"$log" 2>&1 || true
# Graceful shutdown with forced kill after 30 s. The external `timeout 45`
# guarantees we never wait longer than that for this step, even if pct
# itself is stuck (the cushion over 30 s is to let the internal timeout
# cleanly unwind before we kill pct).
timeout 45 pct shutdown "$ctid" --forceStop 1 --timeout 30 >>"$log" 2>&1 || true
sleep 1
_pmx_lxc_running "$ctid" || return 0
# Fallback: abrupt stop, also externally capped so the wizard does not
# hang the user indefinitely if lxc-stop blocks on D-state processes.
timeout 60 pct stop "$ctid" >>"$log" 2>&1 || true
sleep 1
_pmx_lxc_running "$ctid" || return 0
return 1
}
function _pci_next_hostpci_index() {
local vmid="$1"
local idx=0
local hostpci_existing
hostpci_existing=$(qm config "$vmid" 2>/dev/null) || return 1
while grep -q "^hostpci${idx}:" <<< "$hostpci_existing"; do
idx=$((idx + 1))
done
echo "$idx"
}
function _pci_slot_assigned_to_vm() {
local pci_full="$1"
local vmid="$2"
local slot_base
slot_base="${pci_full#0000:}"
slot_base="${slot_base%.*}"
qm config "$vmid" 2>/dev/null \
| grep -qE "^hostpci[0-9]+:.*(0000:)?${slot_base}(\\.[0-7])?([,[:space:]]|$)"
}
function _pci_function_assigned_to_vm() {
local pci_full="$1"
local vmid="$2"
local bdf slot func pattern
bdf="${pci_full#0000:}"
slot="${bdf%.*}"
func="${bdf##*.}"
if [[ "$func" == "0" ]]; then
pattern="^hostpci[0-9]+:.*(0000:)?(${bdf}|${slot})([,:[:space:]]|$)"
else
pattern="^hostpci[0-9]+:.*(0000:)?${bdf}([,[:space:]]|$)"
fi
qm config "$vmid" 2>/dev/null | grep -qE "$pattern"
}
# ==========================================================
# SR-IOV detection helpers
# ==========================================================
# A PCI device participates in SR-IOV when either:
# - It is a Physical Function (PF) with one or more active VFs
# → /sys/bus/pci/devices/<BDF>/sriov_numvfs > 0
# - It is a Virtual Function (VF) spawned by a PF
# → /sys/bus/pci/devices/<BDF>/physfn is a symlink to the PF
#
# These helpers accept a BDF in either "0000:00:02.0" or "00:02.0" form.
# Return 0 on match, non-zero otherwise (shell convention).
function _pci_normalize_bdf() {
local id="$1"
[[ -z "$id" ]] && return 1
[[ "$id" =~ ^0000: ]] || id="0000:${id}"
printf '%s\n' "$id"
}
function _pci_is_vf() {
local id
id=$(_pci_normalize_bdf "$1") || return 1
[[ -L "/sys/bus/pci/devices/${id}/physfn" ]]
}
function _pci_get_pf_of_vf() {
local id
id=$(_pci_normalize_bdf "$1") || return 1
local link="/sys/bus/pci/devices/${id}/physfn"
[[ -L "$link" ]] || return 1
basename "$(readlink -f "$link")"
}
function _pci_is_sriov_capable() {
local id total
id=$(_pci_normalize_bdf "$1") || return 1
total=$(cat "/sys/bus/pci/devices/${id}/sriov_totalvfs" 2>/dev/null)
[[ -n "$total" && "$total" -gt 0 ]]
}
function _pci_active_vf_count() {
local id num
id=$(_pci_normalize_bdf "$1") || { echo 0; return 1; }
num=$(cat "/sys/bus/pci/devices/${id}/sriov_numvfs" 2>/dev/null)
[[ -n "$num" ]] || num=0
echo "$num"
}
function _pci_has_active_vfs() {
local n
n=$(_pci_active_vf_count "$1")
[[ "$n" -gt 0 ]]
}
# Filter an array (by name) of PCI BDFs in place, removing entries that
# are SR-IOV Virtual Functions or Physical Functions with active VFs —
# i.e. the configurations ProxMenux refuses to operate on today.
#
# Usage: _pci_sriov_filter_array <array_name_by_ref>
# Output: one line per removed entry, formatted "BDF|role" where role is
# whatever _pci_sriov_role prints (e.g. "vf 0000:00:02.0" or
# "pf-active 7"). The caller decides how to surface the removals.
# Returns: 0 if the caller should continue (even if some entries were
# filtered); the array mutation happens either way.
function _pci_sriov_filter_array() {
local -n _arr_ref="$1"
local -a _kept=()
local bdf role first
for bdf in "${_arr_ref[@]}"; do
role=$(_pci_sriov_role "$bdf" 2>/dev/null)
first="${role%% *}"
if [[ "$first" == "vf" || "$first" == "pf-active" ]]; then
echo "${bdf}|${role}"
else
_kept+=("$bdf")
fi
done
_arr_ref=("${_kept[@]}")
}
# Emits a one-line SR-IOV role description for diagnostics/messages.
# Prints one of:
# "pf-active <N>" — PF with N>0 active VFs
# "pf-idle" — SR-IOV capable PF with 0 VFs (benign)
# "vf <PF-BDF>" — VF (names its parent PF)
# "none" — device not involved in SR-IOV
function _pci_sriov_role() {
local id
id=$(_pci_normalize_bdf "$1") || { echo "none"; return 0; }
if _pci_is_vf "$id"; then
echo "vf $(_pci_get_pf_of_vf "$id")"
return 0
fi
if _pci_is_sriov_capable "$id"; then
local n
n=$(_pci_active_vf_count "$id")
if [[ "$n" -gt 0 ]]; then
echo "pf-active ${n}"
else
echo "pf-idle"
fi
return 0
fi
echo "none"
}
-277
View File
@@ -1,277 +0,0 @@
#!/bin/bash
# ==========================================================
# Remove Subscription Banner - Proxmox VE (v3 - Minimal Intrusive)
# ==========================================================
# This version makes a surgical change to the checked_command function
# by changing the condition to 'if (false)' and commenting out the banner logic.
# Also patches the mobile UI to remove the subscription dialog.
# ==========================================================
set -euo pipefail
# Source utilities if available
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
# File paths
JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
MIN_JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
MOBILE_UI_FILE="/usr/share/pve-yew-mobile-gui/index.html.tpl"
BACKUP_DIR="$BASE_DIR/backups"
APT_HOOK="/etc/apt/apt.conf.d/no-nag-script"
PATCH_BIN="/usr/local/bin/pve-remove-nag-v3.sh"
MARK="/* PROXMENUX_NAG_PATCH_V3 */"
MOBILE_MARK="<!-- PROXMENUX_MOBILE_NAG_PATCH -->"
# Ensure tools JSON exists
ensure_tools_json() {
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
}
# Register tool in JSON
register_tool() {
command -v jq >/dev/null 2>&1 || return 0
local tool="$1" state="$2"
ensure_tools_json
jq --arg t "$tool" --argjson v "$state" '.[$t]=$v' "$TOOLS_JSON" \
> "$TOOLS_JSON.tmp" && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
}
# Verify JS file integrity
verify_js_integrity() {
local file="$1"
[ -f "$file" ] || return 1
[ -s "$file" ] || return 1
grep -Eq 'Ext|function|var|const|let' "$file" || return 1
if LC_ALL=C grep -qP '\x00' "$file" 2>/dev/null; then
return 1
fi
return 0
}
# Create timestamped backup
create_backup() {
local file="$1"
local timestamp
timestamp=$(date +%Y%m%d_%H%M%S)
local backup_file="$BACKUP_DIR/$(basename "$file").backup.$timestamp"
mkdir -p "$BACKUP_DIR"
if [ -f "$file" ]; then
rm -f "$BACKUP_DIR"/"$(basename "$file")".backup.* 2>/dev/null || true
cp -a "$file" "$backup_file"
echo "$backup_file"
fi
}
# Create the patch script that will be called by APT hook
create_patch_script() {
cat > "$PATCH_BIN" <<'EOFPATCH'
#!/usr/bin/env bash
# ==========================================================
# Proxmox Subscription Banner Patch (v3 - Minimal)
# ==========================================================
set -euo pipefail
JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
MIN_JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
MOBILE_UI_FILE="/usr/share/pve-yew-mobile-gui/index.html.tpl"
BACKUP_DIR="/usr/local/share/proxmenux/backups"
MARK="/* PROXMENUX_NAG_PATCH_V3 */"
MOBILE_MARK="<!-- PROXMENUX_MOBILE_NAG_PATCH -->"
verify_js_integrity() {
local file="$1"
[ -f "$file" ] && [ -s "$file" ] && grep -Eq 'Ext|function' "$file" && ! LC_ALL=C grep -qP '\x00' "$file" 2>/dev/null
}
patch_checked_command() {
[ -f "$JS_FILE" ] || return 0
# Check if already patched
grep -q "$MARK" "$JS_FILE" && return 0
# Create backup
mkdir -p "$BACKUP_DIR"
local backup="$BACKUP_DIR/$(basename "$JS_FILE").backup.$(date +%Y%m%d_%H%M%S)"
cp -a "$JS_FILE" "$backup"
# Set trap to restore on error
trap "cp -a '$backup' '$JS_FILE' 2>/dev/null || true" ERR
# Add patch marker at the beginning
sed -i "1s|^|$MARK\n|" "$JS_FILE"
# Surgical patch: Change the condition in checked_command function
# This changes the if condition to 'if (false)' making the banner never show
if grep -q "res\.data\.status\.toLowerCase() !== 'active'" "$JS_FILE"; then
# Pattern for newer versions (8.4.5+)
sed -i "/checked_command: function/,/},$/s/res === null || res === undefined || !res || res\.data\.status\.toLowerCase() !== 'active'/false/g" "$JS_FILE"
elif grep -q "res\.data\.status !== 'Active'" "$JS_FILE"; then
# Pattern for older versions
sed -i "/checked_command: function/,/},$/s/res === null || res === undefined || !res || res\.data\.status !== 'Active'/false/g" "$JS_FILE"
fi
# Also handle the NoMoreNagging pattern if present
if grep -q "res\.data\.status\.toLowerCase() !== 'NoMoreNagging'" "$JS_FILE"; then
sed -i "/checked_command: function/,/},$/s/res === null || res === undefined || !res || res\.data\.status\.toLowerCase() !== 'NoMoreNagging'/false/g" "$JS_FILE"
fi
# Verify integrity after patch
if ! verify_js_integrity "$JS_FILE"; then
cp -a "$backup" "$JS_FILE"
return 1
fi
# Clean up generated files
rm -f "$MIN_JS_FILE" "$GZ_FILE" 2>/dev/null || true
find /var/cache/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
find /var/lib/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
find /var/cache/nginx/ -type f -delete 2>/dev/null || true
trap - ERR
return 0
}
patch_mobile_ui() {
[ -f "$MOBILE_UI_FILE" ] || return 0
# Check if already patched
grep -q "$MOBILE_MARK" "$MOBILE_UI_FILE" && return 0
# Create backup
mkdir -p "$BACKUP_DIR"
local backup="$BACKUP_DIR/$(basename "$MOBILE_UI_FILE").backup.$(date +%Y%m%d_%H%M%S)"
cp -a "$MOBILE_UI_FILE" "$backup"
# Set trap to restore on error
trap "cp -a '$backup' '$MOBILE_UI_FILE' 2>/dev/null || true" ERR
# Insert the script before </head> tag
sed -i "/<\/head>/i\\
$MOBILE_MARK\\
<!-- Script to remove subscription banner from mobile UI -->\\
<script>\\
function removeNoSubDialog() {\\
const observer = new MutationObserver(() => {\\
const diag = document.querySelector('dialog[aria-label=\"No valid subscription\"]');\\
if (diag) {\\
diag.remove();\\
}\\
});\\
observer.observe(document.body, { childList: true, subtree: true });\\
}\\
window.addEventListener('load', () => {\\
setTimeout(removeNoSubDialog, 200);\\
});\\
</script>" "$MOBILE_UI_FILE"
trap - ERR
return 0
}
reload_services() {
systemctl is-active --quiet pveproxy 2>/dev/null && {
systemctl reload pveproxy 2>/dev/null || systemctl restart pveproxy 2>/dev/null || true
}
systemctl is-active --quiet nginx 2>/dev/null && {
systemctl reload nginx 2>/dev/null || true
}
systemctl is-active --quiet pvedaemon 2>/dev/null && {
systemctl reload pvedaemon 2>/dev/null || true
}
}
main() {
patch_checked_command || return 1
patch_mobile_ui || true
reload_services
}
main
EOFPATCH
chmod 755 "$PATCH_BIN"
}
# Create APT hook to reapply patch after updates
create_apt_hook() {
cat > "$APT_HOOK" <<'EOFAPT'
/* ProxMenux: reapply minimal nag patch after upgrades */
DPkg::Post-Invoke { "/usr/local/bin/pve-remove-nag-v3.sh || true"; };
EOFAPT
chmod 644 "$APT_HOOK"
# Verify APT hook syntax
apt-config dump >/dev/null 2>&1 || {
rm -f "$APT_HOOK"
}
}
# Main function to remove subscription banner
remove_subscription_banner_v3() {
local pve_version
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1 || echo "unknown")
msg_info "$(translate "Detected Proxmox VE") ${pve_version} - $(translate "applying banner patch")"
# Remove old APT hooks
for f in /etc/apt/apt.conf.d/*nag*; do
[[ -e "$f" ]] && rm -f "$f"
done
# Create backup for desktop UI
local backup_file
backup_file=$(create_backup "$JS_FILE")
if [ -n "$backup_file" ]; then
# msg_ok "$(translate "Desktop UI backup created"): $backup_file"
:
fi
if [ -f "$MOBILE_UI_FILE" ]; then
local mobile_backup
mobile_backup=$(create_backup "$MOBILE_UI_FILE")
if [ -n "$mobile_backup" ]; then
# msg_ok "$(translate "Mobile UI backup created"): $mobile_backup"
:
fi
fi
# Create patch script and APT hook
create_patch_script
create_apt_hook
# Apply the patch
if ! "$PATCH_BIN"; then
msg_error "$(translate "Error applying patch. Backups preserved at"): $BACKUP_DIR"
return 1
fi
# Register tool as applied
register_tool "subscription_banner" true
msg_ok "$(translate "Subscription banner removed successfully")"
msg_ok "$(translate "Desktop and Mobile UI patched")"
msg_ok "$(translate "Refresh your browser (Ctrl+Shift+R) to see changes")"
}
# Run if executed directly
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
remove_subscription_banner_v3
fi
-124
View File
@@ -1,124 +0,0 @@
#!/bin/bash
# ==========================================================
# Remove Subscription Banner - Proxmox VE 9.x
# ==========================================================
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
ensure_tools_json() {
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
}
register_tool() {
local tool="$1"
local state="$2"
ensure_tools_json
jq --arg t "$tool" --argjson v "$state" '.[$t]=$v' "$TOOLS_JSON" > "$TOOLS_JSON.tmp" && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
}
remove_subscription_banner_pve9() {
local JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
local MIN_JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
local GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
local APT_HOOK="/etc/apt/apt.conf.d/no-nag-script"
local pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1)
local pve_major=$(echo "$pve_version" | cut -d. -f1)
if [ "$pve_major" -lt 9 ] 2>/dev/null; then
msg_error "This script is for PVE 9.x only. Detected PVE $pve_version"
return 1
fi
msg_info "Detected Proxmox VE $pve_version - Applying PVE 9.x patches"
if [ ! -f "$JS_FILE" ]; then
msg_error "JavaScript file not found: $JS_FILE"
return 1
fi
local backup_file="${JS_FILE}.backup.pve9.$(date +%Y%m%d_%H%M%S)"
cp "$JS_FILE" "$backup_file"
for f in /etc/apt/apt.conf.d/*nag*; do
[[ -e "$f" ]] && rm -f "$f"
done
[[ -f "$GZ_FILE" ]] && rm -f "$GZ_FILE"
[[ -f "$MIN_JS_FILE" ]] && rm -f "$MIN_JS_FILE"
find /var/cache/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
find /var/lib/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
find /var/cache/nginx/ -type f -delete 2>/dev/null || true
sed -i "s/res\.data\.status\.toLowerCase() !== 'active'/false/g" "$JS_FILE"
sed -i "s/subscriptionActive: ''/subscriptionActive: true/g" "$JS_FILE"
sed -i "s/title: gettext('No valid subscription')/title: gettext('Community Edition')/g" "$JS_FILE"
sed -i "s/You do not have a valid subscription for this server/Community Edition - No subscription required/g" "$JS_FILE"
sed -i "s/Enterprise repository needs valid subscription/Enterprise repository configured/g" "$JS_FILE"
sed -i "s/icon: Ext\.Msg\.WARNING/icon: Ext.Msg.INFO/g" "$JS_FILE"
sed -i "s/subscription = !(/subscription = false \&\& (/g" "$JS_FILE"
if grep -q "res\.data\.status\.toLowerCase() !== 'active'" "$JS_FILE"; then
msg_warn "Some patches may not have applied correctly, retrying..."
sed -i "s/res\.data\.status\.toLowerCase() !== 'active'/false/g" "$JS_FILE"
fi
[[ -f "$APT_HOOK" ]] && rm -f "$APT_HOOK"
cat > "$APT_HOOK" << 'EOF'
DPkg::Post-Invoke {
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/res\\.data\\.status\\.toLowerCase() !== '\''active'\''/false/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/subscriptionActive: '\'\'\''/subscriptionActive: true/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/title: gettext('\''No valid subscription'\'')/title: gettext('\''Community Edition'\'')/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/subscription = !(/subscription = false \\&\\& (/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
"rm -f /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz || true";
};
EOF
chmod 644 "$APT_HOOK"
if ! apt-config dump >/dev/null 2>&1; then
msg_warn "APT hook has syntax issues, removing..."
rm -f "$APT_HOOK"
else
msg_ok "APT hook created successfully"
fi
systemctl reload nginx 2>/dev/null || true
msg_ok "Subscription banner removed successfully for Proxmox VE $pve_version"
msg_ok "Banner removal process completed - refresh your browser to see changes"
register_tool "subscription_banner" true
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
remove_subscription_banner_pve9
fi
-119
View File
@@ -1,119 +0,0 @@
#!/bin/bash
# ==========================================================
# Remove Subscription Banner - Proxmox VE 9.x ONLY
# ==========================================================
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
# Tool registration system
ensure_tools_json() {
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
}
register_tool() {
local tool="$1"
local state="$2"
ensure_tools_json
jq --arg t "$tool" --argjson v "$state" '.[$t]=$v' "$TOOLS_JSON" > "$TOOLS_JSON.tmp" && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
}
remove_subscription_banner_pve9() {
local JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
local MIN_JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
local GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
local APT_HOOK="/etc/apt/apt.conf.d/no-nag-script"
# Verify PVE 9.x
local pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1)
local pve_major=$(echo "$pve_version" | cut -d. -f1)
if [ "$pve_major" -lt 9 ] 2>/dev/null; then
msg_error "This script is for PVE 9.x only. Detected PVE $pve_version"
return 1
fi
msg_info "Detected Proxmox VE $pve_version - Applying PVE 9.x patches"
# Verify that the file exists
if [ ! -f "$JS_FILE" ]; then
msg_error "JavaScript file not found: $JS_FILE"
return 1
fi
# Create backup of original file
local backup_file="${JS_FILE}.backup.pve9.$(date +%Y%m%d_%H%M%S)"
cp "$JS_FILE" "$backup_file"
# Clean any existing problematic APT hooks
for f in /etc/apt/apt.conf.d/*nag*; do
[[ -e "$f" ]] && rm -f "$f"
done
# Main subscription check patches for PVE 9
sed -i "s/res\.data\.status\.toLowerCase() !== 'active'/false/g" "$JS_FILE"
sed -i "s/subscriptionActive: ''/subscriptionActive: true/g" "$JS_FILE"
sed -i "s/title: gettext('No valid subscription')/title: gettext('Community Edition')/g" "$JS_FILE"
# Additional UX improvements for PVE 9
sed -i "s/You do not have a valid subscription for this server/Community Edition - No subscription required/g" "$JS_FILE"
sed -i "s/Enterprise repository needs valid subscription/Enterprise repository configured/g" "$JS_FILE"
sed -i "s/icon: Ext\.Msg\.WARNING/icon: Ext.Msg.INFO/g" "$JS_FILE"
# Additional subscription patterns that may exist in PVE 9
sed -i "s/subscription = !(/subscription = false \&\& (/g" "$JS_FILE"
# Remove compressed/minified files to force regeneration
[[ -f "$GZ_FILE" ]] && rm -f "$GZ_FILE"
[[ -f "$MIN_JS_FILE" ]] && rm -f "$MIN_JS_FILE"
# Clear various caches
find /var/cache/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
find /var/lib/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
# Create PVE 9.x specific APT hook
[[ -f "$APT_HOOK" ]] && rm -f "$APT_HOOK"
cat > "$APT_HOOK" << 'EOF'
DPkg::Post-Invoke {
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/res\\.data\\.status\\.toLowerCase() !== '\''active'\''/false/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/subscriptionActive: '\'\'\''/subscriptionActive: true/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/title: gettext('\''No valid subscription'\'')/title: gettext('\''Community Edition'\'')/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/subscription = !(/subscription = false \\&\\& (/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
"rm -f /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz || true";
};
EOF
chmod 644 "$APT_HOOK"
# Verify APT hook syntax
if ! apt-config dump >/dev/null 2>&1; then
msg_warn "APT hook has syntax issues, removing..."
rm -f "$APT_HOOK"
else
msg_ok "APT hook created successfully"
fi
msg_ok "Subscription banner removed successfully for Proxmox VE $pve_version"
msg_ok "Banner removal process completed"
register_tool "subscription_banner" true
}
# Execute function if called directly
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
remove_subscription_banner_pve9
fi
-257
View File
@@ -1,257 +0,0 @@
#!/bin/bash
# ==========================================================
# Remove Subscription Banner - Proxmox VE 9.x (Clean Version)
# ==========================================================
set -euo pipefail
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
ensure_tools_json() {
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
}
register_tool() {
command -v jq >/dev/null 2>&1 || return 0
local tool="$1" state="$2"
ensure_tools_json
jq --arg t "$tool" --argjson v "$state" '.[$t]=$v' "$TOOLS_JSON" \
> "$TOOLS_JSON.tmp" && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
}
JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
MIN_JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
MOBILE_TPL="/usr/share/pve-yew-mobile-gui/index.html.tpl"
APT_HOOK="/etc/apt/apt.conf.d/no-nag-script"
PATCH_BIN="/usr/local/bin/pve-remove-nag.sh"
MARK_JS="PROXMENUX_NAG_REMOVED_v2"
MARK_MOBILE="<!-- PROXMENUX: MOBILE NAG PATCH v2 -->"
verify_js_integrity() {
local file="$1"
[ -f "$file" ] || return 1
[ -s "$file" ] || return 1
grep -Eq 'Ext|function|var|const|let' "$file" || return 1
if LC_ALL=C grep -qP '\x00' "$file" 2>/dev/null; then
return 1
fi
return 0
}
create_backup() {
local file="$1"
local backup_dir="$BASE_DIR/backups"
local timestamp
timestamp=$(date +%Y%m%d_%H%M%S)
local backup_file="$backup_dir/$(basename "$file").backup.$timestamp"
mkdir -p "$backup_dir"
if [ -f "$file" ]; then
cp -a "$file" "$backup_file"
ls -t "$backup_dir"/"$(basename "$file")".backup.* 2>/dev/null | tail -n +6 | xargs -r rm -f 2>/dev/null || true
echo "$backup_file"
fi
}
# ----------------------------------------------------
create_patch_script() {
cat > "$PATCH_BIN" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
MIN_JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
MOBILE_TPL="/usr/share/pve-yew-mobile-gui/index.html.tpl"
MARK_JS="PROXMENUX_NAG_REMOVED_v2"
MARK_MOBILE="<!-- PROXMENUX: MOBILE NAG PATCH v2 -->"
BASE_DIR="/usr/local/share/proxmenux"
verify_js_integrity() {
local file="$1"
[ -f "$file" ] && [ -s "$file" ] && grep -Eq 'Ext|function' "$file" && ! LC_ALL=C grep -qP '\x00' "$file" 2>/dev/null
}
patch_web() {
[ -f "$JS_FILE" ] || return 0
grep -q "$MARK_JS" "$JS_FILE" && return 0
local backup_dir="$BASE_DIR/backups"
mkdir -p "$backup_dir"
local backup="$backup_dir/$(basename "$JS_FILE").backup.$(date +%Y%m%d_%H%M%S)"
cp -a "$JS_FILE" "$backup"
trap "cp -a '$backup' '$JS_FILE' 2>/dev/null || true" ERR
sed -i '1s|^|/* '"$MARK_JS"' */\n|' "$JS_FILE"
local patterns_found=0
if grep -q "res\.data\.status\.toLowerCase() !== 'active'" "$JS_FILE"; then
sed -i "s/res\.data\.status\.toLowerCase() !== 'active'/false/g" "$JS_FILE"
patterns_found=$((patterns_found + 1))
fi
if grep -q "subscriptionActive: ''" "$JS_FILE"; then
sed -i "s/subscriptionActive: ''/subscriptionActive: true/g" "$JS_FILE"
patterns_found=$((patterns_found + 1))
fi
if grep -q "title: gettext('No valid subscription')" "$JS_FILE"; then
sed -i "s/title: gettext('No valid subscription')/title: gettext('Community Edition')/g" "$JS_FILE"
patterns_found=$((patterns_found + 1))
fi
if grep -q "icon: Ext\.Msg\.WARNING" "$JS_FILE"; then
sed -i "s/icon: Ext\.Msg\.WARNING/icon: Ext.Msg.INFO/g" "$JS_FILE"
patterns_found=$((patterns_found + 1))
fi
if grep -q "subscription = !(" "$JS_FILE"; then
sed -i "s/subscription = !(/subscription = false \&\& (/g" "$JS_FILE"
patterns_found=$((patterns_found + 1))
fi
# Si nada coincidió (cambio upstream), restaura y sal limpio
if [ "${patterns_found:-0}" -eq 0 ]; then
cp -a "$backup" "$JS_FILE"
return 0
fi
# Verificación final
if ! verify_js_integrity "$JS_FILE"; then
cp -a "$backup" "$JS_FILE"
return 1
fi
# Limpiar artefactos/cachés
rm -f "$MIN_JS_FILE" "$GZ_FILE" 2>/dev/null || true
find /var/cache/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
find /var/lib/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
find /var/cache/nginx/ -type f -delete 2>/dev/null || true
trap - ERR
}
patch_mobile() {
[ -f "$MOBILE_TPL" ] || return 0
grep -q "$MARK_MOBILE" "$MOBILE_TPL" && return 0
local backup_dir="$BASE_DIR/backups"
mkdir -p "$backup_dir"
cp -a "$MOBILE_TPL" "$backup_dir/$(basename "$MOBILE_TPL").backup.$(date +%Y%m%d_%H%M%S)"
cat >> "$MOBILE_TPL" <<EOM
$MARK_MOBILE
<script>
(function() {
'use strict';
function removeSubscriptionElements() {
try {
const dialogs = document.querySelectorAll('dialog.pwt-outer-dialog');
dialogs.forEach(d => {
const text = (d.textContent || '').toLowerCase();
if (text.includes('subscription') || text.includes('no valid')) { d.remove(); }
});
const cards = document.querySelectorAll('.pwt-card.pwt-p-2.pwt-d-flex.pwt-interactive.pwt-justify-content-center');
cards.forEach(c => {
const text = (c.textContent || '').toLowerCase();
const hasButton = c.querySelector('button');
if (!hasButton && (text.includes('subscription') || text.includes('no valid'))) { c.remove(); }
});
const alerts = document.querySelectorAll('[class*="alert"], [class*="warning"], [class*="notice"]');
alerts.forEach(a => {
const text = (a.textContent || '').toLowerCase();
if (text.includes('subscription') || text.includes('no valid')) { a.remove(); }
});
} catch (e) { console.warn('Error removing subscription elements:', e); }
}
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', removeSubscriptionElements); }
else { removeSubscriptionElements(); }
const observer = new MutationObserver(removeSubscriptionElements);
if (document.body) {
observer.observe(document.body, { childList: true, subtree: true });
const interval = setInterval(removeSubscriptionElements, 500);
setTimeout(() => { try { observer.disconnect(); clearInterval(interval); } catch(e){} }, 30000);
}
})();
</script>
EOM
}
reload_services() {
systemctl is-active --quiet pveproxy 2>/dev/null && {
systemctl reload pveproxy 2>/dev/null || systemctl restart pveproxy 2>/dev/null || true
}
systemctl is-active --quiet nginx 2>/dev/null && {
systemctl reload nginx 2>/dev/null || true
}
systemctl is-active --quiet pvedaemon 2>/dev/null && {
systemctl reload pvedaemon 2>/dev/null || true
}
find /var/cache/pve-manager/ -type f -delete 2>/dev/null || true
find /var/lib/pve-manager/ -type f -delete 2>/dev/null || true
}
main() {
patch_web || return 1
patch_mobile
reload_services
}
main
EOF
chmod 755 "$PATCH_BIN"
}
# ----------------------------------------------------
create_apt_hook() {
cat > "$APT_HOOK" <<'EOF'
/* ProxMenux: reapply nag patch after upgrades */
DPkg::Post-Invoke { "/usr/local/bin/pve-remove-nag.sh || true"; };
EOF
chmod 644 "$APT_HOOK"
apt-config dump >/dev/null 2>&1 || { msg_warn "APT hook syntax issue"; rm -f "$APT_HOOK"; }
}
remove_subscription_banner_pve9() {
local pve_version
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1 || true)
local pve_major="${pve_version%%.*}"
msg_info "$(translate "Detected Proxmox VE ${pve_version:-9.x} removing subscription banner")"
create_patch_script
create_apt_hook
if ! "$PATCH_BIN"; then
msg_error "$(translate "Error applying patches")"
return 1
fi
register_tool "subscription_banner" true
msg_ok "$(translate "Subscription banner removed successfully.")"
msg_ok "$(translate "Refresh your browser to see changes.")"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
remove_subscription_banner_pve9
fi
-339
View File
@@ -1,339 +0,0 @@
#!/bin/bash
# ==========================================================
# Proxmox VE Update Script
# ==========================================================
# Configuration
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
ensure_tools_json() {
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
}
register_tool() {
local tool="$1"
local state="$2"
ensure_tools_json
jq --arg t "$tool" --argjson v "$state" '.[$t]=$v' "$TOOLS_JSON" > "$TOOLS_JSON.tmp" && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
}
download_common_functions() {
if ! source "$LOCAL_SCRIPTS/global/common-functions.sh"; then
return 1
fi
}
update_pve9() {
local pve_version=$(pveversion | awk -F'/' '{print $2}' | cut -d'-' -f1)
local start_time=$(date +%s)
local log_file="/var/log/proxmox-update-$(date +%Y%m%d-%H%M%S).log"
local changes_made=false
local OS_CODENAME="$(grep "VERSION_CODENAME=" /etc/os-release | cut -d"=" -f 2 | xargs)"
local TARGET_CODENAME="trixie"
if [ -z "$OS_CODENAME" ]; then
OS_CODENAME=$(lsb_release -cs 2>/dev/null || echo "trixie")
fi
download_common_functions
msg_info2 "$(translate "Detected: Proxmox VE $pve_version (Current: $OS_CODENAME, Target: $TARGET_CODENAME)")"
echo -e
local available_space=$(df /var/cache/apt/archives | awk 'NR==2 {print int($4/1024)}')
if [ "$available_space" -lt 1024 ]; then
msg_error "$(translate "Insufficient disk space. Available: ${available_space}MB")"
echo -e
msg_success "$(translate "Press Enter to return to menu...")"
read -r
return 1
fi
if ! ping -c 1 download.proxmox.com >/dev/null 2>&1; then
msg_error "$(translate "Cannot reach Proxmox repositories")"
echo -e
msg_success "$(translate "Press Enter to return to menu...")"
read -r
return 1
fi
disable_sources_repo() {
local file="$1"
if [[ -f "$file" ]]; then
sed -i ':a;/^\n*$/{$d;N;ba}' "$file"
if grep -q "^Enabled:" "$file"; then
sed -i 's/^Enabled:.*$/Enabled: false/' "$file"
else
echo "Enabled: false" >> "$file"
fi
if ! grep -q "^Types: " "$file"; then
msg_warn "$(translate "Malformed .sources file detected, removing: $(basename "$file")")"
rm -f "$file"
fi
return 0
fi
return 1
}
if disable_sources_repo "/etc/apt/sources.list.d/pve-enterprise.sources"; then
msg_ok "$(translate "Enterprise Proxmox repository disabled")"
changes_made=true
fi
if disable_sources_repo "/etc/apt/sources.list.d/ceph.sources"; then
msg_ok "$(translate "Enterprise Proxmox Ceph repository disabled")"
changes_made=true
fi
for legacy_file in /etc/apt/sources.list.d/pve-public-repo.list \
/etc/apt/sources.list.d/pve-install-repo.list \
/etc/apt/sources.list.d/debian.list; do
if [[ -f "$legacy_file" ]]; then
rm -f "$legacy_file"
msg_ok "$(translate "Removed legacy repository: $(basename "$legacy_file")")"
fi
done
if [[ -f /etc/apt/sources.list.d/debian.sources ]]; then
rm -f /etc/apt/sources.list.d/debian.sources
msg_ok "$(translate "Old debian.sources file removed to prevent duplication")"
fi
msg_info "$(translate "Creating Proxmox VE 9.x no-subscription repository...")"
cat > /etc/apt/sources.list.d/proxmox.sources << EOF
Enabled: true
Types: deb
URIs: http://download.proxmox.com/debian/pve
Suites: ${TARGET_CODENAME}
Components: pve-no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
EOF
msg_ok "$(translate "Proxmox VE 9.x no-subscription repository created")"
changes_made=true
msg_info "$(translate "Creating Debian ${TARGET_CODENAME} sources file...")"
cat > /etc/apt/sources.list.d/debian.sources << EOF
Types: deb
URIs: http://deb.debian.org/debian/
Suites: ${TARGET_CODENAME} ${TARGET_CODENAME}-updates
Components: main contrib non-free non-free-firmware
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
Types: deb
URIs: http://security.debian.org/debian-security/
Suites: ${TARGET_CODENAME}-security
Components: main contrib non-free non-free-firmware
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
EOF
msg_ok "$(translate "Debian repositories configured for $TARGET_CODENAME")"
local firmware_conf="/etc/apt/apt.conf.d/no-firmware-warnings.conf"
if [ ! -f "$firmware_conf" ]; then
msg_info "$(translate "Disabling non-free firmware warnings...")"
echo 'APT::Get::Update::SourceListWarnings::NonFreeFirmware "false";' > "$firmware_conf"
msg_ok "$(translate "Non-free firmware warnings disabled")"
fi
update_output=$(apt-get update 2>&1)
update_exit_code=$?
if [ $update_exit_code -eq 0 ]; then
msg_ok "$(translate "Package lists updated successfully")"
else
if echo "$update_output" | grep -q "NO_PUBKEY\|GPG error"; then
msg_info "$(translate "Fixing GPG key issues...")"
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys $(echo "$update_output" | grep "NO_PUBKEY" | sed 's/.*NO_PUBKEY //' | head -1) 2>/dev/null
if apt-get update > "$log_file" 2>&1; then
msg_ok "$(translate "Package lists updated after GPG fix")"
else
msg_error "$(translate "Failed to update package lists. Check log: $log_file")"
return 1
fi
elif echo "$update_output" | grep -q "404\|Failed to fetch"; then
msg_warn "$(translate "Some repositories are not available, continuing with available ones...")"
else
msg_error "$(translate "Failed to update package lists. Check log: $log_file")"
echo "Error details: $update_output"
return 1
fi
fi
if apt policy 2>/dev/null | grep -q "${TARGET_CODENAME}.*pve-no-subscription"; then
msg_ok "$(translate "Proxmox VE 9.x repositories verified")"
else
msg_warn "$(translate "Proxmox VE 9.x repositories verification inconclusive, continuing...")"
fi
local current_pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
local available_pve_version=$(apt-cache policy pve-manager 2>/dev/null | grep -oP 'Candidate: \K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
local upgradable=$(apt list --upgradable 2>/dev/null | grep -c "upgradable")
local security_updates=$(apt list --upgradable 2>/dev/null | grep -c "security")
show_update_menu() {
local current_version="$1"
local target_version="$2"
local upgradable_count="$3"
local security_count="$4"
local menu_text="$(translate "System Update Information")\n\n"
menu_text+="$(translate "Current PVE Version"): $current_version\n"
if [ -n "$target_version" ] && [ "$target_version" != "$current_version" ]; then
menu_text+="$(translate "Available PVE Version"): $target_version\n"
fi
menu_text+="\n$(translate "Package Updates Available"): $upgradable_count\n"
menu_text+="$(translate "Security Updates"): $security_count\n\n"
if [ "$upgradable_count" -eq 0 ]; then
menu_text+="$(translate "System is already up to date")"
whiptail --title "$(translate "Update Status")" --msgbox "$menu_text" 15 70
return 2
else
menu_text+="$(translate "Do you want to proceed with the system update?")"
if whiptail --title "$(translate "Proxmox Update")" --yesno "$menu_text" 18 70; then
return 0
else
return 1
fi
fi
}
show_update_menu "$current_pve_version" "$available_pve_version" "$upgradable" "$security_updates"
MENU_RESULT=$?
if [[ $MENU_RESULT -eq 1 ]]; then
msg_info2 "$(translate "Update cancelled by user")"
apt-get -y autoremove > /dev/null 2>&1 || true
apt-get -y autoclean > /dev/null 2>&1 || true
return 0
elif [[ $MENU_RESULT -eq 2 ]]; then
msg_ok "$(translate "System is already up to date. No update needed.")"
apt-get -y autoremove > /dev/null 2>&1 || true
apt-get -y autoclean > /dev/null 2>&1 || true
return 0
fi
msg_info "$(translate "Cleaning up unused time synchronization services...")"
if /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' purge ntp openntpd systemd-timesyncd > /dev/null 2>&1; then
msg_ok "$(translate "Old time services removed successfully")"
else
msg_warn "$(translate "Some old time services could not be removed (not installed)")"
fi
msg_info "$(translate "Updating packages...")"
apt-get install pv -y > /dev/null 2>&1
msg_ok "$(translate "Packages updated successfully")"
tput sc
DEBIAN_FRONTEND=noninteractive apt-get -y \
-o Dpkg::Options::='--force-confdef' \
-o Dpkg::Options::='--force-confold' \
dist-upgrade 2>&1 | while IFS= read -r line; do
echo "$line" >> "$log_file"
if [[ "$line" =~ \[[#=\-]+\]\ *[0-9]{1,3}% ]]; then
continue
fi
if [[ "$line" =~ ^(Setting\ up|Unpacking|Preparing\ to\ unpack|Processing\ triggers\ for) ]]; then
package_name=$(echo "$line" | sed -E 's/.*(Setting up|Unpacking|Preparing to unpack|Processing triggers for) ([^ :]+).*/\2/')
[ -z "$package_name" ] && package_name="$(translate "Unknown")"
row=$(( $(tput lines) - 6 ))
tput cup $row 0; printf "%s\n" "$(translate "Installing packages...")"
tput cup $((row + 1)) 0; printf "%s\n" "──────────────────────────────────────────────"
tput cup $((row + 2)) 0; printf "%s %s\n" "$(translate "Package:")" "$package_name"
tput cup $((row + 3)) 0; printf "%s\n" "Progress: [ ] 0%"
tput cup $((row + 4)) 0; printf "%s\n" "──────────────────────────────────────────────"
for i in $(seq 1 10); do
sleep 0.1
progress=$((i * 10))
tput cup $((row + 3)) 9
printf "[%-50s] %3d%%" "$(printf "#%.0s" $(seq 1 $((progress/2))))" "$progress"
done
fi
done
tput rc
tput ed
upgrade_exit_code=${PIPESTATUS[0]}
if [ $upgrade_exit_code -eq 0 ]; then
msg_ok "$(translate "System upgrade completed successfully")"
else
msg_error "$(translate "System upgrade failed. Check log: $log_file")"
return 1
fi
msg_info "$(translate "Installing essential Proxmox packages...")"
local additional_packages="zfsutils-linux proxmox-backup-restore-image chrony"
if /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' install $additional_packages >> "$log_file" 2>&1; then
msg_ok "$(translate "Essential Proxmox packages installed")"
else
msg_warn "$(translate "Some essential Proxmox packages may not have been installed")"
fi
lvm_repair_check
cleanup_duplicate_repos
#msg_info "$(translate "Performing system cleanup...")"
apt-get -y autoremove > /dev/null 2>&1 || true
apt-get -y autoclean > /dev/null 2>&1 || true
msg_ok "$(translate "Cleanup finished")"
local end_time=$(date +%s)
local duration=$((end_time - start_time))
local minutes=$((duration / 60))
local seconds=$((duration % 60))
echo -e "${TAB}${BGN}$(translate "====== PVE UPDATE COMPLETED ======")${CL}"
echo -e "${TAB}${GN}⏱️ $(translate "Duration")${CL}: ${BL}${minutes}m ${seconds}s${CL}"
echo -e "${TAB}${GN}📄 $(translate "Log file")${CL}: ${BL}$log_file${CL}"
echo -e "${TAB}${GN}📦 $(translate "Packages upgraded")${CL}: ${BL}$upgradable${CL}"
echo -e "${TAB}${GN}🖥️ $(translate "Proxmox VE")${CL}: ${BL}$target_version (Debian $OS_CODENAME)${CL}"
msg_ok "$(translate "Proxmox VE 9.x configuration completed.")"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
update_pve9
fi
-304
View File
@@ -1,304 +0,0 @@
#!/bin/bash
# ==========================================================
# Proxmox VE Update Script - Improved Version
# ==========================================================
# Configuration
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
ensure_tools_json() {
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
}
register_tool() {
local tool="$1"
local state="$2"
ensure_tools_json
jq --arg t "$tool" --argjson v "$state" '.[$t]=$v' "$TOOLS_JSON" > "$TOOLS_JSON.tmp" && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
}
download_common_functions() {
if ! source <(curl -s "$REPO_URL/scripts/global/common-functions.sh"); then
return 1
fi
}
update_pve9() {
local pve_version=$(pveversion | awk -F'/' '{print $2}' | cut -d'-' -f1)
local start_time=$(date +%s)
local log_file="/var/log/proxmox-update-$(date +%Y%m%d-%H%M%S).log"
local changes_made=false
local OS_CODENAME="$(grep "VERSION_CODENAME=" /etc/os-release | cut -d"=" -f 2 | xargs)"
local TARGET_CODENAME="trixie"
local screen_capture="/tmp/proxmenux_screen_capture_$$.txt"
if [ -z "$OS_CODENAME" ]; then
OS_CODENAME=$(lsb_release -cs 2>/dev/null || echo "trixie")
fi
download_common_functions
{
msg_info2 "$(translate "Detected: Proxmox VE $pve_version (Current: $OS_CODENAME, Target: $TARGET_CODENAME)")"
} | tee -a "$screen_capture"
local available_space=$(df /var/cache/apt/archives | awk 'NR==2 {print int($4/1024)}')
if [ "$available_space" -lt 1024 ]; then
msg_error "$(translate "Insufficient disk space. Available: ${available_space}MB")"
echo -e
msg_success "$(translate "Press Enter to return to menu...")"
read -r
return 1
fi
if ! ping -c 1 download.proxmox.com >/dev/null 2>&1; then
msg_error "$(translate "Cannot reach Proxmox repositories")"
echo -e
msg_success "$(translate "Press Enter to return to menu...")"
read -r
return 1
fi
disable_sources_repo() {
local file="$1"
if [[ -f "$file" ]]; then
sed -i ':a;/^\n*$/{$d;N;ba}' "$file"
if grep -q "^Enabled:" "$file"; then
sed -i 's/^Enabled:.*$/Enabled: false/' "$file"
else
echo "Enabled: false" >> "$file"
fi
if ! grep -q "^Types: " "$file"; then
msg_warn "$(translate "Malformed .sources file detected, removing: $(basename "$file")")"
rm -f "$file"
fi
return 0
fi
return 1
}
if disable_sources_repo "/etc/apt/sources.list.d/pve-enterprise.sources"; then
msg_ok "$(translate "Enterprise Proxmox repository disabled")" | tee -a "$screen_capture"
changes_made=true
fi
if disable_sources_repo "/etc/apt/sources.list.d/ceph.sources"; then
msg_ok "$(translate "Enterprise Proxmox Ceph repository disabled")" | tee -a "$screen_capture"
changes_made=true
fi
for legacy_file in /etc/apt/sources.list.d/pve-public-repo.list \
/etc/apt/sources.list.d/pve-install-repo.list \
/etc/apt/sources.list.d/debian.list; do
if [[ -f "$legacy_file" ]]; then
rm -f "$legacy_file"
msg_ok "$(translate "Removed legacy repository: $(basename "$legacy_file")")" | tee -a "$screen_capture"
fi
done
if [[ -f /etc/apt/sources.list.d/debian.sources ]]; then
rm -f /etc/apt/sources.list.d/debian.sources
msg_ok "$(translate "Old debian.sources file removed to prevent duplication")" | tee -a "$screen_capture"
fi
msg_info "$(translate "Creating Proxmox VE 9.x no-subscription repository...")"
cat > /etc/apt/sources.list.d/proxmox.sources << EOF
Enabled: true
Types: deb
URIs: http://download.proxmox.com/debian/pve
Suites: ${TARGET_CODENAME}
Components: pve-no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
EOF
msg_ok "$(translate "Proxmox VE 9.x no-subscription repository created")" | tee -a "$screen_capture"
changes_made=true
msg_info "$(translate "Creating Debian ${TARGET_CODENAME} sources file...")"
cat > /etc/apt/sources.list.d/debian.sources << EOF
Types: deb
URIs: http://deb.debian.org/debian/
Suites: ${TARGET_CODENAME} ${TARGET_CODENAME}-updates
Components: main contrib non-free non-free-firmware
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
Types: deb
URIs: http://security.debian.org/debian-security/
Suites: ${TARGET_CODENAME}-security
Components: main contrib non-free non-free-firmware
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
EOF
msg_ok "$(translate "Debian repositories configured for $TARGET_CODENAME")"
local firmware_conf="/etc/apt/apt.conf.d/no-firmware-warnings.conf"
if [ ! -f "$firmware_conf" ]; then
msg_info "$(translate "Disabling non-free firmware warnings...")"
echo 'APT::Get::Update::SourceListWarnings::NonFreeFirmware "false";' > "$firmware_conf"
msg_ok "$(translate "Non-free firmware warnings disabled")"
fi
#update_output=$(apt-get update 2>&1)
update_output=$(apt-get -o Dpkg::Progress-Fancy=1 update 2>&1)
update_exit_code=$?
if [ $update_exit_code -eq 0 ]; then
msg_ok "$(translate "Package lists updated successfully")" | tee -a "$screen_capture"
else
if echo "$update_output" | grep -q "NO_PUBKEY\|GPG error"; then
msg_info "$(translate "Fixing GPG key issues...")"
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys $(echo "$update_output" | grep "NO_PUBKEY" | sed 's/.*NO_PUBKEY //' | head -1) 2>/dev/null
if apt-get update > "$log_file" 2>&1; then
msg_ok "$(translate "Package lists updated after GPG fix")" | tee -a "$screen_capture"
else
msg_error "$(translate "Failed to update package lists. Check log: $log_file")"
return 1
fi
elif echo "$update_output" | grep -q "404\|Failed to fetch"; then
msg_warn "$(translate "Some repositories are not available, continuing with available ones...")"
else
msg_error "$(translate "Failed to update package lists. Check log: $log_file")"
echo "Error details: $update_output"
return 1
fi
fi
if apt policy 2>/dev/null | grep -q "${TARGET_CODENAME}.*pve-no-subscription"; then
msg_ok "$(translate "Proxmox VE 9.x repositories verified")" | tee -a "$screen_capture"
else
msg_warn "$(translate "Proxmox VE 9.x repositories verification inconclusive, continuing...")"
fi
local current_pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
local available_pve_version=$(apt-cache policy pve-manager 2>/dev/null | grep -oP 'Candidate: \K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
local upgradable=$(apt list --upgradable 2>/dev/null | grep -c "upgradable")
local security_updates=$(apt list --upgradable 2>/dev/null | grep -c "security")
show_update_menu() {
local current_version="$1"
local target_version="$2"
local upgradable_count="$3"
local security_count="$4"
local menu_text="$(translate "System Update Information")\n\n"
menu_text+="$(translate "Current PVE Version"): $current_version\n"
if [ -n "$target_version" ] && [ "$target_version" != "$current_version" ]; then
menu_text+="$(translate "Available PVE Version"): $target_version\n"
fi
menu_text+="\n$(translate "Package Updates Available"): $upgradable_count\n"
menu_text+="$(translate "Security Updates"): $security_count\n\n"
if [ "$upgradable_count" -eq 0 ]; then
menu_text+="$(translate "System is already up to date")"
whiptail --title "$(translate "Update Status")" --msgbox "$menu_text" 15 70
return 2
else
menu_text+="$(translate "Do you want to proceed with the system update?")"
if whiptail --title "$(translate "Proxmox Update")" --yesno "$menu_text" 18 70; then
return 0
else
return 1
fi
fi
}
show_update_menu "$current_pve_version" "$available_pve_version" "$upgradable" "$security_updates"
MENU_RESULT=$?
clear
show_proxmenux_logo
msg_title "$(translate "$SCRIPT_TITLE")"
cat "$screen_capture"
if [[ $MENU_RESULT -eq 1 ]]; then
msg_info2 "$(translate "Update cancelled by user")"
apt-get -y autoremove > /dev/null 2>&1 || true
apt-get -y autoclean > /dev/null 2>&1 || true
rm -f "$screen_capture"
return 0
elif [[ $MENU_RESULT -eq 2 ]]; then
msg_ok "$(translate "System is already up to date. No update needed.")"
apt-get -y autoremove > /dev/null 2>&1 || true
apt-get -y autoclean > /dev/null 2>&1 || true
rm -f "$screen_capture"
return 0
fi
msg_info "$(translate "Cleaning up unused time synchronization services...")"
if /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' purge ntp openntpd systemd-timesyncd > /dev/null 2>&1; then
msg_ok "$(translate "Old time services removed successfully")"
else
msg_warn "$(translate "Some old time services could not be removed (not installed)")"
fi
echo -e
DEBIAN_FRONTEND=noninteractive apt-get -y \
-o Dpkg::Options::='--force-confdef' \
-o Dpkg::Options::='--force-confold' \
dist-upgrade 2>&1 | tee -a "$log_file"
upgrade_exit_code=${PIPESTATUS[0]}
echo -e
clear
show_proxmenux_logo
msg_title "$(translate "$SCRIPT_TITLE")"
cat "$screen_capture"
if [ $upgrade_exit_code -ne 0 ]; then
msg_error "$(translate "System upgrade failed. Check log: $log_file")"
rm -f "$screen_capture"
return 1
fi
msg_info "$(translate "Installing essential Proxmox packages...")"
local additional_packages="zfsutils-linux proxmox-backup-restore-image chrony"
if /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' install $additional_packages >> "$log_file" 2>&1; then
msg_ok "$(translate "Essential Proxmox packages installed")"
else
msg_warn "$(translate "Some essential Proxmox packages may not have been installed")"
fi
lvm_repair_check
cleanup_duplicate_repos
apt-get -y autoremove > /dev/null 2>&1 || true
apt-get -y autoclean > /dev/null 2>&1 || true
msg_ok "$(translate "Cleanup finished")"
local end_time=$(date +%s)
local duration=$((end_time - start_time))
local minutes=$((duration / 60))
local seconds=$((duration % 60))
echo -e "${TAB}${BGN}$(translate "====== PVE UPDATE COMPLETED ======")${CL}"
echo -e "${TAB}${GN}⏱️ $(translate "Duration")${CL}: ${BL}${minutes}m ${seconds}s${CL}"
echo -e "${TAB}${GN}📄 $(translate "Log file")${CL}: ${BL}$log_file${CL}"
echo -e "${TAB}${GN}📦 $(translate "Packages upgraded")${CL}: ${BL}$upgradable${CL}"
echo -e "${TAB}${GN}🖥️ $(translate "Proxmox VE")${CL}: ${BL}$available_pve_version (Debian $OS_CODENAME)${CL}"
msg_ok "$(translate "Proxmox VE 9.x configuration completed.")"
rm -f "$screen_capture"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
update_pve9
fi
+144
View File
@@ -0,0 +1,144 @@
#!/bin/bash
# ==========================================================
# ProxMenux - Shared utility installation functions
# ==========================================================
# Source this file in scripts that need to install system utilities.
# Provides: PROXMENUX_UTILS array, ensure_repositories(), install_single_package()
#
# Usage:
# source "$LOCAL_SCRIPTS/global/utils-install-functions.sh"
# ==========================================================
# All available utilities — format: "package:verify_command:description"
PROXMENUX_UTILS=(
"axel:axel:Download accelerator"
"dos2unix:dos2unix:Convert DOS/Unix text files"
"grc:grc:Generic log colorizer"
"htop:htop:Interactive process viewer"
"btop:btop:Modern resource monitor"
"iftop:iftop:Real-time network usage"
"iotop:iotop:Monitor disk I/O usage"
"iperf3:iperf3:Network bandwidth testing"
"intel-gpu-tools:intel_gpu_top:Intel GPU tools"
"s-tui:s-tui:Stress-Terminal UI"
"ipset:ipset:Manage IP sets"
"iptraf-ng:iptraf-ng:Network monitoring tool"
"plocate:locate:Locate files quickly"
"msr-tools:rdmsr:Access CPU MSRs"
"net-tools:netstat:Legacy networking tools"
"sshpass:sshpass:Non-interactive SSH login"
"tmux:tmux:Terminal multiplexer"
"unzip:unzip:Extract ZIP files"
"zip:zip:Create ZIP files"
"libguestfs-tools:virt-filesystems:VM disk utilities"
"aria2:aria2c:Multi-source downloader"
"cabextract:cabextract:Extract CAB files"
"wimtools:wimlib-imagex:Manage WIM images"
"genisoimage:genisoimage:Create ISO images"
"chntpw:chntpw:Edit Windows registry/passwords"
)
# Ensure APT repositories are configured for the current PVE version.
# Creates missing no-subscription repo entries for PVE8 (bookworm) or PVE9 (trixie).
ensure_repositories() {
local pve_version need_update=false
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
if [[ -z "$pve_version" ]]; then
msg_error "Unable to detect Proxmox version."
return 1
fi
if (( pve_version >= 9 )); then
# ===== PVE 9 (Debian 13 - trixie) =====
if [[ ! -f /etc/apt/sources.list.d/proxmox.sources ]]; then
cat > /etc/apt/sources.list.d/proxmox.sources <<'EOF'
Enabled: true
Types: deb
URIs: http://download.proxmox.com/debian/pve
Suites: trixie
Components: pve-no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
EOF
need_update=true
fi
if [[ ! -f /etc/apt/sources.list.d/debian.sources ]]; then
cat > /etc/apt/sources.list.d/debian.sources <<'EOF'
Types: deb
URIs: http://deb.debian.org/debian/
Suites: trixie trixie-updates
Components: main contrib non-free-firmware
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
Types: deb
URIs: http://security.debian.org/debian-security/
Suites: trixie-security
Components: main contrib non-free-firmware
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
EOF
need_update=true
fi
else
# ===== PVE 8 (Debian 12 - bookworm) =====
local sources_file="/etc/apt/sources.list"
if ! grep -qE 'deb .* bookworm .* main' "$sources_file" 2>/dev/null; then
{
echo "deb http://deb.debian.org/debian bookworm main contrib non-free non-free-firmware"
echo "deb http://deb.debian.org/debian bookworm-updates main contrib non-free non-free-firmware"
echo "deb http://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware"
} >> "$sources_file"
need_update=true
fi
if [[ ! -f /etc/apt/sources.list.d/pve-no-subscription.list ]]; then
echo "deb http://download.proxmox.com/debian/pve bookworm pve-no-subscription" \
> /etc/apt/sources.list.d/pve-no-subscription.list
need_update=true
fi
fi
if [[ "$need_update" == true ]] || [[ ! -d /var/lib/apt/lists || -z "$(ls -A /var/lib/apt/lists 2>/dev/null)" ]]; then
msg_info "$(translate "Updating APT package lists...")"
apt-get update >/dev/null 2>&1 || apt-get update
fi
return 0
}
# Install a single package and verify the resulting command is available.
# Args: package_name verify_command description
# Returns: 0=ok 1=install_failed 2=installed_but_command_not_found
install_single_package() {
local package="$1"
local command_name="${2:-$package}"
local description="${3:-$package}"
msg_info "$(translate "Installing") $package${description:+ ($description)}..."
local install_success=false
if DEBIAN_FRONTEND=noninteractive apt-get install -y "$package" >/dev/null 2>&1; then
install_success=true
fi
cleanup 2>/dev/null || true
if [[ "$install_success" == true ]]; then
hash -r 2>/dev/null
sleep 1
if command -v "$command_name" >/dev/null 2>&1; then
msg_ok "$package $(translate "installed correctly and available")"
return 0
else
msg_warn "$package $(translate "installed but command not immediately available")"
msg_info2 "$(translate "May need to restart terminal")"
return 2
fi
else
msg_error "$(translate "Error installing") $package"
return 1
fi
}
+695
View File
@@ -0,0 +1,695 @@
#!/usr/bin/env bash
if [[ -n "${__PROXMENUX_VM_STORAGE_HELPERS__}" ]]; then
return 0
fi
__PROXMENUX_VM_STORAGE_HELPERS__=1
function _array_contains() {
local needle="$1"
shift
local item
for item in "$@"; do
[[ "$item" == "$needle" ]] && return 0
done
return 1
}
function _vm_boot_order_add_unique() {
local arr_name="$1"
shift
local -n arr_ref="$arr_name"
local entry
for entry in "$@"; do
[[ -z "$entry" ]] && continue
_array_contains "$entry" "${arr_ref[@]}" || arr_ref+=("$entry")
done
}
function _vm_boot_order_join() {
local -a unique_entries=()
local entry
for entry in "$@"; do
[[ -z "$entry" ]] && continue
_array_contains "$entry" "${unique_entries[@]}" || unique_entries+=("$entry")
done
[[ ${#unique_entries[@]} -gt 0 ]] || return 0
local joined
joined=$(IFS=';'; echo "${unique_entries[*]}")
echo "$joined"
}
function _vm_boot_order_hostpci_entries_for_pcis() {
local vmid="$1"
shift
local cfg
cfg=$(qm config "$vmid" 2>/dev/null || true)
[[ -n "$cfg" ]] || return 0
local -a hostpci_entries=()
local pci bdf bdf_re slot_base slot_re line entry
for pci in "$@"; do
[[ -n "$pci" ]] || continue
bdf="${pci#0000:}"
bdf_re="${bdf//./\\.}"
line=$(grep -E "^hostpci[0-9]+:.*(0000:)?${bdf_re}([,[:space:]]|$)" <<< "$cfg" | head -n1)
if [[ -z "$line" ]]; then
slot_base="${bdf%.*}"
slot_re="${slot_base//./\\.}"
line=$(grep -E "^hostpci[0-9]+:.*(0000:)?${slot_re}(\\.[0-7])?([,[:space:]]|$)" <<< "$cfg" | head -n1)
fi
[[ -n "$line" ]] || continue
entry="${line%%:*}"
_array_contains "$entry" "${hostpci_entries[@]}" || hostpci_entries+=("$entry")
done
printf '%s\n' "${hostpci_entries[@]}"
}
function _vmids_scope_key() {
[[ "$#" -eq 0 ]] && { echo ""; return 0; }
printf '%s\n' "$@" | awk 'NF' | sort -u | paste -sd',' -
}
function _refresh_host_storage_cache() {
MOUNTED_DISKS=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}')
SWAP_DISKS=$(swapon --noheadings --raw --show=NAME 2>/dev/null)
LVM_DEVICES=$(pvs --noheadings -o pv_name 2> >(grep -v 'File descriptor .* leaked') | xargs -r -n1 readlink -f | sort -u)
CONFIG_DATA=$(grep -vE '^\s*#' /etc/pve/qemu-server/*.conf /etc/pve/lxc/*.conf 2>/dev/null)
ZFS_DISKS=""
local zfs_raw entry path base_disk
zfs_raw=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror' | grep -v '^raidz')
for entry in $zfs_raw; do
path=""
if [[ "$entry" == /dev/* ]]; then
path=$(readlink -f "$entry" 2>/dev/null)
elif [[ -e "/dev/disk/by-id/$entry" ]]; then
path=$(readlink -f "/dev/disk/by-id/$entry" 2>/dev/null)
elif [[ -e "/dev/$entry" ]]; then
path=$(readlink -f "/dev/$entry" 2>/dev/null)
fi
if [[ -n "$path" ]]; then
base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null)
if [[ -n "$base_disk" ]]; then
ZFS_DISKS+="/dev/$base_disk"$'\n'
else
# Whole-disk vdev — path is already the resolved disk itself
ZFS_DISKS+="$path"$'\n'
fi
fi
done
ZFS_DISKS=$(echo "$ZFS_DISKS" | sort -u)
}
function _disk_is_host_system_used() {
local disk="$1"
local disk_real part fstype part_path
DISK_USAGE_REASON=""
while read -r part fstype; do
[[ -z "$part" ]] && continue
part_path="/dev/$part"
if grep -qFx "$part_path" <<< "$MOUNTED_DISKS"; then
DISK_USAGE_REASON="$(translate "Mounted filesystem detected") ($part_path)"
return 0
fi
if grep -qFx "$part_path" <<< "$SWAP_DISKS"; then
DISK_USAGE_REASON="$(translate "Swap partition detected") ($part_path)"
return 0
fi
case "$fstype" in
zfs_member)
DISK_USAGE_REASON="$(translate "ZFS member detected") ($part_path)"
return 0
;;
linux_raid_member)
DISK_USAGE_REASON="$(translate "RAID member detected") ($part_path)"
return 0
;;
LVM2_member)
DISK_USAGE_REASON="$(translate "LVM physical volume detected") ($part_path)"
return 0
;;
esac
done < <(lsblk -ln -o NAME,FSTYPE "$disk" 2>/dev/null)
disk_real=$(readlink -f "$disk" 2>/dev/null)
if [[ -n "$disk_real" && -n "$LVM_DEVICES" ]] && grep -qFx "$disk_real" <<< "$LVM_DEVICES"; then
DISK_USAGE_REASON="$(translate "Disk is part of host LVM")"
return 0
fi
if [[ -n "$ZFS_DISKS" ]] && grep -qFx "$disk" <<< "$ZFS_DISKS"; then
DISK_USAGE_REASON="$(translate "Disk is part of a host ZFS pool")"
return 0
fi
return 1
}
function _disk_used_in_guest_configs() {
local disk="$1"
local real_path escaped
real_path=$(readlink -f "$disk" 2>/dev/null)
# Use boundary matching: path must be followed by comma, whitespace, or EOL
# This prevents /dev/sdb from falsely matching /dev/sdb1 or /dev/sdb2
if [[ -n "$real_path" ]]; then
escaped="${real_path//./\\.}"
if grep -qE "${escaped}(,|[[:space:]]|$)" <<< "$CONFIG_DATA"; then
return 0
fi
fi
local symlink symlink_escaped
for symlink in /dev/disk/by-id/*; do
[[ -e "$symlink" ]] || continue
[[ "$(readlink -f "$symlink")" == "$real_path" ]] || continue
symlink_escaped="${symlink//./\\.}"
if grep -qE "${symlink_escaped}(,|[[:space:]]|$)" <<< "$CONFIG_DATA"; then
return 0
fi
done
return 1
}
# Returns 0 if the disk is referenced in a RUNNING VM or CT config.
# Mirrors _disk_used_in_guest_configs but checks guest status per-file.
function _disk_used_in_running_guest() {
local disk="$1"
local real_path
real_path=$(readlink -f "$disk" 2>/dev/null)
local -a aliases=()
[[ -n "$disk" ]] && aliases+=("$disk")
[[ -n "$real_path" && "$real_path" != "$disk" ]] && aliases+=("$real_path")
local symlink
for symlink in /dev/disk/by-id/*; do
[[ -e "$symlink" ]] || continue
[[ "$(readlink -f "$symlink" 2>/dev/null)" == "$real_path" ]] && aliases+=("$symlink")
done
local conf vmid alias escaped
for conf in /etc/pve/qemu-server/*.conf; do
[[ -f "$conf" ]] || continue
vmid=$(basename "$conf" .conf)
for alias in "${aliases[@]}"; do
escaped="${alias//./\\.}"
if grep -qE "${escaped}(,|[[:space:]]|$)" "$conf" 2>/dev/null; then
if qm status "$vmid" 2>/dev/null | grep -q "status: running"; then
return 0
fi
fi
done
done
local ctid
for conf in /etc/pve/lxc/*.conf; do
[[ -f "$conf" ]] || continue
ctid=$(basename "$conf" .conf)
for alias in "${aliases[@]}"; do
escaped="${alias//./\\.}"
if grep -qE "${escaped}(,|[[:space:]]|$)" "$conf" 2>/dev/null; then
if pct status "$ctid" 2>/dev/null | grep -q "status: running"; then
return 0
fi
fi
done
done
return 1
}
# Prints "VM:VMID" or "CT:CTID" for each stopped guest that references the disk.
function _disk_guest_ids() {
local disk="$1"
local real_path
real_path=$(readlink -f "$disk" 2>/dev/null)
local -a aliases=()
[[ -n "$disk" ]] && aliases+=("$disk")
[[ -n "$real_path" && "$real_path" != "$disk" ]] && aliases+=("$real_path")
local symlink
for symlink in /dev/disk/by-id/*; do
[[ -e "$symlink" ]] || continue
[[ "$(readlink -f "$symlink" 2>/dev/null)" == "$real_path" ]] && aliases+=("$symlink")
done
local conf vmid alias escaped
for conf in /etc/pve/qemu-server/*.conf; do
[[ -f "$conf" ]] || continue
vmid=$(basename "$conf" .conf)
for alias in "${aliases[@]}"; do
escaped="${alias//./\\.}"
if grep -qE "${escaped}(,|[[:space:]]|$)" "$conf" 2>/dev/null; then
echo "VM:$vmid"
break
fi
done
done
local ctid
for conf in /etc/pve/lxc/*.conf; do
[[ -f "$conf" ]] || continue
ctid=$(basename "$conf" .conf)
for alias in "${aliases[@]}"; do
escaped="${alias//./\\.}"
if grep -qE "${escaped}(,|[[:space:]]|$)" "$conf" 2>/dev/null; then
echo "CT:$ctid"
break
fi
done
done
}
# Print the slot names (e.g. sata0, scsi1) in a VM config that reference the disk.
function _find_disk_slots_in_vm() {
local vmid="$1"
local disk="$2"
local real_path conf
real_path=$(readlink -f "$disk" 2>/dev/null)
conf="/etc/pve/qemu-server/${vmid}.conf"
[[ -f "$conf" ]] || return
local -a aliases=("$disk")
[[ -n "$real_path" && "$real_path" != "$disk" ]] && aliases+=("$real_path")
local symlink
for symlink in /dev/disk/by-id/*; do
[[ -e "$symlink" ]] || continue
[[ "$(readlink -f "$symlink" 2>/dev/null)" == "$real_path" ]] && aliases+=("$symlink")
done
local key rest alias escaped
while IFS=: read -r key rest; do
key=$(echo "$key" | xargs)
[[ "$key" =~ ^(scsi|sata|ide|virtio)[0-9]+$ ]] || continue
for alias in "${aliases[@]}"; do
escaped="${alias//./\\.}"
if echo "$rest" | grep -qE "${escaped}(,|[[:space:]]|$)"; then
echo "$key"
break
fi
done
done < "$conf"
}
# Print the mp names (e.g. mp0, mp1) in a CT config that reference the disk.
function _find_disk_slots_in_ct() {
local ctid="$1"
local disk="$2"
local real_path conf
real_path=$(readlink -f "$disk" 2>/dev/null)
conf="/etc/pve/lxc/${ctid}.conf"
[[ -f "$conf" ]] || return
local -a aliases=("$disk")
[[ -n "$real_path" && "$real_path" != "$disk" ]] && aliases+=("$real_path")
local symlink
for symlink in /dev/disk/by-id/*; do
[[ -e "$symlink" ]] || continue
[[ "$(readlink -f "$symlink" 2>/dev/null)" == "$real_path" ]] && aliases+=("$symlink")
done
local key rest alias escaped
while IFS=: read -r key rest; do
key=$(echo "$key" | xargs)
[[ "$key" =~ ^mp[0-9]+$ ]] || continue
for alias in "${aliases[@]}"; do
escaped="${alias//./\\.}"
if echo "$rest" | grep -qE "${escaped}(,|[[:space:]]|$)"; then
echo "$key"
break
fi
done
done < "$conf"
}
function _controller_block_devices() {
local pci_full="$1"
local pci_root="/sys/bus/pci/devices/$pci_full"
[[ -d "$pci_root" ]] || return 0
local sys_block dev_name cur base
# Walk /sys/block and resolve each block device back to its ancestor PCI device.
# This avoids unbounded recursive scans while still handling NVMe/SATA paths.
for sys_block in /sys/block/*; do
[[ -e "$sys_block/device" ]] || continue
dev_name=$(basename "$sys_block")
[[ -b "/dev/$dev_name" ]] || continue
cur=$(readlink -f "$sys_block/device" 2>/dev/null)
[[ -n "$cur" ]] || continue
while [[ "$cur" != "/" ]]; do
base=$(basename "$cur")
if [[ "$base" == "$pci_full" ]]; then
echo "/dev/$dev_name"
break
fi
cur=$(dirname "$cur")
done
done
}
function _vm_is_q35() {
local vmid="$1"
local machine_line
machine_line=$(qm config "$vmid" 2>/dev/null | awk -F': ' '/^machine:/ {print $2}')
[[ "$machine_line" == *q35* ]]
}
function _vm_storage_register_vfio_iommu_tool() {
local tools_json="${BASE_DIR:-/usr/local/share/proxmenux}/installed_tools.json"
command -v jq >/dev/null 2>&1 || return 0
[[ -f "$tools_json" ]] || echo "{}" > "$tools_json"
jq '.vfio_iommu=true' "$tools_json" > "$tools_json.tmp" \
&& mv "$tools_json.tmp" "$tools_json" || true
}
function _vm_storage_enable_iommu_cmdline() {
local cpu_vendor iommu_param
cpu_vendor=$(grep -m1 "vendor_id" /proc/cpuinfo 2>/dev/null | awk '{print $3}')
if [[ "$cpu_vendor" == "GenuineIntel" ]]; then
iommu_param="intel_iommu=on"
elif [[ "$cpu_vendor" == "AuthenticAMD" ]]; then
iommu_param="amd_iommu=on"
else
return 1
fi
local cmdline_file="/etc/kernel/cmdline"
local grub_file="/etc/default/grub"
if [[ -f "$cmdline_file" ]] && grep -qE 'root=ZFS=|root=ZFS/' "$cmdline_file" 2>/dev/null; then
if ! grep -q "$iommu_param" "$cmdline_file"; then
cp "$cmdline_file" "${cmdline_file}.bak.$(date +%Y%m%d_%H%M%S)"
sed -i "s|\\s*$| ${iommu_param} iommu=pt|" "$cmdline_file"
proxmox-boot-tool refresh >/dev/null 2>&1 || true
fi
elif [[ -f "$grub_file" ]]; then
if ! grep -q "$iommu_param" "$grub_file"; then
cp "$grub_file" "${grub_file}.bak.$(date +%Y%m%d_%H%M%S)"
sed -i "/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param} iommu=pt\"|" "$grub_file"
update-grub >/dev/null 2>&1 || true
fi
else
return 1
fi
return 0
}
function _vm_storage_ensure_iommu_or_offer() {
local reboot_policy="${VM_STORAGE_IOMMU_REBOOT_POLICY:-ask_now}"
if declare -F _pci_is_iommu_active >/dev/null 2>&1 && _pci_is_iommu_active; then
_vm_storage_register_vfio_iommu_tool
return 0
fi
if grep -qE 'intel_iommu=on|amd_iommu=on' /proc/cmdline 2>/dev/null && \
[[ -d /sys/kernel/iommu_groups ]] && \
[[ -n "$(ls /sys/kernel/iommu_groups/ 2>/dev/null)" ]]; then
_vm_storage_register_vfio_iommu_tool
return 0
fi
# Dedup: if IOMMU was already configured/announced in this wizard run, skip prompt
if [[ "${VM_STORAGE_IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then
return 0
fi
# Detect if another script already wrote IOMMU params (e.g. GPU script ran first)
if grep -qE 'intel_iommu=on|amd_iommu=on' /etc/kernel/cmdline 2>/dev/null || \
grep -qE 'intel_iommu=on|amd_iommu=on' /etc/default/grub 2>/dev/null; then
_vm_storage_register_vfio_iommu_tool
VM_STORAGE_IOMMU_PENDING_REBOOT=1
export VM_STORAGE_IOMMU_PENDING_REBOOT
return 0
fi
local prompt
prompt="$(translate "IOMMU is not active on this system.")\n\n"
prompt+="$(translate "Controller/NVMe passthrough to VMs requires IOMMU enabled in BIOS/UEFI and kernel.")\n\n"
prompt+="$(translate "Do you want to enable IOMMU now?")\n\n"
prompt+="$(translate "A host reboot is required after this change.")"
whiptail --title "IOMMU Required" --yesno "$prompt" 14 78
[[ $? -ne 0 ]] && return 1
if ! _vm_storage_enable_iommu_cmdline; then
whiptail --title "IOMMU" --msgbox \
"$(translate "Failed to configure IOMMU automatically.")\n\n$(translate "Please configure it manually and reboot.")" \
10 72
return 1
fi
_vm_storage_register_vfio_iommu_tool
if [[ "$reboot_policy" == "defer" ]]; then
VM_STORAGE_IOMMU_PENDING_REBOOT=1
export VM_STORAGE_IOMMU_PENDING_REBOOT
whiptail --title "Reboot Required" --msgbox \
"$(translate "IOMMU configured successfully.")\n\n$(translate "Continue the VM wizard and reboot the host at the end.")\n\n$(translate "You can now select Controller/NVMe devices in Storage Plan.")\n$(translate "Device assignments will be written now and become active after reboot.")" \
12 78
return 0
fi
if whiptail --title "Reboot Required" --yesno \
"$(translate "IOMMU configured successfully.")\n\n$(translate "Do you want to reboot now?")" 10 68; then
reboot
else
whiptail --title "Reboot Required" --msgbox \
"$(translate "Please reboot manually and run the passthrough step again.")" 9 68
fi
return 1
}
function _vm_storage_confirm_controller_passthrough_risk() {
local vmid="${1:-}"
local vm_name="${2:-}"
local title="${3:-Controller + NVMe}"
local ui_mode="${4:-auto}" # wizard | standalone | auto
local vm_label=""
if [[ -n "$vmid" ]]; then
vm_label="$vmid"
[[ -n "$vm_name" ]] && vm_label="${vm_label} (${vm_name})"
fi
local reinforce_limited_firmware="no"
local bios_date bios_year current_year bios_age cpu_model risk_detail=""
bios_date=$(cat /sys/class/dmi/id/bios_date 2>/dev/null)
bios_year=$(echo "$bios_date" | grep -oE '[0-9]{4}' | tail -n1)
current_year=$(date +%Y 2>/dev/null)
if [[ -n "$bios_year" && -n "$current_year" ]]; then
bios_age=$(( current_year - bios_year ))
if (( bios_age >= 7 )); then
reinforce_limited_firmware="yes"
risk_detail="$(translate "BIOS from") ${bios_year} (${bios_age} $(translate "years old")) — $(translate "older firmware may increase passthrough instability")"
fi
fi
cpu_model=$(grep -m1 'model name' /proc/cpuinfo 2>/dev/null | cut -d: -f2- | xargs)
if echo "$cpu_model" | grep -qiE 'J4[0-9]{3}|J3[0-9]{3}|N4[0-9]{3}|N3[0-9]{3}|Apollo Lake'; then
reinforce_limited_firmware="yes"
[[ -z "$risk_detail" ]] && risk_detail="$(translate "Low-power CPU platform"): ${cpu_model}"
fi
if [[ "$ui_mode" == "auto" ]]; then
if [[ "${PROXMENUX_UI_MODE:-}" == "wizard" || "${WIZARD_CALL:-false}" == "true" ]]; then
ui_mode="wizard"
else
ui_mode="standalone"
fi
fi
local height=20
[[ "$reinforce_limited_firmware" == "yes" ]] && height=23
if [[ "$ui_mode" == "wizard" ]]; then
# whiptail: plain text (no color codes)
local msg
[[ -n "$vm_label" ]] && msg+="$(translate "Target VM"): ${vm_label}\n\n"
msg+="$(translate "Controller/NVMe passthrough — compatibility notice")\n\n"
msg+="$(translate "Not all platforms support Controller/NVMe passthrough reliably.")\n"
msg+="$(translate "On some systems, when starting the VM the host may slow down for several minutes until it stabilizes, or freeze completely.")\n"
if [[ "$reinforce_limited_firmware" == "yes" && -n "$risk_detail" ]]; then
msg+="\n$(translate "Detected risk factor"): ${risk_detail}\n"
fi
msg+="\n$(translate "If the host freezes, remove hostpci entries from") /etc/pve/qemu-server/${vmid:-<VMID>}.conf\n"
msg+="\n$(translate "Do you want to continue?")"
whiptail --title "$title" --yesno "$msg" $height 96
else
# dialog: colored format matching add_controller_nvme_vm.sh
local msg
[[ -n "$vm_label" ]] && msg+="\n\Zb$(translate "Target VM"): ${vm_label}\Zn\n"
msg+="\n\Zb\Z4⚠ $(translate "Controller/NVMe passthrough — compatibility notice")\Zn\n\n"
msg+="$(translate "Not all platforms support Controller/NVMe passthrough reliably.")\n"
msg+="$(translate "On some systems, when starting the VM the host may slow down for several minutes until it stabilizes, or freeze completely.")\n"
if [[ "$reinforce_limited_firmware" == "yes" && -n "$risk_detail" ]]; then
msg+="\n\Z1$(translate "Detected risk factor"): ${risk_detail}\Zn\n"
fi
msg+="\n$(translate "If the host freezes, remove hostpci entries from") /etc/pve/qemu-server/${vmid:-<VMID>}.conf\n"
msg+="\n\Zb$(translate "Do you want to continue?")\Zn"
dialog --backtitle "ProxMenux" --colors \
--title "$title" \
--yesno "$msg" $height 96
fi
}
function _shorten_text() {
local text="$1"
local max_len="${2:-42}"
[[ -z "$text" ]] && { echo ""; return; }
if (( ${#text} > max_len )); then
echo "${text:0:$((max_len-3))}..."
else
echo "$text"
fi
}
function _pci_storage_display_name() {
local pci_full="$1"
local raw_line name_part
raw_line=$(lspci -nn -s "${pci_full#0000:}" 2>/dev/null | sed 's/^[^ ]* //')
if [[ -z "$raw_line" ]]; then
translate "Unknown storage controller"
return 0
fi
# Prefer the right side after class prefix (e.g. "...: Vendor Model ...").
name_part="${raw_line#*: }"
[[ "$name_part" == "$raw_line" ]] && name_part="$raw_line"
# Remove noisy suffixes while keeping the meaningful model name.
name_part="${name_part%% (rev *}"
name_part=$(echo "$name_part" | sed -E 's/\[[0-9a-fA-F]{4}:[0-9a-fA-F]{4}\]//g')
name_part=$(echo "$name_part" | sed -E 's/ Technology Inc\.?//g; s/ Corporation//g; s/ Co\., Ltd\.?//g')
name_part=$(echo "$name_part" | sed -E 's/[[:space:]]+/ /g; s/^ +| +$//g')
[[ -z "$name_part" ]] && name_part="$raw_line"
echo "$name_part"
}
function _pci_slot_base() {
local pci_full="$1"
local slot
slot="${pci_full#0000:}"
slot="${slot%.*}"
echo "$slot"
}
function _vm_status_is_running() {
local vmid="$1"
qm status "$vmid" 2>/dev/null | grep -q "status: running"
}
function _vm_onboot_is_enabled() {
local vmid="$1"
qm config "$vmid" 2>/dev/null | grep -qE '^onboot:\s*1'
}
function _vm_name_by_id() {
local vmid="$1"
local conf="/etc/pve/qemu-server/${vmid}.conf"
local vm_name
vm_name=$(awk '/^name:/ {print $2}' "$conf" 2>/dev/null)
[[ -z "$vm_name" ]] && vm_name="VM-${vmid}"
echo "$vm_name"
}
function _vm_has_pci_slot() {
local vmid="$1"
local slot_base="$2"
local conf="/etc/pve/qemu-server/${vmid}.conf"
[[ -f "$conf" ]] || return 1
grep -qE "^hostpci[0-9]+:.*(0000:)?${slot_base}(\\.[0-7])?([,[:space:]]|$)" "$conf"
}
function _pci_assigned_vm_ids() {
local pci_full="$1"
local exclude_vmid="${2:-}"
local slot_base conf vmid
slot_base=$(_pci_slot_base "$pci_full")
for conf in /etc/pve/qemu-server/*.conf; do
[[ -f "$conf" ]] || continue
vmid=$(basename "$conf" .conf)
[[ -n "$exclude_vmid" && "$vmid" == "$exclude_vmid" ]] && continue
if grep -qE "^hostpci[0-9]+:.*(0000:)?${slot_base}(\\.[0-7])?([,[:space:]]|$)" "$conf"; then
echo "$vmid"
fi
done
}
function _remove_pci_slot_from_vm_config() {
local vmid="$1"
local slot_base="$2"
local conf="/etc/pve/qemu-server/${vmid}.conf"
[[ -f "$conf" ]] || return 1
local tmpf
tmpf=$(mktemp)
awk -v slot="$slot_base" '
$0 ~ "^hostpci[0-9]+:.*(0000:)?" slot "(\\.[0-7])?([,[:space:]]|$)" {next}
{print}
' "$conf" > "$tmpf" && cat "$tmpf" > "$conf"
rm -f "$tmpf"
}
function _pci_assigned_vm_summary() {
local pci_full="$1"
local slot_base conf vmid vm_name running onboot
local -a refs=()
local running_count=0 onboot_count=0
slot_base="${pci_full#0000:}"
slot_base="${slot_base%.*}"
for conf in /etc/pve/qemu-server/*.conf; do
[[ -f "$conf" ]] || continue
if ! grep -qE "^hostpci[0-9]+:.*(0000:)?${slot_base}(\\.[0-7])?([,[:space:]]|$)" "$conf"; then
continue
fi
vmid=$(basename "$conf" .conf)
vm_name=$(awk '/^name:/ {print $2}' "$conf" 2>/dev/null)
[[ -z "$vm_name" ]] && vm_name="VM-${vmid}"
if qm status "$vmid" 2>/dev/null | grep -q "status: running"; then
running="running"
running_count=$((running_count + 1))
else
running="stopped"
fi
if grep -qE "^onboot:\s*1" "$conf" 2>/dev/null; then
onboot="1"
onboot_count=$((onboot_count + 1))
else
onboot="0"
fi
refs+=("${vmid}[${running},onboot=${onboot}]")
done
[[ ${#refs[@]} -eq 0 ]] && return 1
local joined summary
joined=$(IFS=', '; echo "${refs[*]}")
summary="$(translate "Assigned to VM(s)"): ${joined}"
if [[ "$running_count" -gt 0 ]]; then
summary+=" ($(translate "running"): ${running_count})"
fi
if [[ "$onboot_count" -gt 0 ]]; then
summary+=", onboot=1: ${onboot_count}"
fi
echo "$summary"
return 0
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
#!/bin/bash
# ==========================================================
# ProxMenux - GPU/TPU Manual CLI Guide
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : GPL-3.0
# Version : 1.0
# Last Updated: 07/04/2026
# ==========================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)"
LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts"
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
elif [[ ! -f "$UTILS_FILE" ]]; then
UTILS_FILE="$BASE_DIR/utils.sh"
fi
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
GREEN=$'\033[0;32m'
NC=$'\033[0m'
_cl() {
# _cl <num> <display_cmd> <description>
# Prints a numbered command line with fixed-column alignment (separator at col 52).
local num="$1" disp="$2" desc="$3"
local pad=$((47 - ${#disp}))
[[ $pad -lt 1 ]] && pad=1
local spaces
spaces=$(printf '%*s' "$pad" '')
printf " %2d) %s%s%s%s - %s\n" "$num" "$GREEN" "$disp" "$NC" "$spaces" "$desc"
}
while true; do
clear
show_proxmenux_logo
msg_title "$(translate "GPU/TPU - Manual CLI Guide")"
echo -e "${TAB}${YW}$(translate 'Inspection commands run directly. Template commands [T] require parameter substitution.')${CL}"
echo
_cl 1 "lspci -nn | grep -iE 'VGA|3D|Display'" "$(translate 'Detect GPUs in host')"
_cl 2 "lspci -nnk | grep -A3 -Ei 'VGA|3D'" "$(translate 'Show GPU kernel driver in use')"
_cl 3 "cat /proc/cmdline" "$(translate 'Check kernel params (IOMMU flags)')"
_cl 4 "dmesg -T | grep -Ei 'DMAR|IOMMU|vfio|pcie'" "$(translate 'Inspect passthrough/kernel events')"
_cl 5 "find /sys/kernel/iommu_groups -type l" "$(translate 'List IOMMU group mapping')"
_cl 6 "lsmod | grep -E 'vfio|nvidia|amdgpu|apex'" "$(translate 'Check loaded GPU/TPU modules')"
_cl 7 "grep -R \"vfio-pci|blacklist\" /etc/modprobe.d" "$(translate 'Review passthrough config files')"
_cl 8 "nvidia-smi" "$(translate 'Check NVIDIA driver and devices')"
_cl 9 "qm config <vmid> | grep 'hostpci|bios'" "$(translate 'Check VM passthrough settings')"
_cl 10 "pct config <ctid> | grep 'dev|lxc.cgroup2'" "$(translate 'Check LXC GPU/TPU mapping')"
_cl 11 "ls -l /dev/dri /dev/kfd /dev/nvidia*" "$(translate 'Inspect host device nodes')"
_cl 12 "qm set <vmid> --hostpci<slot> <BDF>,pcie=1" "[T] $(translate 'Assign GPU PCI function to VM')"
_cl 13 "qm set <vmid> -delete hostpci<slot>" "[T] $(translate 'Remove passthrough device from VM')"
_cl 14 "qm set <vmid> -onboot 0" "[T] $(translate 'Disable autostart on conflicting VM')"
_cl 15 "sed -i '/GRUB_CMDLINE_LINUX_DEFAULT/ s|...|'" "[T] $(translate 'Enable IOMMU in GRUB or ZFS boot')"
_cl 16 "update-initramfs -u && proxmox-boot-tool" "[T] $(translate 'Apply boot/initramfs changes')"
_cl 17 "lsusb | grep Coral ; lspci | grep Unichip" "$(translate 'Check Coral USB/M.2 detection')"
echo -e " ${DEF} 0) $(translate 'Back to previous menu or Esc + Enter')${CL}"
echo
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter a number, or write or paste a command: ') ${CL}"
read -r user_input
if [[ "$user_input" == $'\x1b' ]]; then
break
fi
mode="exec"
case "$user_input" in
1) cmd="lspci -nn | grep -iE 'VGA compatible|3D controller|Display controller'" ;;
2) cmd="lspci -nnk | grep -A3 -Ei 'VGA compatible|3D controller|Display controller'" ;;
3) cmd="cat /proc/cmdline" ;;
4) cmd="dmesg -T | grep -Ei 'DMAR|IOMMU|vfio|pcie|AER|reset'" ;;
5) cmd="find /sys/kernel/iommu_groups -type l" ;;
6) cmd="lsmod | grep -E 'vfio|nvidia|amdgpu|i915|apex|gasket'" ;;
7) cmd="grep -R \"vfio-pci\\|blacklist .*nvidia\\|blacklist .*amdgpu\\|blacklist .*radeon\" /etc/modprobe.d /etc/modules /etc/default/grub /etc/kernel/cmdline 2>/dev/null" ;;
8) cmd="nvidia-smi" ;;
9)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"
read -r vmid
cmd="qm config $vmid | grep -E '^(hostpci|cpu:|machine:|bios:|args:|boot:)'"
;;
10)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter CT ID: ')${CL}"
read -r ctid
cmd="pct config $ctid | grep -E '^(dev[0-9]+:|lxc\\.cgroup2\\.devices\\.allow:|lxc\\.mount\\.entry:|features:)'"
;;
11) cmd="ls -l /dev/dri /dev/kfd /dev/nvidia* /dev/apex* 2>/dev/null" ;;
12)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter hostpci slot (e.g. 0): ')${CL}"; read -r slot
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter PCI BDF (e.g. 0000:01:00.0): ')${CL}"; read -r bdf
cmd="qm set $vmid --hostpci${slot} ${bdf},pcie=1"
mode="template"
;;
13)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter hostpci slot (e.g. 0): ')${CL}"; read -r slot
cmd="qm set $vmid -delete hostpci${slot}"
mode="template"
;;
14)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
cmd="qm set $vmid -onboot 0"
mode="template"
;;
15)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Boot type (grub/zfs): ')${CL}"; read -r boot_type
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'CPU vendor (intel/amd): ')${CL}"; read -r cpu_vendor
case "$cpu_vendor" in
amd|AMD) iommu_param="amd_iommu=on iommu=pt" ;;
*) iommu_param="intel_iommu=on iommu=pt" ;;
esac
case "$boot_type" in
zfs|ZFS) cmd="sed -i 's/\$/ ${iommu_param}/' /etc/kernel/cmdline" ;;
*) cmd="sed -i '/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param}\"|' /etc/default/grub" ;;
esac
mode="template"
;;
16)
cmd="update-initramfs -u -k all && (proxmox-boot-tool refresh || update-grub)"
mode="template"
;;
17) cmd="lsusb | grep -Ei '18d1:9302|1a6e:089a' ; lspci | grep -i 'Global Unichip'" ;;
0) break ;;
*)
if [[ -n "$user_input" ]]; then
cmd="$user_input"
else
continue
fi
;;
esac
if [[ "$mode" == "template" ]]; then
echo -e "\n${GREEN}$(translate 'Manual command template (copy/paste):')${NC}\n"
echo "$cmd"
echo
msg_success "$(translate 'Press ENTER to continue...')"
read -r tmp
continue
fi
echo -e "\n${GREEN}> $cmd${NC}\n"
bash -c "$cmd"
echo
msg_success "$(translate 'Press ENTER to continue...')"
read -r tmp
done
+493
View File
@@ -0,0 +1,493 @@
#!/bin/bash
# ProxMenux - Coral TPU Installer (unified: PCIe/M.2 + USB)
# =========================================================
# Author : MacRimi
# License : MIT
# Version : 2.0 (unified PCIe+USB; auto-detect; feranick fork; libedgetpu runtime)
# Last Updated: 17/04/2026
# =========================================================
#
# One entry point for every Coral variant. At startup the script detects
# what Coral hardware is present on the host and installs only what is
# actually needed:
#
# • Coral M.2 / Mini-PCIe (vendor 1ac1 on PCIe)
# → build and install `gasket` + `apex` kernel modules via DKMS
# (feranick/gasket-driver fork; google as fallback with patches)
# → create apex group + udev rules
# → reboot required to load the fresh kernel module
#
# • Coral USB Accelerator (USB IDs 1a6e:089a / 18d1:9302)
# → add the Google Coral APT repository (signed-by keyring)
# → install libedgetpu1-std (Edge TPU runtime)
# → udev rules come with the package
# → no reboot required
#
# • Both present → both paths are run in sequence
# • Neither present → informative dialog and clean exit
#
# The script is idempotent: reruns on already-configured hosts skip work
# that is already done and recover from broken gasket-dkms package state
# (typical after a kernel upgrade on PVE 9).
# Guarantee a valid working directory before anything else. When the user
# re-runs the installer from a previous /tmp/gasket-driver/... path that our
# own `rm -rf gasket-driver` removed, the inherited cwd is orphaned and bash
# emits `chdir: error retrieving current directory` warnings from every
# subprocess. Moving to / at launch makes the rest of the script immune to
# that state.
cd / 2>/dev/null || true
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
LOG_FILE="/tmp/coral_install.log"
# Hardware detection results, set by detect_coral_hardware().
CORAL_PCIE_COUNT=0
CORAL_USB_COUNT=0
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
# ============================================================
# Hardware detection
# ============================================================
detect_coral_hardware() {
CORAL_PCIE_COUNT=0
CORAL_USB_COUNT=0
# PCIe / M.2 / Mini-PCIe — vendor 0x1ac1 (Global Unichip Corp.)
if [[ -d /sys/bus/pci/devices ]]; then
for dev in /sys/bus/pci/devices/*; do
[[ -e "$dev/vendor" ]] || continue
local vendor
vendor=$(cat "$dev/vendor" 2>/dev/null)
if [[ "$vendor" == "0x1ac1" ]]; then
CORAL_PCIE_COUNT=$((CORAL_PCIE_COUNT + 1))
fi
done
fi
# USB Accelerator
# 1a6e:089a Global Unichip Corp. (unprogrammed state — before runtime loads fw)
# 18d1:9302 Google Inc. (programmed state — after runtime talks to it)
if command -v lsusb >/dev/null 2>&1; then
CORAL_USB_COUNT=$(lsusb 2>/dev/null \
| grep -cE 'ID (1a6e:089a|18d1:9302)' || true)
fi
}
# ============================================================
# Dialogs
# ============================================================
no_hardware_dialog() {
dialog --backtitle "ProxMenux" \
--title "$(translate 'No Coral Detected')" \
--msgbox "\n$(translate 'No Coral TPU device was found on this host (neither PCIe/M.2 nor USB).')\n\n$(translate 'Connect a Coral Accelerator and try again.')" \
12 72
}
pre_install_prompt() {
local msg="\n"
msg+="$(translate 'Detected Coral hardware:')\n\n"
msg+="$(translate 'M.2 / PCIe devices:') ${CORAL_PCIE_COUNT}\n"
msg+="$(translate 'USB Accelerators:') ${CORAL_USB_COUNT}\n\n"
msg+="$(translate 'This installer will:')\n"
if [[ "$CORAL_PCIE_COUNT" -gt 0 ]]; then
msg+="$(translate 'Build and install the gasket and apex kernel modules (DKMS)')\n"
msg+="$(translate 'Set up the apex group and udev rules')\n"
fi
if [[ "$CORAL_USB_COUNT" -gt 0 ]]; then
msg+="$(translate 'Configure the Google Coral APT repository')\n"
msg+="$(translate 'Install the Edge TPU runtime (libedgetpu1-std)')\n"
fi
if [[ "$CORAL_PCIE_COUNT" -gt 0 ]]; then
msg+="\n$(translate 'A reboot is required after installation to load the new kernel modules.')"
fi
msg+="\n\n$(translate 'Do you want to proceed?')"
if ! dialog --backtitle "ProxMenux" \
--title "$(translate 'Coral TPU Installation')" \
--yesno "$msg" 20 78; then
exit 0
fi
}
# ============================================================
# PCIe / M.2 branch — gasket + apex kernel modules via DKMS
# ============================================================
ensure_apex_group_and_udev() {
msg_info "$(translate 'Ensuring apex group and udev rules...')"
if ! getent group apex >/dev/null; then
groupadd --system apex || true
msg_ok "$(translate 'System group apex created.')"
else
msg_ok "$(translate 'System group apex already exists.')"
fi
cat >/etc/udev/rules.d/99-coral-apex.rules <<'EOF'
# Coral / Google APEX TPU (M.2 / PCIe)
# Assign group "apex" and safe permissions to device nodes
KERNEL=="apex_*", GROUP="apex", MODE="0660"
SUBSYSTEM=="apex", GROUP="apex", MODE="0660"
EOF
if [[ -f /usr/lib/udev/rules.d/60-gasket-dkms.rules ]]; then
sed -i 's/GROUP="[^"]*"/GROUP="apex"/g' /usr/lib/udev/rules.d/60-gasket-dkms.rules || true
fi
udevadm control --reload-rules
udevadm trigger --subsystem-match=apex || true
msg_ok "$(translate 'apex group and udev rules are in place.')"
if ls -l /dev/apex_* 2>/dev/null | grep -q ' apex '; then
msg_ok "$(translate 'Coral TPU device nodes detected with correct group (apex).')"
else
msg_warn "$(translate 'apex device node not found yet; a reboot may be required.')"
fi
}
cleanup_broken_gasket_dkms() {
# Recover from a broken gasket-dkms .deb state (half-configured, unpacked,
# half-installed). This is a common failure mode on PVE 9 kernel upgrades:
# dkms autoinstall tries to rebuild against the new kernel, fails, and
# leaves dpkg stuck — which in turn blocks every subsequent apt-get call.
local pkg_state
pkg_state=$(dpkg -l gasket-dkms 2>/dev/null | awk '/^[a-zA-Z][a-zA-Z]/ {print $1}' | tail -1)
[[ -z "$pkg_state" ]] && return 0 # package not present — nothing to clean
case "$pkg_state" in
ii|rc)
msg_info "$(translate 'Removing any pre-existing gasket-dkms package...')"
dpkg -r gasket-dkms >>"$LOG_FILE" 2>&1 || true
dkms remove gasket/1.0 --all >>"$LOG_FILE" 2>&1 || true
msg_ok "$(translate 'Pre-existing gasket-dkms package removed.')"
;;
*)
msg_warn "$(translate 'Detected broken gasket-dkms package state:') ${pkg_state}. $(translate 'Forcing removal...')"
dpkg --remove --force-remove-reinstreq gasket-dkms >>"$LOG_FILE" 2>&1 || true
dpkg --purge --force-all gasket-dkms >>"$LOG_FILE" 2>&1 || true
dkms remove gasket/1.0 --all >>"$LOG_FILE" 2>&1 || true
apt-get install -f -y >>"$LOG_FILE" 2>&1 || true
msg_ok "$(translate 'Broken gasket-dkms package state recovered.')"
;;
esac
}
clone_gasket_sources() {
# Primary: feranick/gasket-driver — community fork, actively maintained,
# carries patches for kernel 6.10/6.12/6.13.
# Fallback: google/gasket-driver — upstream, stale. Requires manual patches.
# Sets GASKET_SOURCE_USED so the patch step knows whether to apply them.
local FERANICK_URL="https://github.com/feranick/gasket-driver.git"
local GOOGLE_URL="https://github.com/google/gasket-driver.git"
cd /tmp || exit 1
rm -rf gasket-driver >>"$LOG_FILE" 2>&1
msg_info "$(translate 'Cloning Coral driver repository (feranick fork)...')"
if git clone --depth=1 "$FERANICK_URL" gasket-driver >>"$LOG_FILE" 2>&1; then
GASKET_SOURCE_USED="feranick"
msg_ok "$(translate 'feranick/gasket-driver cloned (actively maintained, kernel 6.12+ ready).')"
return 0
fi
msg_warn "$(translate 'feranick fork unreachable. Falling back to google/gasket-driver...')"
rm -rf gasket-driver >>"$LOG_FILE" 2>&1
if git clone --depth=1 "$GOOGLE_URL" gasket-driver >>"$LOG_FILE" 2>&1; then
GASKET_SOURCE_USED="google"
msg_ok "$(translate 'google/gasket-driver cloned (fallback — will apply local patches).')"
return 0
fi
msg_error "$(translate 'Could not clone any gasket-driver repository. Check your internet connection and') ${LOG_FILE}"
exit 1
}
show_dkms_build_failure() {
# Print the last 50 lines of make.log on-screen so the user sees the real
# compilation error without having to dig the log file.
local make_log="/var/lib/dkms/gasket/1.0/build/make.log"
echo "" >&2
msg_warn "$(translate 'DKMS build failed. Last lines of make.log:')"
if [[ -f "$make_log" ]]; then
{
echo "---- /var/lib/dkms/gasket/1.0/build/make.log ----"
cat "$make_log"
} >>"$LOG_FILE" 2>&1
tail -n 50 "$make_log" >&2
else
echo "$(translate '(make.log not found — DKMS may have failed before invoking make)')" >&2
fi
echo "" >&2
echo -e "${TAB}${BL}$(translate 'Full log:')${CL} ${LOG_FILE}" >&2
echo "" >&2
}
install_gasket_apex_dkms() {
# Detect running kernel — used both to pull matching headers and to apply
# kernel-version-specific patches if we fall back to google/gasket-driver.
local KVER KMAJ KMIN
KVER=$(uname -r)
KMAJ=$(echo "$KVER" | cut -d. -f1)
KMIN=$(echo "$KVER" | cut -d. -f2 | cut -d+ -f1 | cut -d- -f1)
cleanup_broken_gasket_dkms
msg_info "$(translate 'Installing build dependencies...')"
apt-get update -qq >>"$LOG_FILE" 2>&1
if ! apt-get install -y git dkms build-essential "proxmox-headers-${KVER}" >>"$LOG_FILE" 2>&1; then
msg_error "$(translate 'Error installing build dependencies. Check') ${LOG_FILE}"
exit 1
fi
msg_ok "$(translate 'Build dependencies installed.')"
clone_gasket_sources
cd /tmp/gasket-driver || exit 1
# Patches are only needed for the stale google fork. feranick already carries
# the equivalent fixes upstream; re-applying them would double-edit sources.
if [[ "$GASKET_SOURCE_USED" == "google" ]]; then
msg_info "$(translate 'Patching source for kernel compatibility...')"
# no_llseek was removed in kernel 6.5 — replace with noop_llseek
if [[ "$KMAJ" -gt 6 ]] || [[ "$KMAJ" -eq 6 && "$KMIN" -ge 5 ]]; then
sed -i 's/\.llseek = no_llseek/\.llseek = noop_llseek/' src/gasket_core.c
fi
# MODULE_IMPORT_NS syntax changed to string-literal in 6.13.
# Applying this patch on kernel <6.13 causes a compile error.
if [[ "$KMAJ" -gt 6 ]] || [[ "$KMAJ" -eq 6 && "$KMIN" -ge 13 ]]; then
sed -i 's/^MODULE_IMPORT_NS(DMA_BUF);/MODULE_IMPORT_NS("DMA_BUF");/' src/gasket_page_table.c
fi
msg_ok "$(translate 'Source patched successfully.') (kernel ${KVER})"
else
msg_info2 "$(translate 'Skipping manual patches — feranick fork already supports this kernel.')"
fi
local GASKET_SRC="/usr/src/gasket-1.0"
if [[ ! -d /tmp/gasket-driver/src ]]; then
msg_error "$(translate 'Expected /tmp/gasket-driver/src not found. The clone seems incomplete or uses an unknown layout.')"
{ echo "---- /tmp/gasket-driver/ contents ----"; ls -la /tmp/gasket-driver 2>/dev/null || true; } >>"$LOG_FILE"
exit 1
fi
if [[ ! -f /tmp/gasket-driver/src/Makefile ]]; then
msg_error "$(translate 'Expected Makefile not found in /tmp/gasket-driver/src. Source tree is incomplete.')"
exit 1
fi
msg_info "$(translate 'Removing previous DKMS source tree...')"
dkms remove gasket/1.0 --all >>"$LOG_FILE" 2>&1 || true
if [[ -d "$GASKET_SRC" ]]; then
if ! rm -rf "$GASKET_SRC" 2>>"$LOG_FILE"; then
msg_error "$(translate 'Could not remove previous DKMS tree at') ${GASKET_SRC}. $(translate 'Check') ${LOG_FILE}"
exit 1
fi
fi
msg_ok "$(translate 'Previous DKMS tree cleared.')"
# Copy only the `src/` contents (where the kernel sources live) so
# Makefile + *.c + *.h sit at the DKMS tree root, matching the Debian
# packaging layout (`dh_install src/* usr/src/gasket-$(VERSION)/`).
msg_info "$(translate 'Copying sources to') ${GASKET_SRC}..."
mkdir -p "$GASKET_SRC"
if ! cp -a /tmp/gasket-driver/src/. "${GASKET_SRC}/" 2>>"$LOG_FILE"; then
msg_error "$(translate 'Failed to copy sources into') ${GASKET_SRC}. $(translate 'Check') ${LOG_FILE}"
exit 1
fi
if [[ ! -f "$GASKET_SRC/Makefile" ]]; then
msg_error "$(translate 'Makefile missing in') ${GASKET_SRC} $(translate 'after copy; source tree is incomplete.')"
exit 1
fi
msg_ok "$(translate 'Sources copied to') ${GASKET_SRC}"
# The repo ships debian/gasket-dkms.dkms as a template with a
# #MODULE_VERSION# placeholder that the .deb pipeline substitutes. Since we
# install directly from sources (no .deb), we write our own dkms.conf.
# MAKE[0] passes ${kernelver} to the Makefile so multi-kernel rebuilds
# (PVE's autoinstall on new kernel installs) target the right headers.
msg_info "$(translate 'Generating dkms.conf...')"
cat > "$GASKET_SRC/dkms.conf" <<'EOF'
PACKAGE_NAME="gasket"
PACKAGE_VERSION="1.0"
BUILT_MODULE_NAME[0]="gasket"
BUILT_MODULE_NAME[1]="apex"
DEST_MODULE_LOCATION[0]="/updates/dkms"
DEST_MODULE_LOCATION[1]="/updates/dkms"
MAKE[0]="make KVERSION=${kernelver}"
CLEAN="make clean"
AUTOINSTALL="yes"
EOF
if [[ ! -s "$GASKET_SRC/dkms.conf" ]]; then
msg_error "$(translate 'Failed to write') ${GASKET_SRC}/dkms.conf"
exit 1
fi
msg_ok "$(translate 'dkms.conf generated.')"
msg_info "$(translate 'Registering module with DKMS...')"
if ! dkms add "$GASKET_SRC" >>"$LOG_FILE" 2>&1; then
msg_error "$(translate 'DKMS add failed. Check') ${LOG_FILE}"
exit 1
fi
msg_ok "$(translate 'DKMS module registered.')"
msg_info "$(translate 'Compiling Coral TPU drivers for current kernel...')"
if ! dkms build gasket/1.0 -k "$KVER" >>"$LOG_FILE" 2>&1; then
show_dkms_build_failure
msg_error "$(translate 'DKMS build failed.')"
exit 1
fi
if ! dkms install gasket/1.0 -k "$KVER" >>"$LOG_FILE" 2>&1; then
show_dkms_build_failure
msg_error "$(translate 'DKMS install failed.')"
exit 1
fi
msg_ok "$(translate 'Drivers compiled and installed via DKMS.') (source: ${GASKET_SOURCE_USED})"
ensure_apex_group_and_udev
msg_info "$(translate 'Loading modules...')"
modprobe gasket >>"$LOG_FILE" 2>&1 || true
modprobe apex >>"$LOG_FILE" 2>&1 || true
if lsmod | grep -q '\bapex\b'; then
msg_ok "$(translate 'Modules loaded.')"
else
msg_warn "$(translate 'Installation finished but drivers are not loaded. A reboot may be required.')"
fi
echo "---- dmesg | grep -i apex (last lines) ----" >>"$LOG_FILE"
dmesg | grep -i apex | tail -n 20 >>"$LOG_FILE" 2>&1
}
# ============================================================
# USB branch — libedgetpu runtime from Google's APT repository
# ============================================================
install_libedgetpu_runtime() {
local KEYRING=/etc/apt/keyrings/coral-edgetpu.gpg
local LIST_FILE=/etc/apt/sources.list.d/coral-edgetpu.list
# Modern repo configuration: one keyring file under /etc/apt/keyrings plus
# a sources.list.d entry with `signed-by=`. Avoids the deprecated apt-key.
msg_info "$(translate 'Setting up the Google Coral APT repository...')"
mkdir -p /etc/apt/keyrings
if [[ ! -s "$KEYRING" ]]; then
if ! curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg \
| gpg --dearmor -o "$KEYRING" 2>>"$LOG_FILE"; then
msg_error "$(translate 'Failed to fetch the Google Coral GPG key. Check') ${LOG_FILE}"
exit 1
fi
chmod 0644 "$KEYRING"
fi
cat > "$LIST_FILE" <<EOF
deb [signed-by=${KEYRING}] https://packages.cloud.google.com/apt coral-edgetpu-stable main
EOF
if ! apt-get update -qq >>"$LOG_FILE" 2>&1; then
msg_warn "$(translate 'apt-get update returned warnings. Continuing anyway; check') ${LOG_FILE}"
fi
msg_ok "$(translate 'Coral APT repository ready.')"
# libedgetpu1-std = standard performance; libedgetpu1-max = overclocked mode
# (more heat). We default to -std; users who explicitly want -max can install
# it manually. Either way the udev rules come with the package.
msg_info "$(translate 'Installing Edge TPU runtime (libedgetpu1-std)...')"
if ! apt-get install -y libedgetpu1-std >>"$LOG_FILE" 2>&1; then
msg_error "$(translate 'Failed to install libedgetpu1-std. Check') ${LOG_FILE}"
exit 1
fi
msg_ok "$(translate 'Edge TPU runtime installed.')"
# Reload udev so the rules shipped with libedgetpu1-std apply to any USB
# Coral already plugged in (otherwise they would only apply after replug).
udevadm control --reload-rules >/dev/null 2>&1 || true
udevadm trigger --subsystem-match=usb >/dev/null 2>&1 || true
}
# ============================================================
# Final prompt
# ============================================================
restart_prompt() {
if whiptail --title "$(translate 'Coral TPU Installation')" --yesno \
"$(translate 'The installation requires a server restart to apply changes. Do you want to restart now?')" 10 70; then
msg_warn "$(translate 'Restarting the server...')"
reboot
else
msg_success "$(translate 'Completed. Press Enter to return to menu...')"
read -r
fi
}
# ============================================================
# Main orchestrator
# ============================================================
main() {
: >"$LOG_FILE"
detect_coral_hardware
# Nothing plugged in — nothing to do.
if [[ "$CORAL_PCIE_COUNT" -eq 0 && "$CORAL_USB_COUNT" -eq 0 ]]; then
no_hardware_dialog
exit 0
fi
pre_install_prompt
show_proxmenux_logo
msg_title "$(translate 'Coral TPU Installation')"
# Force non-interactive apt/dpkg for the whole run so cleanup_broken_gasket_dkms
# and the two install paths never get blocked by package-maintainer prompts.
export DEBIAN_FRONTEND=noninteractive
# Branch 1 — PCIe / M.2 (kernel modules). Runs first so the reboot reminder
# at the end only appears when we actually touched kernel modules.
if [[ "$CORAL_PCIE_COUNT" -gt 0 ]]; then
msg_info2 "$(translate 'Coral M.2 / PCIe detected — installing gasket and apex kernel modules...')"
install_gasket_apex_dkms
fi
# Branch 2 — USB (user-space runtime).
if [[ "$CORAL_USB_COUNT" -gt 0 ]]; then
msg_info2 "$(translate 'Coral USB Accelerator detected — installing Edge TPU runtime...')"
install_libedgetpu_runtime
fi
echo
if [[ "$CORAL_PCIE_COUNT" -gt 0 ]]; then
msg_success "$(translate 'Coral TPU drivers installed and loaded successfully.')"
restart_prompt
else
# USB-only install. No reboot required; the udev rules and runtime are
# already active. Ready to passthrough the device to an LXC/VM.
msg_success "$(translate 'Coral USB runtime installed. No reboot required.')"
msg_success "$(translate 'Completed. Press Enter to return to menu...')"
read -r
fi
}
main
+72 -45
View File
@@ -7,8 +7,8 @@
# Revision : @Blaspt (USB passthrough via udev rule with persistent /dev/coral)
# Copyright : (c) 2024 MacRimi
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.2
# Last Updated: 20/01/2025
# Version : 1.4 (unprivileged container support, PVE dev API for apex/iGPU)
# Last Updated: 01/04/2026
# ==========================================================
# Description:
# This script automates the configuration and installation of
@@ -21,6 +21,12 @@
# Supports Coral USB and Coral M.2 (PCIe) devices.
# Includes USB passthrough enhancement using persistent udev alias (/dev/coral).
#
# Changelog v1.3:
# - Fixed Coral USB passthrough: mount /dev/bus/usb instead of /dev/coral symlink
# The udev symlink /dev/coral is not passthrough-safe in LXC; mounting the full
# USB bus tree ensures the real device node is accessible inside the container
# regardless of which port the Coral USB is connected to.
#
# Changelog v1.2:
# - Fixed symlink detection for /dev/coral (create=dir for symlinks)
# - Fixed /dev/apex_0 not being mounted in PVE 9 (device existence not required)
@@ -152,13 +158,25 @@ add_mount_if_needed() {
cleanup_duplicate_entries() {
local CONFIG_FILE="$1"
local TEMP_FILE=$(mktemp)
awk '!seen[$0]++' "$CONFIG_FILE" > "$TEMP_FILE"
cat "$TEMP_FILE" > "$CONFIG_FILE"
rm -f "$TEMP_FILE"
}
# Returns the next available dev index (dev0, dev1, ...) in a container config.
# The PVE dev API (devN: /dev/foo,gid=N) works in both privileged and unprivileged
# containers, handling cgroup2 permissions automatically.
get_next_dev_index() {
local config="$1"
local idx=0
while grep -q "^dev${idx}:" "$config" 2>/dev/null; do
idx=$((idx + 1))
done
echo "$idx"
}
# ==========================================================
# CONFIGURE LXC HARDWARE PASSTHROUGH
# ==========================================================
@@ -174,25 +192,6 @@ configure_lxc_hardware() {
cleanup_duplicate_entries "$CONFIG_FILE"
# ============================================================
# Convert to privileged container if needed
# ============================================================
if grep -q "^unprivileged: 1" "$CONFIG_FILE"; then
msg_info "$(translate 'The container is unprivileged. Changing to privileged...')"
sed -i "s/^unprivileged: 1/unprivileged: 0/" "$CONFIG_FILE"
STORAGE_TYPE=$(pct config "$CONTAINER_ID" | grep "^rootfs:" | awk -F, '{print $2}' | cut -d'=' -f2)
if [[ "$STORAGE_TYPE" == "dir" ]]; then
STORAGE_PATH=$(pct config "$CONTAINER_ID" | grep "^rootfs:" | awk '{print $2}' | cut -d',' -f1)
chown -R root:root "$STORAGE_PATH"
fi
msg_ok "$(translate 'Container changed to privileged.')"
else
msg_ok "$(translate 'The container is already privileged.')"
fi
sed -i '/^dev[0-9]\+:/d' "$CONFIG_FILE"
# ============================================================
# Enable nesting feature
# ============================================================
@@ -211,19 +210,24 @@ configure_lxc_hardware() {
# iGPU support
# ============================================================
msg_info "$(translate 'Configuring iGPU support...')"
if ! grep -Pq "^lxc.cgroup2.devices.allow: c 226:0 rwm" "$CONFIG_FILE"; then
echo "lxc.cgroup2.devices.allow: c 226:0 rwm # iGPU" >> "$CONFIG_FILE"
fi
if ! grep -Pq "^lxc.cgroup2.devices.allow: c 226:128 rwm" "$CONFIG_FILE"; then
echo "lxc.cgroup2.devices.allow: c 226:128 rwm # iGPU" >> "$CONFIG_FILE"
fi
# Bind-mount the /dev/dri directory so apps can enumerate available devices
add_mount_if_needed "/dev/dri" "dev/dri" "$CONFIG_FILE"
add_mount_if_needed "/dev/dri/renderD128" "dev/dri/renderD128" "$CONFIG_FILE"
add_mount_if_needed "/dev/dri/card0" "dev/dri/card0" "$CONFIG_FILE"
# Add each DRI device via the PVE dev API (gid=44 = render group).
# This approach works in unprivileged containers: PVE manages cgroup2
# permissions automatically and maps the GID into the container namespace.
local igpu_dev_idx
igpu_dev_idx=$(get_next_dev_index "$CONFIG_FILE")
for dri_dev in /dev/dri/renderD128 /dev/dri/renderD129 /dev/dri/card0 /dev/dri/card1; do
if [[ -c "$dri_dev" ]]; then
if ! grep -q ":.*${dri_dev}" "$CONFIG_FILE"; then
echo "dev${igpu_dev_idx}: ${dri_dev},gid=44" >> "$CONFIG_FILE"
igpu_dev_idx=$((igpu_dev_idx + 1))
fi
fi
done
msg_ok "$(translate 'iGPU configuration added')"
# ============================================================
@@ -250,8 +254,13 @@ configure_lxc_hardware() {
if ! grep -Pq "^lxc.cgroup2.devices.allow: c 189:\\\* rwm" "$CONFIG_FILE"; then
echo "lxc.cgroup2.devices.allow: c 189:* rwm # Coral USB" >> "$CONFIG_FILE"
fi
add_mount_if_needed "/dev/coral" "dev/coral" "$CONFIG_FILE"
# FIX v1.3: Mount /dev/bus/usb instead of the /dev/coral symlink.
# The udev symlink /dev/coral cannot be safely passed through to LXC because
# it points to a dynamic path (e.g. /dev/bus/usb/001/005) that changes on
# reconnect. Mounting the full USB bus tree makes the real device node
# available inside the container regardless of port or reconnection.
add_mount_if_needed "/dev/bus/usb" "dev/bus/usb" "$CONFIG_FILE"
if [ -L "/dev/coral" ]; then
msg_ok "$(translate 'Coral USB configuration added - device detected')"
@@ -266,18 +275,29 @@ configure_lxc_hardware() {
if lspci | grep -iq "Global Unichip"; then
msg_info "$(translate 'Coral M.2 Apex detected, configuring...')"
if ! grep -Pq "^lxc.cgroup2.devices.allow: c 245:0 rwm" "$CONFIG_FILE"; then
echo "lxc.cgroup2.devices.allow: c 245:0 rwm # Coral M2 Apex" >> "$CONFIG_FILE"
fi
local APEX_GID apex_dev_idx
APEX_GID=$(getent group apex 2>/dev/null | cut -d: -f3 || echo "0")
apex_dev_idx=$(get_next_dev_index "$CONFIG_FILE")
add_mount_if_needed "/dev/apex_0" "dev/apex_0" "$CONFIG_FILE"
if [ -e "/dev/apex_0" ]; then
# Device is visible — use PVE dev API (works in unprivileged containers).
# PVE handles cgroup2 permissions automatically.
if ! grep -q "dev.*apex_0" "$CONFIG_FILE"; then
echo "dev${apex_dev_idx}: /dev/apex_0,gid=${APEX_GID}" >> "$CONFIG_FILE"
fi
msg_ok "$(translate 'Coral M.2 Apex configuration added - device ready')"
else
# Device not yet visible (host module not loaded or reboot pending).
# Use cgroup2 + optional bind-mount as fallback; detect major number
# dynamically from /proc/devices to avoid hardcoding it.
local APEX_MAJOR
APEX_MAJOR=$(awk '/\bapex\b/{print $1}' /proc/devices 2>/dev/null | head -1)
[[ -z "$APEX_MAJOR" ]] && APEX_MAJOR="245"
if ! grep -q "lxc.cgroup2.devices.allow: c ${APEX_MAJOR}:0 rwm" "$CONFIG_FILE"; then
echo "lxc.cgroup2.devices.allow: c ${APEX_MAJOR}:0 rwm # Coral M2 Apex" >> "$CONFIG_FILE"
fi
add_mount_if_needed "/dev/apex_0" "dev/apex_0" "$CONFIG_FILE"
msg_ok "$(translate 'Coral M.2 Apex configuration added - device will be available after reboot')"
fi
fi
@@ -300,7 +320,13 @@ install_coral_in_container() {
if ! pct status "$CONTAINER_ID" | grep -q "running"; then
pct start "$CONTAINER_ID"
sleep 5
for _ in {1..15}; do
pct status "$CONTAINER_ID" | grep -q "running" && break
sleep 1
done
if ! pct status "$CONTAINER_ID" | grep -q "running"; then
msg_error "$(translate 'Container did not start in time.')"; exit 1
fi
fi
@@ -326,7 +352,8 @@ install_coral_in_container() {
# Install drivers inside container
script -q -c "pct exec \"$CONTAINER_ID\" -- bash -c '
set -e
export DEBIAN_FRONTEND=noninteractive
echo \"[1/6] Updating package lists...\"
apt-get update -qq
-173
View File
@@ -1,173 +0,0 @@
#!/bin/bash
# ProxMenux - Coral TPU Installer (PVE 9.x)
# =========================================
# Author : MacRimi
# License : MIT
# Version : 1.3 (PVE9, silent build)
# Last Updated: 25/09/2025
# =========================================
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
LOG_FILE="/tmp/coral_install.log"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
ensure_apex_group_and_udev() {
msg_info "Ensuring apex group and udev rules..."
if ! getent group apex >/dev/null; then
groupadd --system apex || true
msg_ok "System group 'apex' created"
else
msg_ok "System group 'apex' already exists"
fi
cat >/etc/udev/rules.d/99-coral-apex.rules <<'EOF'
# Coral / Google APEX TPU (M.2 / PCIe)
# Assign group "apex" and safe permissions to device nodes
KERNEL=="apex_*", GROUP="apex", MODE="0660"
SUBSYSTEM=="apex", GROUP="apex", MODE="0660"
EOF
if [[ -f /usr/lib/udev/rules.d/60-gasket-dkms.rules ]]; then
sed -i 's/GROUP="[^"]*"/GROUP="apex"/g' /usr/lib/udev/rules.d/60-gasket-dkms.rules || true
fi
udevadm control --reload-rules
udevadm trigger --subsystem-match=apex || true
msg_ok "apex group and udev rules are in place"
if ls -l /dev/apex_* 2>/dev/null | grep -q ' apex '; then
msg_ok "Coral TPU device nodes detected with correct group (apex)"
else
msg_warn "apex device node not found yet; a reboot may be required"
fi
}
pre_install_prompt() {
if ! dialog --title "$(translate 'Coral TPU Installation')" --yesno \
"\n$(translate 'Installing Coral TPU drivers requires rebooting the server after installation. Do you want to proceed?')" 10 70; then
exit 0
fi
}
install_coral_host() {
show_proxmenux_logo
: >"$LOG_FILE"
msg_info "$(translate 'Installing build dependencies...')"
apt-get update -qq >>"$LOG_FILE" 2>&1
apt-get install -y git devscripts dh-dkms dkms proxmox-headers-$(uname -r) >>"$LOG_FILE" 2>&1
if [[ $? -ne 0 ]]; then msg_error "$(translate 'Error installing build dependencies. Check /tmp/coral_install.log')"; exit 1; fi
msg_ok "$(translate 'Build dependencies installed.')"
cd /tmp || exit 1
rm -rf gasket-driver >>"$LOG_FILE" 2>&1
msg_info "$(translate 'Cloning Google Coral driver repository...')"
git clone https://github.com/google/gasket-driver.git >>"$LOG_FILE" 2>&1
if [[ $? -ne 0 ]]; then msg_error "$(translate 'Could not clone the repository. Check /tmp/coral_install.log')"; exit 1; fi
msg_ok "$(translate 'Repository cloned successfully.')"
cd /tmp/gasket-driver || exit 1
msg_info "$(translate 'Patching source for kernel compatibility...')"
sed -i 's/\.llseek = no_llseek/\.llseek = noop_llseek/' src/gasket_core.c
sed -i 's/^MODULE_IMPORT_NS(DMA_BUF);/MODULE_IMPORT_NS("DMA_BUF");/' src/gasket_page_table.c
sed -i "s/\(linux-headers-686-pae | linux-headers-amd64 | linux-headers-generic | linux-headers\)/\1 | proxmox-headers-$(uname -r) | pve-headers-$(uname -r)/" debian/control
if [[ $? -ne 0 ]]; then msg_error "$(translate 'Patching failed. Check /tmp/coral_install.log')"; exit 1; fi
msg_ok "$(translate 'Source patched successfully.')"
msg_info "$(translate 'Building DKMS package...')"
debuild -us -uc -tc -b >>"$LOG_FILE" 2>&1
if [[ $? -ne 0 ]]; then msg_error "$(translate 'Failed to build DKMS package. Check /tmp/coral_install.log')"; exit 1; fi
msg_ok "$(translate 'DKMS package built successfully.')"
msg_info "$(translate 'Installing DKMS package...')"
dpkg -i ../gasket-dkms_*.deb >>"$LOG_FILE" 2>&1 || true
if ! dpkg -s gasket-dkms >/dev/null 2>&1; then
msg_error "$(translate 'Failed to install DKMS package. Check /tmp/coral_install.log')"; exit 1
fi
msg_ok "$(translate 'DKMS package installed.')"
msg_info "$(translate 'Compiling Coral TPU drivers for current kernel...')"
dkms remove -m gasket -v 1.0 -k "$(uname -r)" >>"$LOG_FILE" 2>&1 || true
dkms add -m gasket -v 1.0 >>"$LOG_FILE" 2>&1 || true
dkms build -m gasket -v 1.0 -k "$(uname -r)" >>"$LOG_FILE" 2>&1
if [[ $? -ne 0 ]]; then
sed -n '1,200p' /var/lib/dkms/gasket/1.0/build/make.log >>"$LOG_FILE" 2>&1 || true
msg_error "$(translate 'DKMS build failed. Check /tmp/coral_install.log')"; exit 1
fi
dkms install -m gasket -v 1.0 -k "$(uname -r)" >>"$LOG_FILE" 2>&1
if [[ $? -ne 0 ]]; then msg_error "$(translate 'DKMS install failed. Check /tmp/coral_install.log')"; exit 1; fi
msg_ok "$(translate 'Drivers compiled and installed via DKMS.')"
ensure_apex_group_and_udev
msg_info "$(translate 'Loading modules...')"
modprobe gasket >>"$LOG_FILE" 2>&1 || true
modprobe apex >>"$LOG_FILE" 2>&1 || true
if lsmod | grep -q '\bapex\b'; then
msg_ok "$(translate 'Modules loaded.')"
msg_success "$(translate 'Coral TPU drivers installed and loaded successfully.')"
else
msg_warn "$(translate 'Installation finished but drivers are not loaded. Please check dmesg and /tmp/coral_install.log')"
fi
echo "---- dmesg | grep -i apex (last lines) ----" >>"$LOG_FILE"
dmesg | grep -i apex | tail -n 20 >>"$LOG_FILE" 2>&1
}
restart_prompt() {
if whiptail --title "$(translate 'Coral TPU Installation')" --yesno \
"$(translate 'The installation requires a server restart to apply changes. Do you want to restart now?')" 10 70; then
msg_warn "$(translate 'Restarting the server...')"
reboot
else
msg_success "$(translate 'Completed. Press Enter to return to menu...')"
read -r
fi
}
pre_install_prompt
install_coral_host
restart_prompt
+459 -42
View File
@@ -2,9 +2,10 @@
# ProxMenux - NVIDIA Driver Installer (PVE 9.x)
# ============================================
# Author : MacRimi
# License : MIT
# Version : 0.9 (PVE9, fixed download issues)
# Last Updated: 29/11/2025
# Copyright : (c) 2024 MacRimi
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.2 (PVE9, fixed download issues)
# Last Updated: 26/03/2026
# ============================================
SCRIPT_TITLE="NVIDIA GPU Driver Installer for Proxmox VE"
@@ -19,6 +20,12 @@ screen_capture="/tmp/proxmenux_nvidia_screen_capture_$$.txt"
NVIDIA_BASE_URL="https://download.nvidia.com/XFree86/Linux-x86_64"
NVIDIA_WORKDIR="/opt/nvidia"
# LXC post-install update constants (used only when NVIDIA LXC passthrough
# containers are detected and the user confirms updating them after the host
# install/reinstall finishes).
NVIDIA_INSTALL_MIN_MB=2048
CT_ORIG_MEM=""
export BASE_DIR
export COMPONENTS_STATUS_FILE
@@ -56,12 +63,39 @@ detect_nvidia_gpus() {
fi
}
check_gpu_not_in_vm_passthrough() {
local dev vendor driver vfio_list=""
for dev in /sys/bus/pci/devices/*; do
vendor=$(cat "$dev/vendor" 2>/dev/null)
[[ "$vendor" != "0x10de" ]] && continue
if [[ -L "$dev/driver" ]]; then
driver=$(basename "$(readlink "$dev/driver")")
if [[ "$driver" == "vfio-pci" ]]; then
vfio_list+="$(basename "$dev")\n"
fi
fi
done
[[ -z "$vfio_list" ]] && return 0
local msg
msg="\n$(translate "One or more NVIDIA GPUs are currently configured for VM passthrough (vfio-pci):")\n\n"
msg+="${vfio_list}\n"
msg+="$(translate "Installing host drivers while the GPU is assigned to a VM could break passthrough and destabilize the system.")\n\n"
msg+="$(translate "To install host drivers, first remove the GPU from VM passthrough configuration and reboot.")"
dialog --backtitle "ProxMenux" \
--title "$(translate "GPU in VM Passthrough Mode")" \
--msgbox "$msg" 16 78
exit 0
}
detect_driver_status() {
CURRENT_DRIVER_INSTALLED=false
CURRENT_DRIVER_VERSION=""
# First check if nvidia kernel module is actually loaded
if lsmod | grep -q "^nvidia "; then
if grep -q "^nvidia " /proc/modules 2>/dev/null; then
modprobe nvidia-uvm 2>/dev/null || true
sleep 1
@@ -91,6 +125,272 @@ detect_driver_status() {
fi
}
# ==========================================================
# LXC NVIDIA passthrough — discovery & userspace-libs update
# Invoked after the host install/reinstall completes. Aligned with the install
# path used in add_gpu_lxc.sh (distro-aware, memory/disk checks, --no-dkms,
# --no-install-compat32-libs, visible progress via tee).
# ==========================================================
find_nvidia_containers() {
NVIDIA_CONTAINERS=()
for conf in /etc/pve/lxc/*.conf; do
[[ -f "$conf" ]] || continue
if grep -qiE "dev[0-9]+:.*nvidia" "$conf"; then
NVIDIA_CONTAINERS+=("$(basename "$conf" .conf)")
fi
done
}
get_lxc_nvidia_version() {
local ctid="$1"
local version=""
# Prefer nvidia-smi when the container is running (works with .run-installed drivers)
if pct status "$ctid" 2>/dev/null | grep -q "running"; then
version=$(pct exec "$ctid" -- nvidia-smi \
--query-gpu=driver_version --format=csv,noheader 2>/dev/null \
| head -1 | tr -d '[:space:]' || true)
fi
# Fallback: dpkg status for apt-installed libcuda1 (dir-type storage, no start needed)
if [[ -z "$version" ]]; then
local rootfs="/var/lib/lxc/${ctid}/rootfs"
if [[ -f "${rootfs}/var/lib/dpkg/status" ]]; then
version=$(grep -A5 "^Package: libcuda1$" "${rootfs}/var/lib/dpkg/status" \
| grep "^Version:" | head -1 | awk '{print $2}' | cut -d- -f1)
fi
fi
echo "${version:-$(translate 'not installed')}"
}
_detect_container_distro() {
local distro
distro=$(pct exec "$1" -- grep "^ID=" /etc/os-release 2>/dev/null \
| cut -d= -f2 | tr -d '[:space:]"')
echo "${distro:-unknown}"
}
_ensure_container_memory() {
local ctid="$1"
local cur_mem
cur_mem=$(pct config "$ctid" 2>/dev/null | awk '/^memory:/{print $2}')
[[ -z "$cur_mem" ]] && cur_mem=512
if [[ "$cur_mem" -lt "$NVIDIA_INSTALL_MIN_MB" ]]; then
if whiptail --title "$(translate 'Low Container Memory')" --yesno \
"$(translate 'Container') ${ctid} $(translate 'has') ${cur_mem}MB RAM.\n\n$(translate 'The NVIDIA installer needs at least') ${NVIDIA_INSTALL_MIN_MB}MB $(translate 'to run without being killed by the OOM killer.')\n\n$(translate 'Increase container RAM temporarily to') ${NVIDIA_INSTALL_MIN_MB}MB?" \
13 72; then
CT_ORIG_MEM="$cur_mem"
pct set "$ctid" -memory "$NVIDIA_INSTALL_MIN_MB" >>"$LOG_FILE" 2>&1 || true
else
msg_warn "$(translate 'Insufficient memory. Skipping LXC') ${ctid}."
return 1
fi
fi
return 0
}
_restore_container_memory() {
local ctid="$1"
if [[ -n "$CT_ORIG_MEM" ]]; then
msg_info "$(translate 'Restoring container memory to') ${CT_ORIG_MEM}MB..."
pct set "$ctid" -memory "$CT_ORIG_MEM" >>"$LOG_FILE" 2>&1 || true
msg_ok "$(translate 'Memory restored.')"
CT_ORIG_MEM=""
fi
}
_start_container_and_wait() {
local ctid="$1"
msg_info "$(translate 'Starting container') ${ctid}..."
pct start "$ctid" >>"$LOG_FILE" 2>&1 || true
local ready=false
for _ in {1..15}; do
sleep 2
if pct exec "$ctid" -- true >/dev/null 2>&1; then
ready=true
break
fi
done
if ! $ready; then
msg_warn "$(translate 'Container') ${ctid} $(translate 'did not become ready. Skipping.')"
return 1
fi
msg_ok "$(translate 'Container') ${ctid} $(translate 'started.')" | tee -a "$screen_capture"
return 0
}
update_lxc_nvidia() {
local ctid="$1"
local version="$2"
local started_here=false
local old_version
old_version=$(get_lxc_nvidia_version "$ctid")
msg_info2 "$(translate 'Container') ${ctid}: $(translate 'updating NVIDIA userspace libs') (${old_version}${version})"
if ! pct status "$ctid" 2>/dev/null | grep -q "running"; then
started_here=true
_start_container_and_wait "$ctid" || return 1
fi
msg_info "$(translate 'Detecting container OS...')"
local distro
distro=$(_detect_container_distro "$ctid")
msg_ok "$(translate 'Container OS:') ${distro}" | tee -a "$screen_capture"
local install_rc=0
case "$distro" in
alpine)
msg_info2 "$(translate 'Upgrading NVIDIA utils (Alpine)...')"
pct exec "$ctid" -- sh -c \
"apk update && apk add --no-cache --upgrade nvidia-utils" \
2>&1 | tee -a "$LOG_FILE"
install_rc=${PIPESTATUS[0]}
;;
arch|manjaro|endeavouros)
msg_info2 "$(translate 'Upgrading NVIDIA utils (Arch)...')"
pct exec "$ctid" -- bash -c \
"pacman -Syu --noconfirm nvidia-utils" \
2>&1 | tee -a "$LOG_FILE"
install_rc=${PIPESTATUS[0]}
;;
*)
local run_file="${NVIDIA_WORKDIR}/NVIDIA-Linux-x86_64-${version}.run"
if [[ ! -f "$run_file" ]]; then
msg_warn "$(translate 'Installer not found:') ${run_file}. $(translate 'Skipping LXC') ${ctid}."
install_rc=1
elif ! _ensure_container_memory "$ctid"; then
install_rc=1
else
local free_mb
free_mb=$(pct exec "$ctid" -- df -m / 2>/dev/null | awk 'NR==2{print $4}' || echo 0)
if [[ "$free_mb" -lt 1500 ]]; then
_restore_container_memory "$ctid"
whiptail --backtitle "ProxMenux" \
--title "$(translate 'Insufficient Disk Space')" \
--msgbox "\n$(translate 'Container') ${ctid} $(translate 'has only') ${free_mb}MB $(translate 'of free disk space.')\n\n$(translate 'NVIDIA libs require approximately 1.5GB of free space.')" \
11 72
msg_warn "$(translate 'Insufficient disk space. Skipping LXC') ${ctid}."
install_rc=1
else
local extract_dir="${NVIDIA_WORKDIR}/extracted_${version}"
local archive="/tmp/nvidia_lxc_${version}.tar.gz"
msg_info2 "$(translate 'Extracting NVIDIA installer on host...')"
rm -rf "$extract_dir"
sh "$run_file" --extract-only --target "$extract_dir" 2>&1 | tee -a "$LOG_FILE"
if [[ ${PIPESTATUS[0]} -ne 0 ]]; then
msg_warn "$(translate 'Extraction failed. Check log:') ${LOG_FILE}"
_restore_container_memory "$ctid"
install_rc=1
else
msg_ok "$(translate 'NVIDIA installer extracted.')" | tee -a "$screen_capture"
msg_info2 "$(translate 'Packing installer archive...')"
tar --checkpoint=5000 --checkpoint-action=dot \
-czf "$archive" -C "$extract_dir" . 2>&1 | tee -a "$LOG_FILE"
echo ""
local archive_size
archive_size=$(du -sh "$archive" 2>/dev/null | cut -f1)
msg_ok "$(translate 'Archive ready') (${archive_size})." | tee -a "$screen_capture"
msg_info "$(translate 'Copying installer to container') ${ctid}..."
if ! pct push "$ctid" "$archive" /tmp/nvidia_lxc.tar.gz >>"$LOG_FILE" 2>&1; then
msg_warn "$(translate 'pct push failed. Check log:') ${LOG_FILE}"
rm -f "$archive"
rm -rf "$extract_dir"
_restore_container_memory "$ctid"
install_rc=1
else
rm -f "$archive"
msg_ok "$(translate 'Installer copied to container.')" | tee -a "$screen_capture"
msg_info2 "$(translate 'Running NVIDIA installer in container. This may take several minutes...')"
echo "" >>"$LOG_FILE"
pct exec "$ctid" -- bash -c "
mkdir -p /tmp/nvidia_lxc_install
tar -xzf /tmp/nvidia_lxc.tar.gz -C /tmp/nvidia_lxc_install 2>&1
/tmp/nvidia_lxc_install/nvidia-installer \
--no-kernel-modules \
--no-questions \
--ui=none \
--no-nouveau-check \
--no-dkms \
--no-install-compat32-libs
EXIT=\$?
rm -rf /tmp/nvidia_lxc_install /tmp/nvidia_lxc.tar.gz
exit \$EXIT
" 2>&1 | tee -a "$LOG_FILE"
install_rc=${PIPESTATUS[0]}
rm -rf "$extract_dir"
_restore_container_memory "$ctid"
fi
fi
fi
fi
;;
esac
if [[ $install_rc -ne 0 ]]; then
msg_warn "$(translate 'NVIDIA update failed for LXC') ${ctid} (rc=${install_rc}). $(translate 'Check log:') ${LOG_FILE}"
if $started_here; then
pct stop "$ctid" >>"$LOG_FILE" 2>&1 || true
fi
return 1
fi
if pct exec "$ctid" -- sh -c "which nvidia-smi" >/dev/null 2>&1; then
local new_ver
new_ver=$(pct exec "$ctid" -- nvidia-smi \
--query-gpu=driver_version --format=csv,noheader 2>/dev/null \
| head -1 | tr -d '[:space:]' || true)
msg_ok "$(translate 'Container') ${ctid}: ${old_version}${new_ver:-$version}" | tee -a "$screen_capture"
else
msg_warn "$(translate 'nvidia-smi not found in container') ${ctid} $(translate 'after update.')"
fi
if $started_here; then
msg_info "$(translate 'Stopping container') ${ctid}..."
pct stop "$ctid" >>"$LOG_FILE" 2>&1 || true
msg_ok "$(translate 'Container stopped.')" | tee -a "$screen_capture"
fi
return 0
}
# Post-host-install LXC update offer — scans for NVIDIA LXCs and, if any are
# found, asks the user if they want to propagate the driver update to them.
offer_lxc_updates_if_any() {
local target_version="$1"
find_nvidia_containers
[[ ${#NVIDIA_CONTAINERS[@]} -eq 0 ]] && return 0
local info ctid lxc_ver ct_name
info="\n$(translate 'The following LXC containers have NVIDIA passthrough configured:')\n\n"
for ctid in "${NVIDIA_CONTAINERS[@]}"; do
lxc_ver=$(get_lxc_nvidia_version "$ctid")
ct_name=$(pct config "$ctid" 2>/dev/null | grep "^hostname:" | awk '{print $2}')
info+=" CT ${ctid} ${ct_name:+(${ct_name})}$(translate 'driver:') ${lxc_ver}\n"
done
info+="\n$(translate 'Do you want to update the NVIDIA userspace libraries inside these containers to match the host?')"
if ! hybrid_whiptail_yesno "$(translate 'Update NVIDIA in LXC Containers')" "$info" 20 80; then
msg_info2 "$(translate 'LXC update skipped by user.')"
return 0
fi
for ctid in "${NVIDIA_CONTAINERS[@]}"; do
update_lxc_nvidia "$ctid" "$target_version" || true
done
}
# ==========================================================
# System preparation (repos, headers, etc.)
# ==========================================================
@@ -114,10 +414,39 @@ ensure_repos_and_headers() {
blacklist_nouveau() {
msg_info "$(translate 'Blacklisting nouveau driver...')"
# Write blacklist config files
if ! grep -q '^blacklist nouveau' /etc/modprobe.d/blacklist.conf 2>/dev/null; then
echo "blacklist nouveau" >> /etc/modprobe.d/blacklist.conf
fi
msg_ok "$(translate 'nouveau driver has been blacklisted.')" | tee -a "$screen_capture"
# Also write explicit options file to ensure it's fully disabled
cat > /etc/modprobe.d/nouveau-blacklist.conf <<'EOF'
blacklist nouveau
options nouveau modeset=0
EOF
# Attempt to unload nouveau if currently loaded
if grep -q "^nouveau " /proc/modules 2>/dev/null; then
msg_info "$(translate 'Nouveau module is loaded, attempting to unload...')"
modprobe -r nouveau 2>/dev/null || true
sleep 1
# Check if unload succeeded
if grep -q "^nouveau " /proc/modules 2>/dev/null; then
NOUVEAU_STILL_LOADED=true
msg_warn "$(translate 'Could not unload nouveau module (may be in use). The blacklist will take effect after reboot. Installation will continue but a reboot will be required.')"
echo "WARNING: nouveau module still loaded after unload attempt" >> "$LOG_FILE"
else
NOUVEAU_STILL_LOADED=false
msg_ok "$(translate 'nouveau module unloaded successfully.')" | tee -a "$screen_capture"
fi
else
NOUVEAU_STILL_LOADED=false
msg_ok "$(translate 'nouveau driver has been blacklisted.')" | tee -a "$screen_capture"
fi
}
ensure_modules_config() {
@@ -161,7 +490,7 @@ stop_and_disable_nvidia_services() {
systemctl disable "$service" >/dev/null 2>&1 || true
fi
done
sleep 2
msg_ok "$(translate 'NVIDIA services stopped and disabled.')" | tee -a "$screen_capture"
@@ -169,26 +498,31 @@ stop_and_disable_nvidia_services() {
}
unload_nvidia_modules() {
msg_info "$(translate 'Unloading NVIDIA kernel modules...')"
for mod in nvidia_uvm nvidia_drm nvidia_modeset nvidia; do
modprobe -r "$mod" >/dev/null 2>&1 || true
done
# Give the kernel a moment to finalize sysfs teardown before re-checking.
# Reading /proc/modules directly (instead of lsmod) avoids the
# "could not open /sys/module/<mod>/holders" race when a module has just
# been removed from /proc/modules but its sysfs dir hasn't been reaped yet.
sleep 1
if lsmod | grep -qi '\bnvidia'; then
if grep -q "^nvidia" /proc/modules 2>/dev/null; then
for mod in nvidia_uvm nvidia_drm nvidia_modeset nvidia; do
modprobe -r --force "$mod" >/dev/null 2>&1 || true
done
sleep 1
fi
if lsmod | grep -qi '\bnvidia'; then
msg_warn "$(translate 'Some NVIDIA modules could not be unloaded. Installation may fail. Ensure no processes are using the GPU.')"
if grep -q "^nvidia" /proc/modules 2>/dev/null; then
if command -v lsof >/dev/null 2>&1; then
echo "$(translate 'Processes using NVIDIA:'):" >> "$LOG_FILE"
lsof /dev/nvidia* 2>/dev/null >> "$LOG_FILE" || true
fi
else
msg_ok "$(translate 'NVIDIA kernel modules unloaded successfully.')" | tee -a "$screen_capture"
fi
}
@@ -202,7 +536,7 @@ complete_nvidia_uninstall() {
nvidia-uninstall --silent >>"$LOG_FILE" 2>&1 || true
msg_ok "$(translate 'NVIDIA uninstaller completed.')"
fi
msg_ok "$(translate 'NVIDIA uninstallation steps completed.')" | tee -a "$screen_capture"
cleanup_nvidia_dkms
msg_info "$(translate 'Removing NVIDIA packages...')"
@@ -219,10 +553,11 @@ complete_nvidia_uninstall() {
find "$NVIDIA_WORKDIR" -type d -name "nvidia-persistenced" -exec rm -rf {} + 2>/dev/null || true
find "$NVIDIA_WORKDIR" -type d -name "nvidia-patch" -exec rm -rf {} + 2>/dev/null || true
fi
update_component_status "nvidia_driver" "removed" "" "gpu" '{}'
msg_ok "$(translate 'Complete NVIDIA uninstallation finished.')" | tee -a "$screen_capture"
}
cleanup_nvidia_dkms() {
@@ -458,7 +793,7 @@ download_nvidia_installer() {
return 0
else
echo "Existing file FAILED integrity check, removing..." >> "$LOG_FILE"
msg_warn "$(translate 'Existing file failed verification, re-downloading...')" >&2
msg_warn "$(translate 'Existing file, re-downloading...')" >&2
rm -f "$run_file"
fi
else
@@ -478,44 +813,72 @@ download_nvidia_installer() {
"${NVIDIA_BASE_URL}/${version}/NVIDIA-Linux-x86_64-${version}.run"
"${NVIDIA_BASE_URL}/${version}/NVIDIA-Linux-x86_64-${version}-no-compat32.run"
)
# Web mode (ProxMenux Monitor) runs scripts without a controlling TTY, so
# /dev/tty is not writable and progress-bar animations using \r don't render
# in the web terminal. Fall back to a quiet wget in that case; interactive
# users (SSH / console) still get the ISO-like progress bar.
local _nv_has_tty=false
if ! is_web_mode 2>/dev/null && [[ -t 2 ]]; then
_nv_has_tty=true
fi
if $_nv_has_tty; then
printf '\n %s NVIDIA-Linux-x86_64-%s.run\n' \
"$(translate 'Downloading')" "$version" >/dev/tty
else
echo " $(translate 'Downloading') NVIDIA-Linux-x86_64-${version}.run" >&2
fi
local success=false
local url_index=0
for url in "${urls[@]}"; do
((url_index++))
echo "Attempting download from: $url" >> "$LOG_FILE"
rm -f "$run_file"
if curl -fL --connect-timeout 30 --max-time 600 "$url" -o "$run_file" >> "$LOG_FILE" 2>&1; then
local _dl_ok=false
if $_nv_has_tty; then
# Interactive: progress bar to /dev/tty (bypasses any caller redirection).
if wget --no-verbose --show-progress \
--connect-timeout=30 --timeout=600 --tries=1 \
-O "$run_file" "$url" 2>/dev/tty; then
_dl_ok=true
fi
else
# Web / no-TTY: silent wget, log errors only.
if wget --quiet \
--connect-timeout=30 --timeout=600 --tries=1 \
-O "$run_file" "$url" 2>>"$LOG_FILE"; then
_dl_ok=true
fi
fi
if $_dl_ok; then
echo "Download completed, verifying file..." >> "$LOG_FILE"
if [[ ! -f "$run_file" ]]; then
echo "ERROR: File not created after download" >> "$LOG_FILE"
continue
fi
local file_size
file_size=$(stat -c%s "$run_file" 2>/dev/null || stat -f%z "$run_file" 2>/dev/null || echo "0")
echo "Downloaded file size: $file_size bytes" >> "$LOG_FILE"
if [[ $file_size -lt 40000000 ]]; then
echo "ERROR: File too small ($file_size bytes, expected >40MB)" >> "$LOG_FILE"
head -c 200 "$run_file" >> "$LOG_FILE" 2>&1
rm -f "$run_file"
continue
fi
local file_type
file_type=$(file "$run_file" 2>/dev/null)
echo "File type: $file_type" >> "$LOG_FILE"
if echo "$file_type" | grep -q "executable"; then
echo "SUCCESS: Valid executable downloaded" >> "$LOG_FILE"
success=true
@@ -526,11 +889,11 @@ download_nvidia_installer() {
rm -f "$run_file"
fi
else
echo "ERROR: curl failed for $url (exit code: $?)" >> "$LOG_FILE"
echo "ERROR: wget failed for $url (exit code: $?)" >> "$LOG_FILE"
rm -f "$run_file"
fi
done
if ! $success; then
msg_error "$(translate 'Download failed for all attempted URLs')" >&2
msg_error "Version $version may not be available for your architecture" >&2
@@ -553,10 +916,38 @@ run_nvidia_installer() {
echo "" >>"$LOG_FILE"
echo "=== Running NVIDIA installer: $installer ===" >>"$LOG_FILE"
# If nouveau is still loaded, rebuild initramfs first so the blacklist takes
# effect for the installer sanity checks. Without this the .run installer
# detects nouveau as active and aborts even when --disable-nouveau is passed.
if [[ "${NOUVEAU_STILL_LOADED:-false}" == "true" ]]; then
msg_info "$(translate 'Rebuilding initramfs to apply nouveau blacklist before installation...')"
update-initramfs -u -k all >>"$LOG_FILE" 2>&1 || true
# Try one more time to unload nouveau after initramfs rebuild
modprobe -r nouveau 2>/dev/null || true
sleep 1
if grep -q "^nouveau " /proc/modules 2>/dev/null; then
echo "WARNING: nouveau still loaded after initramfs rebuild, proceeding with --no-nouveau-check" >> "$LOG_FILE"
msg_warn "$(translate 'nouveau still active. Proceeding with installation. A reboot will be required for the driver to work.')"
else
NOUVEAU_STILL_LOADED=false
msg_ok "$(translate 'nouveau module unloaded after initramfs rebuild.')" | tee -a "$screen_capture"
fi
fi
local tmp_extract_dir="$NVIDIA_WORKDIR/tmp_extract"
mkdir -p "$tmp_extract_dir"
sh "$installer" --tmpdir="$tmp_extract_dir" --no-questions --ui=none --disable-nouveau --dkms 2>&1 | tee -a "$LOG_FILE"
# --no-nouveau-check: prevents the installer from aborting when nouveau is
# still loaded. The blacklist files are already in place; nouveau will be
# gone after the reboot that the script offers at the end.
sh "$installer" \
--tmpdir="$tmp_extract_dir" \
--no-questions \
--ui=none \
--disable-nouveau \
--no-nouveau-check \
--dkms \
2>&1 | tee -a "$LOG_FILE"
local rc=${PIPESTATUS[0]}
echo "" >>"$LOG_FILE"
@@ -667,18 +1058,32 @@ show_install_overview() {
overview+="$(translate 'Install NVIDIA proprietary drivers')\n"
overview+="$(translate 'Configure GPU passthrough with VFIO')\n"
overview+="$(translate 'Blacklist nouveau driver')\n"
overview+="$(translate 'Enable IOMMU support if not enabled')\n\n"
overview+="$(translate 'Enable IOMMU support if not enabled')\n"
overview+="$(translate 'Optionally update NVIDIA libs in LXC containers with passthrough')\n\n"
overview+="$(translate 'Detected GPU(s):')\n"
overview+="\Zb\Z4$DETECTED_GPUS_TEXT\Zn\n"
overview+="\Zb\Z4$DETECTED_GPUS_TEXT\Zn\n"
overview+="\n\Zn$(translate 'Current status: ') "
overview+="\Zb${CURRENT_STATUS_TEXT}\Zn\n\n"
overview+="\Zb${CURRENT_STATUS_TEXT}\Zn\n"
overview+="$(translate 'After confirming, you will be asked to choose the NVIDIA driver version to install.')\n\n"
# Scan for LXC containers with NVIDIA passthrough and surface them in the
# overview so the user knows upfront they will be offered a driver update.
find_nvidia_containers
if [[ ${#NVIDIA_CONTAINERS[@]} -gt 0 ]]; then
overview+="\n$(translate 'LXC containers with NVIDIA passthrough:')\n"
local ctid lxc_ver ct_name
for ctid in "${NVIDIA_CONTAINERS[@]}"; do
lxc_ver=$(get_lxc_nvidia_version "$ctid")
ct_name=$(pct config "$ctid" 2>/dev/null | grep "^hostname:" | awk '{print $2}')
overview+=" \Zb\Z4CT ${ctid}\Zn ${ct_name:+(${ct_name})}$(translate 'driver:') ${lxc_ver}\n"
done
fi
overview+="\n$(translate 'After confirming, you will be asked to choose the NVIDIA driver version to install.')\n\n"
overview+="$(translate 'Do you want to continue?')"
hybrid_yesno "$(translate 'NVIDIA GPU Driver Installation')" "$overview" 22 90
hybrid_yesno "$(translate 'NVIDIA GPU Driver Installation')" "$overview" 24 90
}
show_version_menu() {
@@ -784,8 +1189,11 @@ main() {
: >"$LOG_FILE"
: >"$screen_capture"
NOUVEAU_STILL_LOADED=false
detect_nvidia_gpus
detect_driver_status
check_gpu_not_in_vm_passthrough
if ! $NVIDIA_GPU_PRESENT; then
dialog --backtitle "ProxMenux" --title "$(translate 'NVIDIA GPU Driver Installation')" --msgbox \
@@ -827,7 +1235,7 @@ main() {
show_proxmenux_logo
msg_title "$(translate "$SCRIPT_TITLE")"
msg_info2 "$(translate 'Uninstalling current NVIDIA driver before installing new version...')"
msg_info2 "$(translate 'Uninstalling current NVIDIA driver before installing new version')"
complete_nvidia_uninstall
sleep 2
@@ -846,18 +1254,20 @@ main() {
stop_and_disable_nvidia_services
unload_nvidia_modules
msg_info "$(translate 'Downloading NVIDIA driver version:') $DRIVER_VERSION"
# No msg_info spinner here — it would clash with wget --show-progress,
# which writes its progress bar directly to /dev/tty from inside the
# download function. Stderr from the function is allowed through so
# warnings/errors reach the user.
local installer
installer=$(download_nvidia_installer "$DRIVER_VERSION" 2>>"$LOG_FILE")
installer=$(download_nvidia_installer "$DRIVER_VERSION")
local download_result=$?
if [[ $download_result -ne 0 ]]; then
msg_error "$(translate 'Failed to download NVIDIA installer')"
exit 1
fi
msg_ok "$(translate 'NVIDIA installer downloaded successfully')"
msg_ok "$(translate 'NVIDIA installer downloaded successfully')" | tee -a "$screen_capture"
if [[ -z "$installer" || ! -f "$installer" ]]; then
msg_error "$(translate 'Internal error: NVIDIA installer path is empty or file not found.')"
@@ -901,6 +1311,13 @@ main() {
update_component_status "nvidia_driver" "failed" "" "gpu" '{"patched":false}'
fi
# Propagate the new driver to LXC containers with NVIDIA passthrough, if any.
# Uses the same .run installer cached in $NVIDIA_WORKDIR — runs only if the
# host install succeeded and the user confirms.
if [[ -n "$CURRENT_DRIVER_VERSION" ]]; then
offer_lxc_updates_if_any "$CURRENT_DRIVER_VERSION"
fi
apply_nvidia_patch_if_needed
restart_prompt
;;
-916
View File
@@ -1,916 +0,0 @@
#!/bin/bash
# ProxMenux - NVIDIA Driver Installer (PVE 9.x)
# ============================================
# Author : MacRimi
# License : MIT
# Version : 0.9 (PVE9, fixed download issues)
# Last Updated: 29/11/2025
# ============================================
SCRIPT_TITLE="NVIDIA GPU Driver Installer for Proxmox VE"
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
COMPONENTS_STATUS_FILE="$BASE_DIR/components_status.json"
LOG_FILE="/tmp/nvidia_install.log"
screen_capture="/tmp/proxmenux_nvidia_screen_capture_$$.txt"
NVIDIA_BASE_URL="https://download.nvidia.com/XFree86/Linux-x86_64"
NVIDIA_WORKDIR="/opt/nvidia"
export BASE_DIR
export COMPONENTS_STATUS_FILE
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
if [[ ! -f "$COMPONENTS_STATUS_FILE" ]]; then
echo "{}" > "$COMPONENTS_STATUS_FILE"
fi
load_language
initialize_cache
# ==========================================================
# GPU detection and current status
# ==========================================================
detect_nvidia_gpus() {
# Only video controllers (not audio)
local lspci_output
lspci_output=$(lspci | grep -i "NVIDIA" \
| grep -Ei "VGA compatible controller|3D controller|Display controller" || true)
if [[ -z "$lspci_output" ]]; then
NVIDIA_GPU_PRESENT=false
DETECTED_GPUS_TEXT="$(translate 'No NVIDIA GPU detected on this system.')"
else
NVIDIA_GPU_PRESENT=true
DETECTED_GPUS_TEXT=""
local i=1
while IFS= read -r line; do
DETECTED_GPUS_TEXT+=" ${i}. ${line}\n"
((i++))
done <<< "$lspci_output"
fi
}
detect_driver_status() {
CURRENT_DRIVER_INSTALLED=false
CURRENT_DRIVER_VERSION=""
# First check if nvidia kernel module is actually loaded
if lsmod | grep -q "^nvidia "; then
modprobe nvidia-uvm 2>/dev/null || true
sleep 1
if command -v nvidia-smi >/dev/null 2>&1; then
CURRENT_DRIVER_VERSION=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -n1)
if [[ -n "$CURRENT_DRIVER_VERSION" ]]; then
CURRENT_DRIVER_INSTALLED=true
# Register the installed driver version in components_status.json
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
fi
fi
fi
if $CURRENT_DRIVER_INSTALLED; then
CURRENT_STATUS_TEXT="$(printf '%s %s' "$(translate 'NVIDIA driver installed:')" "$CURRENT_DRIVER_VERSION")"
else
CURRENT_STATUS_TEXT="$(translate 'No NVIDIA driver installed.')"
fi
if $CURRENT_DRIVER_INSTALLED; then
CURRENT_STATUS_COLORED="\Z2${CURRENT_STATUS_TEXT}\Zn"
else
CURRENT_STATUS_COLORED="\Z3${CURRENT_STATUS_TEXT}\Zn"
fi
}
# ==========================================================
# System preparation (repos, headers, etc.)
# ==========================================================
ensure_repos_and_headers() {
msg_info "$(translate 'Checking kernel headers and build tools...')"
local kver
kver=$(uname -r)
apt-get update -qq >>"$LOG_FILE" 2>&1
if ! dpkg -s "pve-headers-$kver" >/dev/null 2>&1 && \
! dpkg -s "proxmox-headers-$kver" >/dev/null 2>&1; then
apt-get install -y "pve-headers-$kver" "proxmox-headers-$kver" build-essential dkms >>"$LOG_FILE" 2>&1 || true
else
apt-get install -y build-essential dkms >>"$LOG_FILE" 2>&1 || true
fi
msg_ok "$(translate 'Kernel headers and build tools verified.')" | tee -a "$screen_capture"
}
blacklist_nouveau() {
msg_info "$(translate 'Blacklisting nouveau driver...')"
if ! grep -q '^blacklist nouveau' /etc/modprobe.d/blacklist.conf 2>/dev/null; then
echo "blacklist nouveau" >> /etc/modprobe.d/blacklist.conf
fi
msg_ok "$(translate 'nouveau driver has been blacklisted.')" | tee -a "$screen_capture"
}
ensure_modules_config() {
msg_info "$(translate 'Configuring NVIDIA and VFIO modules...')"
cat > /etc/modules-load.d/nvidia-vfio.conf <<'EOF'
vfio
vfio_iommu_type1
vfio_pci
vfio_virqfd
nvidia
nvidia_uvm
EOF
msg_ok "$(translate 'Modules configuration updated.')" | tee -a "$screen_capture"
}
stop_and_disable_nvidia_services() {
local services=(
"nvidia-persistenced.service"
"nvidia-persistenced"
"nvidia-powerd.service"
)
local services_detected=0
for service in "${services[@]}"; do
if systemctl is-active --quiet "$service" 2>/dev/null || \
systemctl is-enabled --quiet "$service" 2>/dev/null; then
services_detected=1
break
fi
done
if [ "$services_detected" -eq 1 ]; then
msg_info "$(translate 'Stopping and disabling NVIDIA services...')"
for service in "${services[@]}"; do
if systemctl is-active --quiet "$service" 2>/dev/null; then
systemctl stop "$service" >/dev/null 2>&1 || true
fi
if systemctl is-enabled --quiet "$service" 2>/dev/null; then
systemctl disable "$service" >/dev/null 2>&1 || true
fi
done
sleep 2
msg_ok "$(translate 'NVIDIA services stopped and disabled.')" | tee -a "$screen_capture"
fi
}
unload_nvidia_modules() {
msg_info "$(translate 'Unloading NVIDIA kernel modules...')"
for mod in nvidia_uvm nvidia_drm nvidia_modeset nvidia; do
modprobe -r "$mod" >/dev/null 2>&1 || true
done
if lsmod | grep -qi '\bnvidia'; then
for mod in nvidia_uvm nvidia_drm nvidia_modeset nvidia; do
modprobe -r --force "$mod" >/dev/null 2>&1 || true
done
fi
if lsmod | grep -qi '\bnvidia'; then
msg_warn "$(translate 'Some NVIDIA modules could not be unloaded. Installation may fail. Ensure no processes are using the GPU.')"
if command -v lsof >/dev/null 2>&1; then
echo "$(translate 'Processes using NVIDIA:'):" >> "$LOG_FILE"
lsof /dev/nvidia* 2>/dev/null >> "$LOG_FILE" || true
fi
else
msg_ok "$(translate 'NVIDIA kernel modules unloaded successfully.')" | tee -a "$screen_capture"
fi
}
complete_nvidia_uninstall() {
stop_and_disable_nvidia_services
unload_nvidia_modules
if command -v nvidia-uninstall >/dev/null 2>&1; then
msg_info "$(translate 'Running NVIDIA uninstaller...')"
nvidia-uninstall --silent >>"$LOG_FILE" 2>&1 || true
msg_ok "$(translate 'NVIDIA uninstaller completed.')"
fi
cleanup_nvidia_dkms
msg_info "$(translate 'Removing NVIDIA packages...')"
apt-get -y purge 'nvidia-*' 'libnvidia-*' 'cuda-*' 'libcudnn*' >>"$LOG_FILE" 2>&1 || true
apt-get -y autoremove --purge >>"$LOG_FILE" 2>&1 || true
apt-get -y autoclean >>"$LOG_FILE" 2>&1 || true
rm -f /etc/modules-load.d/nvidia-vfio.conf
rm -f /etc/udev/rules.d/70-nvidia.rules
rm -rf /usr/lib/modprobe.d/nvidia*.conf
rm -rf /etc/modprobe.d/nvidia*.conf
if [[ -d "$NVIDIA_WORKDIR" ]]; then
find "$NVIDIA_WORKDIR" -type d -name "nvidia-persistenced" -exec rm -rf {} + 2>/dev/null || true
find "$NVIDIA_WORKDIR" -type d -name "nvidia-patch" -exec rm -rf {} + 2>/dev/null || true
fi
update_component_status "nvidia_driver" "removed" "" "gpu" '{}'
msg_ok "$(translate 'Complete NVIDIA uninstallation finished.')" | tee -a "$screen_capture"
}
cleanup_nvidia_dkms() {
local versions
versions=$(dkms status 2>/dev/null | awk -F, '/nvidia/ {gsub(/ /,"",$2); print $2}' || true)
[[ -z "$versions" ]] && return 0
msg_info "$(translate 'Removing NVIDIA DKMS entries...')"
while IFS= read -r ver; do
[[ -z "$ver" ]] && continue
dkms remove -m nvidia -v "$ver" --all >/dev/null 2>&1 || true
done <<< "$versions"
msg_ok "$(translate 'NVIDIA DKMS entries removed.')"
}
ensure_workdir() {
mkdir -p "$NVIDIA_WORKDIR"
}
# ==========================================================
# Kernel compatibility detection
# ==========================================================
get_kernel_compatibility_info() {
local kernel_version
kernel_version=$(uname -r)
# Determine Proxmox and kernel version
if [[ -f /etc/pve/.version ]]; then
PVE_VERSION=$(cat /etc/pve/.version)
else
PVE_VERSION="unknown"
fi
# Extract kernel major version (6.x, 5.x, etc)
KERNEL_MAJOR=$(echo "$kernel_version" | cut -d. -f1)
KERNEL_MINOR=$(echo "$kernel_version" | cut -d. -f2)
# Define minimum compatible versions based on kernel
# Based on https://docs.nvidia.com/datacenter/tesla/drivers/index.html
if [[ "$KERNEL_MAJOR" -ge 6 ]] && [[ "$KERNEL_MINOR" -ge 17 ]]; then
# Kernel 6.17+ (Proxmox 9.x) - Requires 580.82.07 or higher
MIN_DRIVER_VERSION="580.82.07"
RECOMMENDED_BRANCH="580"
COMPATIBILITY_NOTE="Kernel $kernel_version requires NVIDIA driver 580.82.07 or newer"
elif [[ "$KERNEL_MAJOR" -ge 6 ]] && [[ "$KERNEL_MINOR" -ge 8 ]]; then
# Kernel 6.8-6.16 (Proxmox 8.2+) - Works with 550.x or higher
MIN_DRIVER_VERSION="550"
RECOMMENDED_BRANCH="580"
COMPATIBILITY_NOTE="Kernel $kernel_version works best with NVIDIA driver 550.x or newer"
elif [[ "$KERNEL_MAJOR" -ge 6 ]]; then
# Kernel 6.2-6.7 (Proxmox 8.x initial) - Works with 535.x or higher
MIN_DRIVER_VERSION="535"
RECOMMENDED_BRANCH="550"
COMPATIBILITY_NOTE="Kernel $kernel_version works with NVIDIA driver 535.x or newer"
elif [[ "$KERNEL_MAJOR" -eq 5 ]] && [[ "$KERNEL_MINOR" -ge 15 ]]; then
# Kernel 5.15+ (Proxmox 7.x, 8.x legacy) - Works with 470.x or higher
MIN_DRIVER_VERSION="470"
RECOMMENDED_BRANCH="535"
COMPATIBILITY_NOTE="Kernel $kernel_version works with NVIDIA driver 470.x or newer"
else
# Old kernels
MIN_DRIVER_VERSION="450"
RECOMMENDED_BRANCH="470"
COMPATIBILITY_NOTE="For older kernels, compatibility may vary"
fi
}
is_version_compatible() {
local version="$1"
local ver_major ver_minor ver_patch
# Extract version components (major.minor.patch)
ver_major=$(echo "$version" | cut -d. -f1)
ver_minor=$(echo "$version" | cut -d. -f2)
ver_patch=$(echo "$version" | cut -d. -f3)
if [[ "$MIN_DRIVER_VERSION" == "580.82.07" ]]; then
# Compare full version: must be >= 580.82.07
if [[ ${ver_major} -gt 580 ]]; then
return 0
elif [[ ${ver_major} -eq 580 ]]; then
if [[ $((10#${ver_minor})) -gt 82 ]]; then
return 0
elif [[ $((10#${ver_minor})) -eq 82 ]]; then
if [[ $((10#${ver_patch:-0})) -ge 7 ]]; then
return 0
fi
fi
fi
return 1
fi
if [[ ${ver_major} -ge ${MIN_DRIVER_VERSION} ]]; then
return 0
else
return 1
fi
}
# ==========================================================
# NVIDIA version management - FIXED VERSION
# ==========================================================
download_latest_version() {
local latest_line version
latest_line=$(curl -fsSL "${NVIDIA_BASE_URL}/latest.txt" 2>&1)
if [[ -z "$latest_line" ]]; then
echo "" >&2
return 1
fi
version=$(echo "$latest_line" | awk '{print $1}' | tr -d '[:space:]')
if [[ -z "$version" ]]; then
echo "" >&2
return 1
fi
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
echo "" >&2
return 1
fi
echo "$version"
return 0
}
list_available_versions() {
local html_content versions
html_content=$(curl -s "$NVIDIA_BASE_URL/" 2>&1)
if [[ -z "$html_content" ]]; then
echo "" >&2
return 1
fi
versions=$(echo "$html_content" \
| grep -o 'href=[^ >]*' \
| awk -F"'" '{print $2}' \
| grep -E '^[0-9]' \
| sed 's/\/$//' \
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
| sort -Vr \
| uniq)
if [[ -z "$versions" ]]; then
echo "" >&2
return 1
fi
echo "$versions"
return 0
}
verify_version_exists() {
local version="$1"
local url="${NVIDIA_BASE_URL}/${version}/"
if curl -fsSL --head "$url" >/dev/null 2>&1; then
return 0
else
return 1
fi
}
download_nvidia_installer() {
ensure_workdir
local version="$1"
version=$(echo "$version" | tr -d '[:space:]' | tr -d '\n' | tr -d '\r')
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
msg_error "Invalid version format: $version" >&2
echo "ERROR: Invalid version format: '$version'" >> "$LOG_FILE"
return 1
fi
local run_file="$NVIDIA_WORKDIR/NVIDIA-Linux-x86_64-${version}.run"
if [[ -f "$run_file" ]]; then
echo "Found existing file: $run_file" >> "$LOG_FILE"
local existing_size file_type
existing_size=$(stat -c%s "$run_file" 2>/dev/null || stat -f%z "$run_file" 2>/dev/null || echo "0")
file_type=$(file "$run_file" 2>/dev/null || echo "unknown")
echo "Existing file size: $existing_size bytes" >> "$LOG_FILE"
echo "Existing file type: $file_type" >> "$LOG_FILE"
if [[ $existing_size -gt 40000000 ]] && echo "$file_type" | grep -q "executable"; then
if sh "$run_file" --check 2>&1 | tee -a "$LOG_FILE" | grep -q "OK"; then
echo "Existing file passed integrity check" >> "$LOG_FILE"
msg_ok "$(translate 'Installer already downloaded and verified.')" >&2
printf '%s\n' "$run_file"
return 0
else
echo "Existing file FAILED integrity check, removing..." >> "$LOG_FILE"
msg_warn "$(translate 'Existing file failed verification, re-downloading...')" >&2
rm -f "$run_file"
fi
else
echo "Existing file invalid (size or type), removing..." >> "$LOG_FILE"
msg_warn "$(translate 'Removing invalid existing file...')" >&2
rm -f "$run_file"
fi
fi
if ! verify_version_exists "$version"; then
msg_error "Version $version does not exist on NVIDIA servers" >&2
echo "ERROR: Version $version not found on server" >> "$LOG_FILE"
return 1
fi
local urls=(
"${NVIDIA_BASE_URL}/${version}/NVIDIA-Linux-x86_64-${version}.run"
"${NVIDIA_BASE_URL}/${version}/NVIDIA-Linux-x86_64-${version}-no-compat32.run"
)
local success=false
local url_index=0
for url in "${urls[@]}"; do
((url_index++))
echo "Attempting download from: $url" >> "$LOG_FILE"
rm -f "$run_file"
if curl -fL --connect-timeout 30 --max-time 600 "$url" -o "$run_file" >> "$LOG_FILE" 2>&1; then
echo "Download completed, verifying file..." >> "$LOG_FILE"
if [[ ! -f "$run_file" ]]; then
echo "ERROR: File not created after download" >> "$LOG_FILE"
continue
fi
local file_size
file_size=$(stat -c%s "$run_file" 2>/dev/null || stat -f%z "$run_file" 2>/dev/null || echo "0")
echo "Downloaded file size: $file_size bytes" >> "$LOG_FILE"
if [[ $file_size -lt 40000000 ]]; then
echo "ERROR: File too small ($file_size bytes, expected >40MB)" >> "$LOG_FILE"
head -c 200 "$run_file" >> "$LOG_FILE" 2>&1
rm -f "$run_file"
continue
fi
local file_type
file_type=$(file "$run_file" 2>/dev/null)
echo "File type: $file_type" >> "$LOG_FILE"
if echo "$file_type" | grep -q "executable"; then
echo "SUCCESS: Valid executable downloaded" >> "$LOG_FILE"
success=true
break
else
echo "ERROR: Not a valid executable" >> "$LOG_FILE"
head -c 200 "$run_file" | od -c >> "$LOG_FILE" 2>&1
rm -f "$run_file"
fi
else
echo "ERROR: curl failed for $url (exit code: $?)" >> "$LOG_FILE"
rm -f "$run_file"
fi
done
if ! $success; then
msg_error "$(translate 'Download failed for all attempted URLs')" >&2
msg_error "Version $version may not be available for your architecture" >&2
echo "ERROR: All download attempts failed" >> "$LOG_FILE"
return 1
fi
chmod +x "$run_file"
echo "Installation file ready: $run_file" >> "$LOG_FILE"
printf '%s\n' "$run_file"
}
# ==========================================================
# Installation / uninstallation
# ==========================================================
run_nvidia_installer() {
local installer="$1"
msg_info2 "$(translate 'Starting NVIDIA installer. This may take several minutes...')"
echo "" >>"$LOG_FILE"
echo "=== Running NVIDIA installer: $installer ===" >>"$LOG_FILE"
local tmp_extract_dir="$NVIDIA_WORKDIR/tmp_extract"
mkdir -p "$tmp_extract_dir"
sh "$installer" --tmpdir="$tmp_extract_dir" --no-questions --ui=none --disable-nouveau --dkms 2>&1 | tee -a "$LOG_FILE"
local rc=${PIPESTATUS[0]}
echo "" >>"$LOG_FILE"
rm -rf "$tmp_extract_dir"
if [[ $rc -ne 0 ]]; then
msg_error "$(translate 'NVIDIA installer reported an error. Check /tmp/nvidia_install.log')"
update_component_status "nvidia_driver" "failed" "" "gpu" '{"patched":false}'
return 1
fi
msg_ok "$(translate 'NVIDIA driver installed successfully.')" | tee -a "$screen_capture"
return 0
}
remove_nvidia_driver() {
complete_nvidia_uninstall
}
install_udev_rules_and_persistenced() {
msg_info "$(translate 'Installing NVIDIA udev rules and persistence service...')"
cat >/etc/udev/rules.d/70-nvidia.rules <<'EOF'
# /etc/udev/rules.d/70-nvidia.rules
KERNEL=="nvidia", RUN+="/bin/bash -c '/usr/bin/nvidia-smi -L'"
KERNEL=="nvidia_uvm", RUN+="/bin/bash -c '/usr/bin/nvidia-modprobe -c0 -u'"
EOF
udevadm control --reload-rules
udevadm trigger --subsystem-match=drm --subsystem-match=pci || true
ensure_workdir
cd "$NVIDIA_WORKDIR" || return 1
if [[ ! -d nvidia-persistenced ]]; then
git clone https://github.com/NVIDIA/nvidia-persistenced.git >>"$LOG_FILE" 2>&1 || true
fi
if [[ -d nvidia-persistenced/init ]]; then
cd nvidia-persistenced/init || return 1
./install.sh >>"$LOG_FILE" 2>&1 || true
fi
msg_ok "$(translate 'NVIDIA udev rules and persistence service installed.')" | tee -a "$screen_capture"
}
apply_nvidia_patch_if_needed() {
if ! whiptail --title "$(translate 'NVIDIA Patch')" --yesno \
"\n$(translate 'Do you want to apply the optional NVIDIA patch to remove some GPU limitations?')" 10 70; then
msg_info2 "$(translate 'NVIDIA patch not applied.')"
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
return 0
fi
msg_info "$(translate 'Cloning and applying NVIDIA patch (keylase/nvidia-patch)...')"
ensure_workdir
cd "$NVIDIA_WORKDIR" || return 1
if [[ ! -d nvidia-patch ]]; then
git clone https://github.com/keylase/nvidia-patch.git >>"$LOG_FILE" 2>&1 || true
fi
if [[ -x nvidia-patch/patch.sh ]]; then
cd nvidia-patch || return 1
./patch.sh >>"$LOG_FILE" 2>&1 || true
msg_ok "$(translate 'NVIDIA patch applied - check README for supported versions.')"
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":true}'
else
msg_warn "$(translate 'Could not run NVIDIA patch script. Please verify repository and driver version.')"
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
fi
}
restart_prompt() {
if whiptail --title "$(translate 'NVIDIA Drivers')" --yesno \
"\n$(translate 'The installation/changes require a server restart to apply correctly. Do you want to reboot now?')" 10 70; then
msg_success "$(translate 'Installation completed. Press Enter to continue...')"
read -r
msg_warn "$(translate 'Restarting the server...')"
rm -f "$screen_capture"
reboot
else
msg_success "$(translate 'Installation completed. Please reboot the server manually as soon as possible.')"
msg_success "$(translate 'Completed. Press Enter to return to menu...')"
read -r
rm -f "$screen_capture"
fi
}
# ==========================================================
# Dialog menus
# ==========================================================
show_action_menu_if_installed() {
if ! $CURRENT_DRIVER_INSTALLED; then
ACTION="install"
return 0
fi
local menu_choices=(
"install" "$(translate 'Reinstall/Update NVIDIA drivers')"
"remove" "$(translate 'Uninstall NVIDIA drivers and configuration')"
)
ACTION=$(dialog --clear --stdout \
--backtitle "ProxMenux" \
--title "$(translate 'NVIDIA GPU Driver Management')" \
--menu "$(translate 'Choose an action:')" 14 80 8 \
"${menu_choices[@]}") || ACTION="cancel"
}
show_install_overview() {
local overview
overview="\n$(translate 'This installation will:')\n\n"
overview+="$(translate 'Install NVIDIA proprietary drivers')\n"
overview+="$(translate 'Configure GPU passthrough with VFIO')\n"
overview+="$(translate 'Blacklist nouveau driver')\n"
overview+="$(translate 'Enable IOMMU support if not enabled')\n\n"
overview+="$(translate 'Detected GPU(s):')\n"
overview+="\Zb\Z4$DETECTED_GPUS_TEXT\Zn\n"
overview+="\n\Zn$(translate 'Current status: ') "
overview+="\Zb${CURRENT_STATUS_TEXT}\Zn\n\n"
overview+="$(translate 'After confirming, you will be asked to choose the NVIDIA driver version to install.')\n\n"
overview+="$(translate 'Do you want to continue?')"
dialog --colors --backtitle "ProxMenux" \
--title "$(translate 'NVIDIA GPU Driver Installation')" \
--yesno "$overview" 22 90
}
show_version_menu() {
local latest versions_list
local kernel_version
kernel_version=$(uname -r)
latest=$(download_latest_version 2>/dev/null)
versions_list=$(list_available_versions 2>/dev/null)
if [[ -z "$latest" ]] && [[ -z "$versions_list" ]]; then
dialog --backtitle "ProxMenux" --title "$(translate 'Error')" --msgbox \
"$(translate 'Could not retrieve versions list from NVIDIA. Please check your internet connection.')\n\nURL: ${NVIDIA_BASE_URL}" 10 80
DRIVER_VERSION="cancel"
return 1
fi
if [[ -z "$latest" ]] && [[ -n "$versions_list" ]]; then
latest=$(echo "$versions_list" | head -n1)
fi
if [[ -n "$latest" ]] && [[ -z "$versions_list" ]]; then
versions_list="$latest"
fi
# Clean latest version
latest=$(echo "$latest" | tr -d '[:space:]')
local filter=""
local selection
local choices
local current_list
local menu_text
while true; do
current_list="$versions_list"
if [[ -n "$MIN_DRIVER_VERSION" ]]; then
local filtered_list=""
while IFS= read -r ver; do
[[ -z "$ver" ]] && continue
if is_version_compatible "$ver"; then
filtered_list+="$ver"$'\n'
fi
done <<< "$current_list"
current_list="$filtered_list"
fi
if [[ -n "$filter" ]]; then
current_list=$(echo "$current_list" | grep "$filter" || true)
fi
menu_text="$(translate 'Select the NVIDIA driver version to install:')\n\n"
menu_text+="$(translate 'Use the filter entry to narrow the list. Latest available (recommended in most cases), or choose a specific version from the list.')"
choices=()
choices+=("latest" "$(translate 'Latest available') (${latest:-unknown})")
choices+=("" "")
choices+=("filter" "$(translate 'Filter versions')${filter:+: $filter}")
if [[ -n "$current_list" ]]; then
while IFS= read -r ver; do
[[ -z "$ver" ]] && continue
ver=$(echo "$ver" | tr -d '[:space:]')
[[ -z "$ver" ]] && continue
choices+=("$ver" "$ver")
done <<< "$current_list"
else
choices+=("" "$(translate 'No versions match the current filter')")
fi
selection=$(dialog --clear --stdout \
--backtitle "ProxMenux" \
--title "$(translate 'NVIDIA Driver Version')" \
--menu "$menu_text" 26 90 16 \
"${choices[@]}") || { DRIVER_VERSION="cancel"; return 1; }
case "$selection" in
"")
continue
;;
filter)
filter=$(dialog --clear --stdout \
--backtitle "ProxMenux" \
--title "$(translate 'Filter NVIDIA versions')" \
--inputbox "$(translate 'Enter a filter (e.g., 560, 570, 580). Leave empty to show all.')" 10 80 "$filter") || true
;;
latest)
DRIVER_VERSION="$latest"
DRIVER_VERSION=$(echo "$DRIVER_VERSION" | tr -d '[:space:]')
return 0
;;
*)
DRIVER_VERSION="$selection"
DRIVER_VERSION=$(echo "$DRIVER_VERSION" | tr -d '[:space:]')
return 0
;;
esac
done
}
# ==========================================================
# Main flow
# ==========================================================
main() {
: >"$LOG_FILE"
: >"$screen_capture"
detect_nvidia_gpus
detect_driver_status
if ! $NVIDIA_GPU_PRESENT; then
dialog --backtitle "ProxMenux" --title "$(translate 'NVIDIA GPU Driver Installation')" --msgbox \
"\n$(translate 'No NVIDIA GPU has been detected on this system. The installer will now exit.')" 20 70
exit 1
fi
show_action_menu_if_installed
case "$ACTION" in
install)
if ! show_install_overview; then
exit 0
fi
get_kernel_compatibility_info
show_version_menu
if [[ "$DRIVER_VERSION" == "cancel" || -z "$DRIVER_VERSION" ]]; then
exit 0
fi
if $CURRENT_DRIVER_INSTALLED; then
if [[ "$CURRENT_DRIVER_VERSION" == "$DRIVER_VERSION" ]]; then
if ! dialog --colors --backtitle "ProxMenux" --title "$(translate 'Same Version Detected')" --yesno \
"$(printf '\n\n\n%s \Zb%s\Zn\n\n%s' \
"$(translate 'Version')" "$DRIVER_VERSION" \
"$(translate 'is already installed. Do you want to reinstall it? This will perform a clean uninstall first.')")" 14 70; then
exit 0
fi
else
if ! dialog --colors --backtitle "ProxMenux" --title "$(translate 'Version Change Detected')" --yesno \
"$(printf '\n\n%s \Zb%s\Zn\n%s \Zb\Z4%s\Zn\n\n%s' \
"$(translate 'Current version:')" "$CURRENT_DRIVER_VERSION" \
"$(translate 'New version:')" "$DRIVER_VERSION" \
"$(translate 'The current driver will be completely uninstalled before installing the new version. Continue?')")" 20 70; then
exit 0
fi
fi
show_proxmenux_logo
msg_title "$(translate "$SCRIPT_TITLE")"
msg_info2 "$(translate 'Uninstalling current NVIDIA driver before installing new version...')"
complete_nvidia_uninstall
sleep 2
CURRENT_DRIVER_INSTALLED=false
CURRENT_DRIVER_VERSION=""
fi
show_proxmenux_logo
msg_title "$(translate "$SCRIPT_TITLE")"
ensure_repos_and_headers
blacklist_nouveau
ensure_modules_config
stop_and_disable_nvidia_services
unload_nvidia_modules
msg_info "$(translate 'Downloading NVIDIA driver version:') $DRIVER_VERSION"
local installer
installer=$(download_nvidia_installer "$DRIVER_VERSION" 2>>"$LOG_FILE")
local download_result=$?
if [[ $download_result -ne 0 ]]; then
msg_error "$(translate 'Failed to download NVIDIA installer')"
exit 1
fi
msg_ok "$(translate 'NVIDIA installer downloaded successfully')"
if [[ -z "$installer" || ! -f "$installer" ]]; then
msg_error "$(translate 'Internal error: NVIDIA installer path is empty or file not found.')"
rm -f "$screen_capture"
exit 1
fi
if ! run_nvidia_installer "$installer"; then
rm -f "$screen_capture"
exit 1
fi
sleep 2
show_proxmenux_logo
msg_title "$(translate "$SCRIPT_TITLE")"
cat "$screen_capture"
echo -e "${TAB}${GN}📄 $(translate "Log file")${CL}: ${BL}$LOG_FILE${CL}"
install_udev_rules_and_persistenced
msg_info "$(translate 'Updating initramfs for all kernels...')"
update-initramfs -u -k all >>"$LOG_FILE" 2>&1 || true
msg_ok "$(translate 'initramfs updated.')"
msg_info2 "$(translate 'Checking NVIDIA driver status with nvidia-smi')"
if command -v nvidia-smi >/dev/null 2>&1; then
nvidia-smi || true
CURRENT_DRIVER_VERSION=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -n1)
CURRENT_DRIVER_INSTALLED=true
else
msg_warn "$(translate 'nvidia-smi not found in PATH. Please verify the driver installation.')"
fi
if [[ -n "$CURRENT_DRIVER_VERSION" ]]; then
msg_ok "$(translate 'NVIDIA driver') $CURRENT_DRIVER_VERSION $(translate 'installed successfully.')"
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
msg_success "$(translate 'Driver installed successfully. Press Enter to continue...')"
read -r
else
msg_error "$(translate 'Failed to detect installed NVIDIA driver version.')"
update_component_status "nvidia_driver" "failed" "" "gpu" '{"patched":false}'
fi
apply_nvidia_patch_if_needed
restart_prompt
;;
remove)
if dialog --backtitle "ProxMenux" --title "$(translate 'NVIDIA Driver Uninstall')" --yesno \
"\n\n\n$(translate 'This will remove NVIDIA drivers and related configuration. Do you want to continue?')" 14 70; then
show_proxmenux_logo
msg_title "$(translate "$SCRIPT_TITLE")"
remove_nvidia_driver
msg_info "$(translate 'Updating initramfs for all kernels...')"
update-initramfs -u -k all >>"$LOG_FILE" 2>&1 || true
msg_ok "$(translate 'initramfs updated.')"
restart_prompt
fi
;;
cancel|*)
exit 0
;;
esac
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main
fi
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+95 -21
View File
@@ -303,7 +303,7 @@ show_storage_commands() {
15) cmd="lvs" ;;
16) cmd="cat /etc/pve/storage.cfg" ;;
17) cmd="pvesm status" ;;
19)
18)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter storage ID: ')${CL}"
read -r store
cmd="pvesm list $store"
@@ -591,42 +591,116 @@ show_update_commands() {
# ===============================================================
# 06 GPU Passthrough Commands
# 06 GPU/TPU Passthrough Commands
# ===============================================================
show_gpu_commands() {
while true; do
clear
echo -e "${YELLOW}$(translate 'GPU Passthrough Commands')${NC}"
echo "------------------------------------------------"
echo -e " 1) ${GREEN}lspci -nn | grep -i nvidia${NC} - $(translate 'List NVIDIA PCI devices')"
echo -e " 2) ${GREEN}lspci -nn | grep -i vga${NC} - $(translate 'List all VGA compatible devices')"
echo -e " 3) ${GREEN}dmesg | grep -i vfio${NC} - $(translate 'Check VFIO module messages')"
echo -e " 4) ${GREEN}cat /etc/modprobe.d/vfio.conf${NC} - $(translate 'Review VFIO passthrough configuration')"
echo -e " 5) ${GREEN}update-initramfs -u${NC} - $(translate 'Apply initramfs changes (VFIO)')"
echo -e " 6) ${GREEN}cat /etc/default/grub${NC} - $(translate 'Review GRUB options for IOMMU')"
echo -e " 7) ${GREEN}update-grub${NC} - $(translate 'Apply GRUB changes')"
echo -e "${YELLOW}$(translate 'GPU/TPU Passthrough Commands')${NC}"
echo -e "${TAB}${YW}$(translate 'Inspection commands run directly. Template commands [T] require parameter substitution.')${CL}"
echo "------------------------------------------------------------"
echo -e " 1) ${GREEN}lspci -nn | grep -iE 'VGA|3D|Display'${NC} - $(translate 'Detect GPUs in host')"
echo -e " 2) ${GREEN}lspci -nnk | grep -A3 -Ei 'VGA|3D'${NC} - $(translate 'Show GPU kernel driver in use')"
echo -e " 3) ${GREEN}cat /proc/cmdline${NC} - $(translate 'Check kernel params (IOMMU flags)')"
echo -e " 4) ${GREEN}dmesg -T | grep -Ei 'DMAR|IOMMU|vfio|pcie'${NC} - $(translate 'Inspect passthrough/kernel events')"
echo -e " 5) ${GREEN}find /sys/kernel/iommu_groups -type l${NC} - $(translate 'List IOMMU group mapping')"
echo -e " 6) ${GREEN}lsmod | grep -E 'vfio|nvidia|amdgpu|apex'${NC} - $(translate 'Check loaded GPU/TPU modules')"
echo -e " 7) ${GREEN}grep -R \"vfio-pci|blacklist\" /etc/modprobe.d${NC} - $(translate 'Review passthrough config files')"
echo -e " 8) ${GREEN}nvidia-smi${NC} - $(translate 'Check NVIDIA driver and devices')"
echo -e " 9) ${GREEN}qm config <vmid> | grep 'hostpci|bios'${NC} - [T] $(translate 'Check VM passthrough settings')"
echo -e "10) ${GREEN}pct config <ctid> | grep 'dev|lxc.cgroup2'${NC} - [T] $(translate 'Check LXC GPU/TPU mapping')"
echo -e "11) ${GREEN}ls -l /dev/dri /dev/kfd /dev/nvidia*${NC} - $(translate 'Inspect host device nodes')"
echo -e "12) ${GREEN}qm set <vmid> --hostpci<slot> <BDF>,pcie=1${NC} - [T] $(translate 'Assign GPU PCI function to VM')"
echo -e "13) ${GREEN}qm set <vmid> -delete hostpci<slot>${NC} - [T] $(translate 'Remove passthrough device from VM')"
echo -e "14) ${GREEN}qm set <vmid> -onboot 0${NC} - [T] $(translate 'Disable autostart on conflicting VM')"
echo -e "15) ${GREEN}sed -i '/GRUB_CMDLINE_LINUX_DEFAULT/ s|...|'${NC} - [T] $(translate 'Enable IOMMU in GRUB or ZFS boot')"
echo -e "16) ${GREEN}update-initramfs -u && proxmox-boot-tool${NC} - [T] $(translate 'Apply boot/initramfs changes')"
echo -e "17) ${GREEN}lsusb | grep Coral ; lspci | grep Unichip${NC} - $(translate 'Check Coral USB/M.2 detection')"
echo -e " ${DEF}0) $(translate ' Back to previous menu or Esc + Enter')"
echo
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter a number, or write or paste a command: ') ${CL}"
read -r user_input
# Check for Esc key press
if [[ "$user_input" == $'\x1b' ]]; then
break
fi
mode="exec"
case "$user_input" in
1) cmd="lspci -nn | grep -i nvidia" ;;
2) cmd="lspci -nn | grep -i vga" ;;
3) cmd="dmesg | grep -i vfio" ;;
4) cmd="cat /etc/modprobe.d/vfio.conf" ;;
5) cmd="update-initramfs -u" ;;
6) cmd="cat /etc/default/grub" ;;
7) cmd="update-grub" ;;
1) cmd="lspci -nn | grep -iE 'VGA compatible|3D controller|Display controller'" ;;
2) cmd="lspci -nnk | grep -A3 -Ei 'VGA compatible|3D controller|Display controller'" ;;
3) cmd="cat /proc/cmdline" ;;
4) cmd="dmesg -T | grep -Ei 'DMAR|IOMMU|vfio|pcie|AER|reset'" ;;
5) cmd="find /sys/kernel/iommu_groups -type l" ;;
6) cmd="lsmod | grep -E 'vfio|nvidia|amdgpu|i915|apex|gasket'" ;;
7) cmd="grep -R \"vfio-pci\\|blacklist .*nvidia\\|blacklist .*amdgpu\\|blacklist .*radeon\" /etc/modprobe.d /etc/modules /etc/default/grub /etc/kernel/cmdline 2>/dev/null" ;;
8) cmd="nvidia-smi" ;;
9)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"
read -r vmid
cmd="qm config $vmid | grep -E '^(hostpci|cpu:|machine:|bios:|args:|boot:)'"
;;
10)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter CT ID: ')${CL}"
read -r ctid
cmd="pct config $ctid | grep -E '^(dev[0-9]+:|lxc\\.cgroup2\\.devices\\.allow:|lxc\\.mount\\.entry:|features:)'"
;;
11) cmd="ls -l /dev/dri /dev/kfd /dev/nvidia* /dev/apex* 2>/dev/null" ;;
12)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter hostpci slot (e.g. 0): ')${CL}"; read -r slot
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter PCI BDF (e.g. 0000:01:00.0): ')${CL}"; read -r bdf
cmd="qm set $vmid --hostpci${slot} ${bdf},pcie=1"
mode="template"
;;
13)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter hostpci slot (e.g. 0): ')${CL}"; read -r slot
cmd="qm set $vmid -delete hostpci${slot}"
mode="template"
;;
14)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
cmd="qm set $vmid -onboot 0"
mode="template"
;;
15)
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Boot type (grub/zfs): ')${CL}"; read -r boot_type
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'CPU vendor (intel/amd): ')${CL}"; read -r cpu_vendor
case "$cpu_vendor" in
amd|AMD) iommu_param="amd_iommu=on iommu=pt" ;;
*) iommu_param="intel_iommu=on iommu=pt" ;;
esac
case "$boot_type" in
zfs|ZFS) cmd="sed -i 's/\$/ ${iommu_param}/' /etc/kernel/cmdline" ;;
*) cmd="sed -i '/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param}\"|' /etc/default/grub" ;;
esac
mode="template"
;;
16)
cmd="update-initramfs -u -k all && (proxmox-boot-tool refresh || update-grub)"
mode="template"
;;
17) cmd="lsusb | grep -Ei '18d1:9302|1a6e:089a' ; lspci | grep -i 'Global Unichip'" ;;
0) break ;;
*) cmd="$user_input" ;;
*)
if [[ -n "$user_input" ]]; then
cmd="$user_input"
else
continue
fi
;;
esac
if [[ "$mode" == "template" ]]; then
echo -e "\n${GREEN}$(translate 'Manual command template (copy/paste):')${NC}\n"
echo "$cmd"
echo
msg_success "$(translate 'Press ENTER to continue...')"
read -r tmp
continue
fi
echo -e "\n${GREEN}> $cmd${NC}\n"
bash -c "$cmd"
echo
@@ -913,7 +987,7 @@ show_tools_commands() {
while true; do
OPTION=$(dialog --stdout \
--title "$(translate 'Help and Info')" \
--menu "\n$(translate 'Select a category of useful commands:')" 20 70 9 \
--menu "$(translate 'Select a category of useful commands:')" 20 70 9 \
1 "$(translate 'Useful System Commands')" \
2 "$(translate 'VM and CT Management Commands')" \
3 "$(translate 'Storage and Disks Commands')" \
-194
View File
@@ -1,194 +0,0 @@
#!/bin/bash
# ==========================================================
# ProxMenux - A menu-driven script for Proxmox VE management
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.1
# Last Updated: 29/05/2025
# ==========================================================
# Description:
# This script automates the process of importing disk images into Proxmox VE virtual machines (VMs),
# making it easy to attach pre-existing disk files without manual configuration.
#
# Before running the script, ensure that disk images are available in /var/lib/vz/template/images/.
# The script scans this directory for compatible formats (.img, .qcow2, .vmdk, .raw) and lists the available files.
#
# Using an interactive menu, you can:
# - Select a VM to attach the imported disk.
# - Choose one or multiple disk images for import.
# - Pick a storage volume in Proxmox for disk placement.
# - Assign a suitable interface (SATA, SCSI, VirtIO, or IDE).
# - Enable optional settings like SSD emulation or bootable disk configuration.
#
# Once completed, the script ensures the selected images are correctly attached and ready to use.
# ==========================================================
# Configuration ============================================
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
[[ -f "$UTILS_FILE" ]] && source "$UTILS_FILE"
load_language
initialize_cache
# Configuration ============================================
detect_image_dir() {
for store in $(pvesm status -content images | awk 'NR>1 {print $1}'); do
path=$(pvesm path "${store}:template" 2>/dev/null)
if [[ -d "$path" ]]; then
for ext in raw img qcow2 vmdk; do
if compgen -G "$path/*.$ext" > /dev/null; then
echo "$path"
return 0
fi
done
for sub in images iso; do
dir="$path/$sub"
if [[ -d "$dir" ]]; then
for ext in raw img qcow2 vmdk; do
if compgen -G "$dir/*.$ext" > /dev/null; then
echo "$dir"
return 0
fi
done
fi
done
fi
done
for fallback in /var/lib/vz/template/images /var/lib/vz/template/iso; do
if [[ -d "$fallback" ]]; then
for ext in raw img qcow2 vmdk; do
if compgen -G "$fallback/*.$ext" > /dev/null; then
echo "$fallback"
return 0
fi
done
fi
done
return 1
}
IMAGES_DIR=$(detect_image_dir)
if [[ -z "$IMAGES_DIR" ]]; then
dialog --title "$(translate 'No Images Found')" \
--msgbox "$(translate 'Could not find any directory containing disk images')\n\n$(translate 'Make sure there is at least one file with extension .img, .qcow2, .vmdk or .raw')" 15 60
exit 1
fi
IMAGES=$(ls -A "$IMAGES_DIR" | grep -E "\.(img|qcow2|vmdk|raw)$")
if [ -z "$IMAGES" ]; then
dialog --title "$(translate 'No Disk Images Found')" \
--msgbox "$(translate 'No compatible disk images found in:')\n\n$IMAGES_DIR\n\n$(translate 'Supported formats: .img, .qcow2, .vmdk, .raw')" 15 60
exit 1
fi
# === Select VM
msg_info "$(translate 'Getting VM list')"
VM_LIST=$(qm list | awk 'NR>1 {print $1" "$2}')
[[ -z "$VM_LIST" ]] && { msg_error "$(translate 'No VMs available in the system')"; exit 1; }
msg_ok "$(translate 'VM list obtained')"
VMID=$(whiptail --title "$(translate 'Select VM')" \
--menu "$(translate 'Select the VM where you want to import the disk image:')" 20 70 10 $VM_LIST 3>&1 1>&2 2>&3)
[[ -z "$VMID" ]] && exit 1
# === Select storage
msg_info "$(translate 'Getting storage volumes')"
STORAGE_LIST=$(pvesm status -content images | awk 'NR>1 {print $1}')
[[ -z "$STORAGE_LIST" ]] && { msg_error "$(translate 'No storage volumes available')"; exit 1; }
msg_ok "$(translate 'Storage volumes obtained')"
STORAGE_OPTIONS=()
while read -r storage; do STORAGE_OPTIONS+=("$storage" ""); done <<< "$STORAGE_LIST"
STORAGE=$(whiptail --title "$(translate 'Select Storage')" \
--menu "$(translate 'Select the storage volume for disk import:')" 20 70 10 "${STORAGE_OPTIONS[@]}" 3>&1 1>&2 2>&3)
[[ -z "$STORAGE" ]] && exit 1
# === Select images
IMAGE_OPTIONS=()
while read -r img; do IMAGE_OPTIONS+=("$img" "" "OFF"); done <<< "$IMAGES"
SELECTED_IMAGES=$(whiptail --title "$(translate 'Select Disk Images')" \
--checklist "$(translate 'Select the disk images to import:')" 20 70 12 "${IMAGE_OPTIONS[@]}" 3>&1 1>&2 2>&3)
[[ -z "$SELECTED_IMAGES" ]] && exit 1
# === Import each selected image
for IMAGE in $SELECTED_IMAGES; do
IMAGE=$(echo "$IMAGE" | tr -d '"')
INTERFACE=$(whiptail --title "$(translate 'Interface Type')" --menu "$(translate 'Select the interface type for the image:') $IMAGE" 15 40 4 \
"sata" "SATA" "scsi" "SCSI" "virtio" "VirtIO" "ide" "IDE" 3>&1 1>&2 2>&3)
[[ -z "$INTERFACE" ]] && { msg_error "$(translate 'No interface type selected for') $IMAGE"; continue; }
FULL_PATH="$IMAGES_DIR/$IMAGE"
msg_info "$(translate 'Importing image:') $IMAGE"
TEMP_DISK_FILE=$(mktemp)
qm importdisk "$VMID" "$FULL_PATH" "$STORAGE" 2>&1 | while read -r line; do
if [[ "$line" =~ transferred ]]; then
PERCENT=$(echo "$line" | grep -oP "\(\d+\.\d+%\)" | tr -d '()%')
echo -ne "\r${TAB}${BL}-$(translate 'Importing image:') $IMAGE-${CL} ${PERCENT}%"
elif [[ "$line" =~ successfully\ imported\ disk ]]; then
echo "$line" | grep -oP "(?<=successfully imported disk ').*(?=')" > "$TEMP_DISK_FILE"
fi
done
echo -ne "\n"
IMPORT_STATUS=${PIPESTATUS[0]}
if [ "$IMPORT_STATUS" -eq 0 ]; then
msg_ok "$(translate 'Image imported successfully')"
IMPORTED_DISK=$(cat "$TEMP_DISK_FILE")
rm -f "$TEMP_DISK_FILE"
if [ -n "$IMPORTED_DISK" ]; then
EXISTING_DISKS=$(qm config "$VMID" | grep -oP "${INTERFACE}\d+" | sort -n)
NEXT_SLOT=0
[[ -n "$EXISTING_DISKS" ]] && NEXT_SLOT=$(( $(echo "$EXISTING_DISKS" | tail -n1 | sed "s/${INTERFACE}//") + 1 ))
SSD_OPTION=""
if [ "$INTERFACE" != "virtio" ]; then
whiptail --yesno "$(translate 'Do you want to use SSD emulation for this disk?')" 10 60 && SSD_OPTION=",ssd=1"
fi
msg_info "$(translate 'Configuring disk')"
if qm set "$VMID" --${INTERFACE}${NEXT_SLOT} "$IMPORTED_DISK${SSD_OPTION}" &>/dev/null; then
msg_ok "$(translate 'Image') $IMAGE $(translate 'configured as') ${INTERFACE}${NEXT_SLOT}"
whiptail --yesno "$(translate 'Do you want to make this disk bootable?')" 10 60 && {
msg_info "$(translate 'Configuring disk as bootable')"
if qm set "$VMID" --boot c --bootdisk ${INTERFACE}${NEXT_SLOT} &>/dev/null; then
msg_ok "$(translate 'Disk configured as bootable')"
else
msg_error "$(translate 'Could not configure the disk as bootable')"
fi
}
else
msg_error "$(translate 'Could not configure disk') ${INTERFACE}${NEXT_SLOT} $(translate 'for VM') $VMID"
fi
else
msg_error "$(translate 'Could not find the imported disk')"
fi
else
msg_error "$(translate 'Could not import') $IMAGE"
fi
done
msg_ok "$(translate 'All selected images have been processed')"
msg_success "$(translate "Press Enter to return to menu...")"
read -r
-256
View File
@@ -1,256 +0,0 @@
#!/bin/bash
# ==========================================================
# ProxMenux - A menu-driven script for Proxmox VE management
# ==========================================================
# Author : MacRimi
# Revision : @Blaspt (USB passthrough via udev rule with persistent /dev/coral)
# Copyright : (c) 2024 MacRimi
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.1
# Last Updated: 16/05/2025
# ==========================================================
# Description:
# This script automates the configuration and installation of
# Coral TPU and iGPU support in Proxmox VE containers. It:
# - Configures a selected LXC container for hardware acceleration
# - Installs and sets up Coral TPU drivers on the Proxmox host
# - Installs necessary drivers inside the container
# - Manages required system and container restarts
#
# Supports Coral USB and Coral M.2 (PCIe) devices.
# Includes USB passthrough enhancement using persistent udev alias (/dev/coral).
# ==========================================================
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
load_language
initialize_cache
# ==========================================================
select_container() {
CONTAINERS=$(pct list | awk 'NR>1 {print $1, $3}' | xargs -n2)
if [ -z "$CONTAINERS" ]; then
msg_error "$(translate 'No containers available in Proxmox.')"
exit 1
fi
CONTAINER_ID=$(whiptail --title "$(translate 'Select Container')" \
--menu "$(translate 'Select the LXC container:')" 20 70 10 $CONTAINERS 3>&1 1>&2 2>&3)
if [ -z "$CONTAINER_ID" ]; then
msg_error "$(translate 'No container selected. Exiting.')"
exit 1
fi
if ! pct list | awk 'NR>1 {print $1}' | grep -qw "$CONTAINER_ID"; then
msg_error "$(translate 'Container with ID') $CONTAINER_ID $(translate 'does not exist. Exiting.')"
exit 1
fi
msg_ok "$(translate 'Container selected:') $CONTAINER_ID"
}
validate_container_id() {
if [ -z "$CONTAINER_ID" ]; then
msg_error "$(translate 'Container ID not defined. Make sure to select a container first.')"
exit 1
fi
if pct status "$CONTAINER_ID" | grep -q "running"; then
msg_info "$(translate 'Stopping the container before applying configuration...')"
pct stop "$CONTAINER_ID"
msg_ok "$(translate 'Container stopped.')"
fi
}
add_udev_rule_for_coral_usb_() {
RULE_FILE="/etc/udev/rules.d/99-coral-usb.rules"
RULE_CONTENT='SUBSYSTEM=="usb", ATTRS{idVendor}=="18d1", ATTRS{idProduct}=="9302", MODE="0666", TAG+="uaccess"'
if [[ ! -f "$RULE_FILE" ]] || ! grep -qF "$RULE_CONTENT" "$RULE_FILE"; then
echo "$RULE_CONTENT" > "$RULE_FILE"
udevadm control --reload-rules && udevadm trigger
msg_ok "$(translate 'Udev rule for Coral USB added and rules reloaded.')"
else
msg_ok "$(translate 'Udev rule for Coral USB already exists.')"
fi
}
add_udev_rule_for_coral_usb() {
RULE_FILE="/etc/udev/rules.d/99-coral-usb.rules"
RULE_CONTENT='# Coral USB Accelerator
SUBSYSTEM=="usb", ATTRS{idVendor}=="18d1", ATTRS{idProduct}=="9302", MODE="0666", TAG+="uaccess", SYMLINK+="coral"
# Coral Dev Board / Mini PCIe
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a6e", ATTRS{idProduct}=="089a", MODE="0666", TAG+="uaccess", SYMLINK+="coral"'
if [[ ! -f "$RULE_FILE" ]] || ! grep -q "18d1.*9302\|1a6e.*089a" "$RULE_FILE"; then
echo "$RULE_CONTENT" > "$RULE_FILE"
udevadm control --reload-rules && udevadm trigger
msg_ok "$(translate 'Udev rules for Coral USB devices added and rules reloaded.')"
else
msg_ok "$(translate 'Udev rules for Coral USB devices already exist.')"
fi
}
add_mount_if_needed() {
local DEVICE="$1"
local DEST="$2"
local CONFIG_FILE="$3"
if [ -e "$DEVICE" ] && ! grep -q "lxc.mount.entry: $DEVICE" "$CONFIG_FILE"; then
echo "lxc.mount.entry: $DEVICE $DEST none bind,optional,create=$( [ -c "$DEVICE" ] && echo file || echo dir )" >> "$CONFIG_FILE"
fi
}
configure_lxc_hardware() {
validate_container_id
CONFIG_FILE="/etc/pve/lxc/${CONTAINER_ID}.conf"
if [ ! -f "$CONFIG_FILE" ]; then
msg_error "$(translate 'Configuration file for container') $CONTAINER_ID $(translate 'not found.')"
exit 1
fi
# Privileged container
if grep -q "^unprivileged: 1" "$CONFIG_FILE"; then
msg_info "$(translate 'The container is unprivileged. Changing to privileged...')"
sed -i "s/^unprivileged: 1/unprivileged: 0/" "$CONFIG_FILE"
STORAGE_TYPE=$(pct config "$CONTAINER_ID" | grep "^rootfs:" | awk -F, '{print $2}' | cut -d'=' -f2)
if [[ "$STORAGE_TYPE" == "dir" ]]; then
STORAGE_PATH=$(pct config "$CONTAINER_ID" | grep "^rootfs:" | awk '{print $2}' | cut -d',' -f1)
chown -R root:root "$STORAGE_PATH"
fi
msg_ok "$(translate 'Container changed to privileged.')"
else
msg_ok "$(translate 'The container is already privileged.')"
fi
sed -i '/^dev[0-9]\+:/d' "$CONFIG_FILE"
# Enable nesting feature
if ! grep -q "features: nesting=1" "$CONFIG_FILE"; then
echo "features: nesting=1" >> "$CONFIG_FILE"
fi
# iGPU support
if ! grep -q "c 226:0 rwm" "$CONFIG_FILE"; then
echo "lxc.cgroup2.devices.allow: c 226:0 rwm # iGPU" >> "$CONFIG_FILE"
echo "lxc.cgroup2.devices.allow: c 226:128 rwm # iGPU" >> "$CONFIG_FILE"
fi
add_mount_if_needed "/dev/dri" "dev/dri" "$CONFIG_FILE"
add_mount_if_needed "/dev/dri/renderD128" "dev/dri/renderD128" "$CONFIG_FILE"
add_mount_if_needed "/dev/dri/card0" "dev/dri/card0" "$CONFIG_FILE"
# Framebuffer support
if ! grep -q "c 29:0 rwm # Framebuffer" "$CONFIG_FILE"; then
echo "lxc.cgroup2.devices.allow: c 29:0 rwm # Framebuffer" >> "$CONFIG_FILE"
fi
add_mount_if_needed "/dev/fb0" "dev/fb0" "$CONFIG_FILE"
# ----------------------------------------------------------
# Coral USB passthrough (via udev + /dev/coral)
# ----------------------------------------------------------
add_udev_rule_for_coral_usb
if ! grep -Pq "^lxc.cgroup2.devices.allow: c 189:\* rwm # Coral USB$" "$CONFIG_FILE"; then
echo "lxc.cgroup2.devices.allow: c 189:* rwm # Coral USB" >> "$CONFIG_FILE"
fi
add_mount_if_needed "/dev/coral" "dev/coral" "$CONFIG_FILE"
# ----------------------------------------------------------
# Coral M.2 (PCIe) support
# ----------------------------------------------------------
if lspci | grep -iq "Global Unichip"; then
if ! grep -Pq "^lxc.cgroup2.devices.allow: c 245:0 rwm # Coral M2 Apex$" "$CONFIG_FILE"; then
echo "lxc.cgroup2.devices.allow: c 245:0 rwm # Coral M2 Apex" >> "$CONFIG_FILE"
fi
add_mount_if_needed "/dev/apex_0" "dev/apex_0" "$CONFIG_FILE"
fi
msg_ok "$(translate 'Coral TPU and iGPU configuration added to container') $CONTAINER_ID."
}
install_coral_in_container() {
msg_info2 "$(translate 'Installing iGPU and Coral TPU drivers inside the container...')"
tput sc
LOG_FILE=$(mktemp)
pct start "$CONTAINER_ID"
CORAL_M2=$(lspci | grep -i "Global Unichip")
if [[ -n "$CORAL_M2" ]]; then
DRIVER_OPTION=$(whiptail --title "$(translate 'Select driver version')" \
--menu "$(translate 'Choose the driver version for Coral M.2:\n\nCaution: Maximum mode generates more heat.')" 15 60 2 \
1 "libedgetpu1-std ($(translate 'standard performance'))" \
2 "libedgetpu1-max ($(translate 'maximum performance'))" 3>&1 1>&2 2>&3)
case "$DRIVER_OPTION" in
1) DRIVER_PACKAGE="libedgetpu1-std" ;;
2) DRIVER_PACKAGE="libedgetpu1-max" ;;
*) DRIVER_PACKAGE="libedgetpu1-std" ;;
esac
else
DRIVER_PACKAGE="libedgetpu1-std"
fi
script -q -c "pct exec \"$CONTAINER_ID\" -- bash -c '
set -e
echo \"- Updating package lists...\"
apt-get update
echo \"- Installing iGPU drivers...\"
apt-get install -y va-driver-all ocl-icd-libopencl1 intel-opencl-icd vainfo intel-gpu-tools
chgrp video /dev/dri && chmod 755 /dev/dri
adduser root video && adduser root render
echo \"- Installing Coral TPU dependencies...\"
apt-get install -y gnupg python3 python3-pip python3-venv
echo \"- Adding Coral TPU repository...\"
curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/coral-edgetpu.gpg
echo \"deb [signed-by=/usr/share/keyrings/coral-edgetpu.gpg] https://packages.cloud.google.com/apt coral-edgetpu-stable main\" | tee /etc/apt/sources.list.d/coral-edgetpu.list
echo \"- Updating package lists again...\"
apt-get update
echo \"- Installing Coral TPU driver ($DRIVER_PACKAGE)...\"
apt-get install -y $DRIVER_PACKAGE
'" "$LOG_FILE"
if [ $? -eq 0 ]; then
tput rc
tput ed
rm -f "$LOG_FILE"
msg_ok "$(translate 'iGPU and Coral TPU drivers installed inside the container.')"
else
msg_error "$(translate 'Failed to install iGPU and Coral TPU drivers inside the container.')"
cat "$LOG_FILE"
rm -f "$LOG_FILE"
exit 1
fi
}
select_container
show_proxmenux_logo
configure_lxc_hardware
install_coral_in_container
msg_ok "$(translate 'Configuration completed.')"
echo -e
msg_success "$(translate "Press Enter to return to menu...")"
read -r

Some files were not shown because too many files have changed in this diff Show More