1416 Commits

Author SHA1 Message Date
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 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
github-actions[bot] 46b9309336 Update AppImage beta build (2026-03-20 22:56:56) 2026-03-20 22:56:56 +00:00
MacRimi e60d2db8cb Update verified_ai_models.json 2026-03-20 23:48:07 +01:00
MacRimi e5e6c00100 Update notification service 2026-03-20 23:40:17 +01:00
MacRimi 40709b7480 Update flask_notification_routes.py 2026-03-20 23:29:25 +01:00
MacRimi 900c7154b6 Update notification service 2026-03-20 23:21:00 +01:00
MacRimi 2f4ea02544 Update notification service 2026-03-20 22:18:56 +01:00
MacRimi c24c10a13a Update notification service 2026-03-20 21:45:19 +01:00
MacRimi 22cd2e4bb3 Update notification service 2026-03-20 20:33:41 +01:00
github-actions[bot] 53ac43eb49 Update AppImage beta build (2026-03-20 19:13:03) 2026-03-20 19:13:03 +00:00
MacRimi fa29c46a95 Update notification service 2026-03-20 20:11:08 +01:00
MacRimi d871b4c78e Update notification_templates.py 2026-03-20 20:04:38 +01:00
MacRimi 03acdce2c8 Update beta_version.txt 2026-03-20 19:57:58 +01:00
github-actions[bot] 9aa3b61efc Update AppImage beta build (2026-03-20 18:56:21) 2026-03-20 18:56:21 +00:00
MacRimi 0a62e3deca Update notification_templates.py 2026-03-20 19:48:14 +01:00
MacRimi f454d5f045 Update notification_templates.py 2026-03-20 19:38:45 +01:00
MacRimi a5dca65e57 Update notification_templates.py 2026-03-20 19:20:51 +01:00
MacRimi 979a7e5d18 Update notification_templates.py 2026-03-20 19:16:36 +01:00
ProxMenuxBot d1e7154040 Update helpers_cache.json 2026-03-20 18:10:20 +00:00
MacRimi 4750ff8cd5 Update notification_templates.py 2026-03-20 19:03:04 +01:00
MacRimi 72d02010c7 Update notification service 2026-03-20 18:45:35 +01:00
MacRimi b49be42f2d Update notification_templates.py 2026-03-20 18:37:31 +01:00
MacRimi eddc183b85 Update notification_templates.py 2026-03-20 18:07:54 +01:00
MacRimi 812cf83de4 Update notification_templates.py 2026-03-20 17:46:02 +01:00
MacRimi 502cb8403f Update notification_templates.py 2026-03-20 17:09:32 +01:00
github-actions[bot] cf78bff21d Update AppImage beta build (2026-03-20 15:28:37) 2026-03-20 15:28:37 +00:00
MacRimi 1e352f4a7e Update notification service 2026-03-20 16:26:30 +01:00
MacRimi 47be85fdc0 Update notification_templates.py 2026-03-20 14:13:22 +01:00
MacRimi 5c9849e729 Update notification_templates.py 2026-03-20 13:55:47 +01:00
ProxMenuxBot e695b4e764 Update helpers_cache.json 2026-03-20 12:08:24 +00:00
github-actions[bot] d4d2e33619 Update AppImage beta build (2026-03-20 11:08:38) 2026-03-20 11:08:38 +00:00
MacRimi 1603f1ae66 Update notification service 2026-03-20 11:44:46 +01:00
MacRimi 88da476249 Update notification service 2026-03-20 11:26:26 +01:00
github-actions[bot] 1218fde6ea Update AppImage beta build (2026-03-19 20:30:24) 2026-03-19 20:30:24 +00:00
MacRimi 5046398e80 Update notification_templates.py 2026-03-19 21:28:27 +01:00
github-actions[bot] 20e5b6cc5a Update AppImage beta build (2026-03-19 19:47:33) 2026-03-19 19:47:33 +00:00
MacRimi c867c4ef51 Update ollama_provider.py 2026-03-19 20:45:35 +01:00
github-actions[bot] f2e804783b Update AppImage beta build (2026-03-19 19:22:23) 2026-03-19 19:22:23 +00:00
MacRimi 817e18ded2 Update ollama_provider.py 2026-03-19 20:20:11 +01:00
github-actions[bot] f99b498608 Update AppImage beta build (2026-03-19 19:11:56) 2026-03-19 19:11:56 +00:00
MacRimi d6cd4763f5 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-19 20:09:39 +01:00
MacRimi fdac846ede update ollama modal 2026-03-19 20:09:26 +01:00
github-actions[bot] d75c73df30 Update AppImage beta build (2026-03-19 19:00:26) 2026-03-19 19:00:27 +00:00
MacRimi 55c8dffe37 Update notification-settings.tsx 2026-03-19 19:58:17 +01:00
github-actions[bot] 8b35861602 Update AppImage beta build (2026-03-19 18:48:01) 2026-03-19 18:48:01 +00:00
MacRimi 6db7e64ca9 Update notification-settings.tsx 2026-03-19 19:42:42 +01:00
MacRimi 9484f78fb6 Update notification-settings.tsx 2026-03-19 19:36:39 +01:00
github-actions[bot] 1949aeb10f Update AppImage beta build (2026-03-19 18:20:31) 2026-03-19 18:20:31 +00:00
MacRimi 65fad4cc37 Create switch.tsx 2026-03-19 19:11:32 +01:00
MacRimi 876194cdc8 update storage settings 2026-03-19 19:07:26 +01:00
MacRimi cefeac72fc Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-19 17:56:57 +01:00
MacRimi 71708c3874 Update notification-settings.tsx 2026-03-19 17:56:51 +01:00
ProxMenuxBot b1eae7b768 Update helpers_cache.json 2026-03-19 12:09:20 +00:00
github-actions[bot] f02985e367 Update AppImage beta build (2026-03-19 09:11:42) 2026-03-19 09:11:42 +00:00
MacRimi 2848f672c1 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-19 10:08:11 +01:00
MacRimi f2210946c2 update ollama model 2026-03-19 10:08:08 +01:00
github-actions[bot] 4b79b9a417 Update AppImage beta build (2026-03-19 08:20:27) 2026-03-19 08:20:27 +00:00
MacRimi 10f8735f55 Update notification service 2026-03-19 09:18:14 +01:00
MacRimi 19a3a14417 Delete AppImage/ProxMenux-1.0.2.AppImage 2026-03-18 23:07:40 +01:00
github-actions[bot] aeaea1289c Update AppImage beta build (2026-03-18 22:05:40) 2026-03-18 22:05:40 +00:00
MacRimi 014ffa9b74 Update release-notes-modal.tsx 2026-03-18 23:02:18 +01:00
github-actions[bot] 0a9efe0122 Update AppImage beta build (2026-03-18 21:43:41) 2026-03-18 21:43:41 +00:00
MacRimi 82082a4b89 Update v1.0.2-beta 2026-03-18 22:41:39 +01: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
MacRimi fd399edce7 Update README.md 2026-03-18 22:13:03 +01:00
MacRimi 5611b69ad2 Update README.md 2026-03-18 22:06:24 +01:00
MacRimi 7b181046d3 Update beta_version.txt 2026-03-18 21:56:26 +01:00
MacRimi b593d50b9a Update beta_version.txt 2026-03-18 21:55:49 +01:00
github-actions[bot] 8fe9426f5c Update AppImage beta build (2026-03-18 20:53:59) 2026-03-18 20:53:59 +00:00
MacRimi 0ce5f72df4 Update install_proxmenux_beta.sh 2026-03-18 21:47:24 +01:00
MacRimi d88c6570d0 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-18 21:24:33 +01:00
MacRimi 2980f7c9b8 Update menu 2026-03-18 21:24:18 +01:00
github-actions[bot] 7b2825e5ce Update AppImage beta build (2026-03-18 20:18:13) 2026-03-18 20:18:13 +00:00
MacRimi e47f79bcd4 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-18 21:15:49 +01:00
MacRimi 415020bf5d Beta Program Installer 2026-03-18 21:15:34 +01:00
github-actions[bot] 6ab1582db8 Update AppImage beta build (2026-03-18 20:05:10) 2026-03-18 20:05:10 +00:00
MacRimi 751a02aae8 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-18 21:02:46 +01:00
MacRimi bee637aa48 Beta Program Installer 2026-03-18 21:02:42 +01:00
github-actions[bot] ad6c7ffda8 Update AppImage beta build (2026-03-18 19:12:58) 2026-03-18 19:12:58 +00:00
MacRimi 7f59ca37f2 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-18 20:10:50 +01:00
MacRimi 9056964bb9 Update notification_templates.py 2026-03-18 20:10:48 +01:00
github-actions[bot] 154441bc1b Update AppImage beta build (2026-03-18 18:34:56) 2026-03-18 18:34:56 +00:00
MacRimi 873ec75586 update notification service 2026-03-18 19:32:38 +01:00
ProxMenuxBot e8232a9ea0 Update helpers_cache.json 2026-03-18 18:18:46 +00:00
MacRimi 751b361528 Update notification_templates.py 2026-03-18 19:18:27 +01:00
MacRimi 8990a3e243 Update notification_templates.py 2026-03-18 19:08:48 +01:00
MacRimi bc44490e96 Update notification_templates.py 2026-03-18 18:59:46 +01:00
MacRimi fe2d0b1d2a Update notification_templates.py 2026-03-18 18:43:05 +01:00
MacRimi bf3c5c1602 Update notification_templates.py 2026-03-18 18:37:55 +01:00
github-actions[bot] 6364931322 Update AppImage beta build (2026-03-18 16:52:23) 2026-03-18 16:52:23 +00:00
MacRimi 536d7141d9 Add files via upload 2026-03-18 17:50:13 +01:00
MacRimi 69e0bfe89a update notification service 2026-03-18 17:48:02 +01: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
MacRimi c13c7ba626 Update notification service 2026-03-18 09:36:05 +01:00
ProxMenuxBot ef041f2702 Update helpers_cache.json 2026-03-18 00:17:00 +00:00
MacRimi 46b222180a Update notification-settings.tsx 2026-03-17 20:43:33 +01:00
MacRimi cd69b317c0 Update notification_manager.py 2026-03-17 20:41:09 +01:00
MacRimi 05fa751137 Update notification_templates.py 2026-03-17 20:34:55 +01:00
MacRimi 820317b9bd Update notification_manager.py 2026-03-17 20:25:19 +01:00
MacRimi bce01ad7a1 Update notification_templates.py 2026-03-17 20:05:23 +01:00
MacRimi bbe014798e Update notification_templates.py 2026-03-17 19:52:25 +01:00
MacRimi beea4dea04 Update notification_templates.py 2026-03-17 19:43:26 +01:00
MacRimi 71505362b4 Update notification service 2026-03-17 19:22:12 +01:00
MacRimi ff6904d436 Update notification-settings.tsx 2026-03-17 18:49:22 +01:00
MacRimi 1915bb3a9b Update notification service 2026-03-17 18:19:34 +01:00
MacRimi df0f15419e Add files via upload 2026-03-17 18:07:18 +01:00
MacRimi 04474d2e07 Create telegram.png 2026-03-17 17:35:27 +01:00
MacRimi 518bf0f217 Update notification service 2026-03-17 15:23:44 +01:00
MacRimi ac8f06c3a2 Update notification_manager.py 2026-03-17 15:04:29 +01:00
MacRimi feaf7b8abd Update notification-settings.tsx 2026-03-17 14:56:23 +01:00
MacRimi ac71057a3d Update notification-settings.tsx 2026-03-17 14:13:39 +01:00
MacRimi 0cb8900374 Update notification service 2026-03-17 14:07:47 +01:00
ProxMenuxBot dc531eaa37 Update helpers_cache.json 2026-03-17 12:13:15 +00:00
MacRimi 9a51a9e635 Update terminal-panel.tsx 2026-03-16 23:59:49 +01:00
MacRimi 754a0988ee Update terminal-panel.tsx 2026-03-16 23:48:35 +01:00
MacRimi 1985c0f815 Update terminal-panel.tsx 2026-03-16 23:35:31 +01:00
MacRimi 889b778d43 Update terminal-panel.tsx 2026-03-16 23:22:42 +01:00
MacRimi 26e90aa39e Update terminal panel 2026-03-16 23:13:13 +01:00
ProxMenuxBot 1eaabd14bd Update helpers_cache.json 2026-03-16 18:18:06 +00:00
MacRimi f406342b53 update latency modal 2026-03-16 18:19:19 +01:00
MacRimi b7c800b550 Update oci_manager.py 2026-03-16 17:58:04 +01:00
MacRimi b6780ba876 Update oci manager 2026-03-16 17:46:34 +01:00
ProxMenuxBot c9c8987cca Update helpers_cache.json 2026-03-16 12:14:04 +00:00
MacRimi f74d336072 Update health_persistence.py 2026-03-16 09:48:58 +01:00
MacRimi 6aaaa910af Update health_monitor.py 2026-03-16 09:36:40 +01:00
MacRimi 35eee03aa5 Update setupWebSocket 2026-03-16 09:20:45 +01:00
ProxMenuxBot 06dc6ea23f Update helpers_cache.json 2026-03-16 00:17:59 +00:00
MacRimi c1f8e7f511 Update lxc-terminal-modal.tsx 2026-03-15 21:34:45 +01:00
MacRimi 6d39acc627 Update terminal modal 2026-03-15 21:24:04 +01:00
MacRimi 11f768d26c Update oci_manager.py 2026-03-15 21:06:39 +01:00
MacRimi 602afc2954 Update jwt 2026-03-15 20:43:05 +01:00
MacRimi b7203b8219 update simple_jwt 2026-03-15 20:00:10 +01:00
MacRimi 513774bb7b Update SSL WebSocket 2026-03-15 19:04:35 +01:00
MacRimi 7375e306fb Update health_persistence.py 2026-03-15 18:27:55 +01:00
MacRimi 785d58cb59 Update health monitor 2026-03-15 18:12:42 +01:00
MacRimi af61d145da Update oci manager 2026-03-15 17:59:47 +01:00
MacRimi 0b75e967f3 Update oci manager 2026-03-15 17:19:35 +01:00
MacRimi cfa4210b0a Update terminal panel 2026-03-15 16:40:28 +01:00
MacRimi 0d6d570ae8 Update terminal panel 2026-03-15 16:27:37 +01:00
MacRimi 65add36b2f Update terminal panel 2026-03-15 16:16:03 +01:00
MacRimi 793b3dde12 Upgrade GitHub Actions to latest versions 2026-03-15 14:27:07 +01: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
MacRimi 9112bcc52f Update health_monitor.py 2026-03-15 10:54:37 +01:00
MacRimi e534cffcf7 Update health_monitor.py 2026-03-15 10:41:34 +01:00
MacRimi a184dcc38f Update health_monitor.py 2026-03-15 10:36:19 +01:00
MacRimi 2c80223fc4 Update health_persistence.py 2026-03-15 10:28:53 +01:00
MacRimi 59a578fb2d Update health_persistence.py 2026-03-15 10:23:38 +01:00
MacRimi 91c3f3520b Update health_persistence.py 2026-03-15 10:13:44 +01:00
MacRimi e169200f40 Update health monitor 2026-03-15 10:03:35 +01:00
ProxMenuxBot 486c7ef530 Update helpers_cache.json 2026-03-15 00:18:01 +00:00
MacRimi 26c75e8309 Update health_persistence.py 2026-03-15 00:00:24 +01:00
MacRimi bc6eb0b5a0 Update secure-gateway-setup.tsx 2026-03-14 23:35:47 +01:00
MacRimi 9a057ef646 Update secure-gateway-setup.tsx 2026-03-14 23:29:17 +01:00
MacRimi a7b06bd5fc Update secure-gateway-setup.tsx 2026-03-14 23:09:46 +01:00
MacRimi 9d706d3aa3 Update secure-gateway-setup.tsx 2026-03-14 22:56:55 +01:00
MacRimi fdc4253117 Update secure-gateway-setup.tsx 2026-03-14 22:50:07 +01:00
MacRimi ff168937aa Update vpn seervice 2026-03-14 22:38:48 +01:00
MacRimi b7d060a1f3 Update vpn service 2026-03-14 22:10:17 +01:00
MacRimi c37466e948 Update secure-gateway-setup.tsx 2026-03-14 21:45:00 +01:00
MacRimi 09513c0beb Update vpn service 2026-03-14 21:29:16 +01:00
MacRimi a3f4277bdc Update secure-gateway-setup.tsx 2026-03-14 20:53:42 +01:00
MacRimi b43d8918bd update vpn service 2026-03-14 20:43:56 +01:00
MacRimi 4546adb894 Update vpn service 2026-03-14 20:30:53 +01:00
MacRimi db5ac37ad3 Update secure-gateway-setup.tsx 2026-03-14 20:07:25 +01:00
MacRimi 098c14f9e0 Update secure-gateway-setup.tsx 2026-03-14 19:55:54 +01: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 461a353e92 Update menu_Helper_Scripts.sh 2026-03-14 18:18:05 +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 1fd896fb72 Update oci_manager.py 2026-03-13 20:12:35 +01:00
MacRimi d081cc6c21 Update oci_manager.py 2026-03-13 19:19:32 +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 21cfc63fc0 Update oci_manager.py 2026-03-13 00:13:52 +01:00
MacRimi a5f14146b9 Update oci_manager.py 2026-03-12 23:47:37 +01:00
MacRimi 2c18f6d975 update oci manager 2026-03-12 23:37:21 +01:00
MacRimi 84d9146c04 update oci manager 2026-03-12 22:50:20 +01:00
MacRimi 6d4006fd93 update oci manager 2026-03-12 22:13:56 +01:00
MacRimi b4a2e5ee11 Create oci manager 2026-03-12 21:30:44 +01:00
MacRimi 304b814bb1 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-12 19:40:31 +01:00
MacRimi c5e4774b29 Update notification service 2026-03-12 19:40:13 +01:00
MacRimi e90651b55b Update Node.js version and add environment variable 2026-03-12 19:37:25 +01:00
MacRimi 60d7c395bc Update Node.js version and add environment variable 2026-03-12 19:35:23 +01:00
MacRimi 2b5c9c2d61 Update health_persistence.py 2026-03-12 19:22:06 +01:00
MacRimi a703f1db73 Update health_persistence.py 2026-03-12 18:55:15 +01:00
MacRimi 4aaba7619e Update notification service 2026-03-12 18:12:04 +01:00
MacRimi 1a88dd801d Update proxmox-dashboard.tsx 2026-03-10 19:31:07 +01:00
MacRimi 83352ab9fe Update proxmox-dashboard.tsx 2026-03-10 19:25:03 +01:00
MacRimi 6e268a1bf4 Update proxmox-dashboard.tsx 2026-03-10 19:15:31 +01:00
MacRimi 4d65e54576 Update proxmox-dashboard.tsx 2026-03-10 19:06:28 +01:00
MacRimi c131ec722e Update proxmox-dashboard.tsx 2026-03-10 18:54:03 +01:00
MacRimi 574e12f336 Update proxmox-dashboard.tsx 2026-03-10 18:37:37 +01:00
MacRimi 1705868457 Update proxmox-dashboard.tsx 2026-03-10 18:22:59 +01:00
MacRimi 8392d111dc Update health-status-modal.tsx 2026-03-10 18:06:40 +01:00
MacRimi 8c5ccbadac Update notification service 2026-03-10 18:00:56 +01:00
MacRimi e4aa081e64 Update health-status-modal.tsx 2026-03-10 17:32:18 +01:00
MacRimi 8cc74eceb6 Update notification service 2026-03-10 17:22:27 +01:00
MacRimi 45365e3860 Update storage-overview.tsx 2026-03-08 23:22:18 +01:00
MacRimi 3739560956 Update notification service 2026-03-08 22:47:04 +01:00
MacRimi b8cff3e699 Update notification service 2026-03-08 20:01:02 +01:00
MacRimi d1d44afc9d Update notification service 2026-03-08 19:37:04 +01:00
ProxMenuxBot 782d847e54 Update helpers_cache.json 2026-03-08 18:04:29 +00:00
MacRimi be2bfa0087 Update health_monitor.py 2026-03-08 18:21:34 +01:00
MacRimi 1ea28d66df Update notification service 2026-03-08 18:15:36 +01:00
MacRimi 8c51957bfa Update notification service 2026-03-08 16:33:52 +01:00
MacRimi 858a1bba4f Update health_monitor.py 2026-03-08 10:23:53 +01:00
MacRimi 17e4227978 Update notification service 2026-03-08 10:15:55 +01:00
MacRimi f8b5e07518 Update notification service 2026-03-08 10:00:17 +01:00
ProxMenuxBot d96e4019aa Update helpers_cache.json 2026-03-08 00:15:29 +00:00
MacRimi acc9760690 Update health_monitor.py 2026-03-08 00:58:37 +01:00
MacRimi 56dab535c3 Update notification service 2026-03-08 00:53:35 +01:00
MacRimi 94670711e7 Update flask_server.py 2026-03-08 00:30:05 +01:00
MacRimi 673c206e02 Update latency-detail-modal.tsx 2026-03-07 23:47:24 +01:00
MacRimi decd3bd134 Update latency-detail-modal.tsx 2026-03-07 23:40:19 +01:00
MacRimi 6e5c7aeab5 Update notification service 2026-03-07 23:33:13 +01:00
MacRimi 2647550324 Update notification service 2026-03-07 23:18:25 +01:00
MacRimi 424a63011b Update notification service 2026-03-07 23:05:51 +01:00
MacRimi 0e6a125c60 update notification service 2026-03-07 22:55:42 +01:00
MacRimi 758cae4f86 Update latency-detail-modal.tsx 2026-03-07 22:36:20 +01:00
MacRimi 6e8368c62a Update latency-detail-modal.tsx 2026-03-07 22:14:47 +01:00
MacRimi a14e554323 Update latency-detail-modal.tsx 2026-03-07 21:58:57 +01:00
MacRimi 6435202fa1 Update latency-detail-modal.tsx 2026-03-07 21:37:24 +01:00
MacRimi cf8425ff14 update notification service 2026-03-07 21:17:13 +01:00
MacRimi 9bb1c1b233 Update notification service 2026-03-07 20:38:18 +01:00
MacRimi c9ccc5e27e update notification service 2026-03-07 20:17:40 +01:00
MacRimi 7115b2ff54 Update notification service 2026-03-07 20:06:59 +01:00
MacRimi b89e234ba4 Update latency-detail-modal.tsx 2026-03-07 19:45:03 +01:00
MacRimi 70fbaa0bfd Update latency-detail-modal.tsx 2026-03-07 19:27:31 +01:00
MacRimi f6cdd4ff36 Update latency-detail-modal.tsx 2026-03-07 19:02:14 +01:00
MacRimi 1857f46452 Update notification service 2026-03-07 18:44:51 +01:00
MacRimi f95a6f4fd7 Update latency-detail-modal.tsx 2026-03-07 17:57:57 +01:00
MacRimi b0bc66f548 Update latency-detail-modal.tsx 2026-03-07 17:21:44 +01:00
MacRimi 34b4a6c3d8 Update latency-detail-modal.tsx 2026-03-07 17:06:47 +01:00
MacRimi b4c7463226 Update latency-detail-modal.tsx 2026-03-07 16:59:13 +01:00
MacRimi ca5b33ef69 Update latency-detail-modal.tsx 2026-03-07 16:42:01 +01:00
MacRimi acd980091d Update latency-detail-modal.tsx 2026-03-07 16:23:11 +01:00
MacRimi de5317987e Update latency-detail-modal.tsx 2026-03-07 12:02:20 +01:00
ProxMenuxBot 6b438bc4aa Update helpers_cache.json 2026-03-07 00:14:59 +00:00
MacRimi 9b1495a490 Update notification service 2026-03-06 21:25:14 +01:00
MacRimi f638011d63 Update latency-detail-modal.tsx 2026-03-06 20:54:40 +01:00
MacRimi 8447a95c8a Update latency-detail-modal.tsx 2026-03-06 20:34:59 +01:00
MacRimi 4feceaa1d1 Update latency-detail-modal.tsx 2026-03-06 20:13:31 +01:00
MacRimi 8383e381d1 Update latency-detail-modal.tsx 2026-03-06 20:02:49 +01:00
MacRimi a064a7471e Update notification service 2026-03-06 19:32:10 +01:00
MacRimi f0e3d7d09a Update notification_events.py 2026-03-06 19:05:08 +01:00
MacRimi 2b7f4ccd6c Update notification_manager.py 2026-03-06 18:57:43 +01:00
MacRimi 46fa89233b Update notification service 2026-03-06 18:44:27 +01:00
MacRimi 591099e42b Update storage-overview.tsx 2026-03-06 15:24:18 +01:00
MacRimi d08398ea57 Update flask_server.py 2026-03-06 14:43:18 +01:00
MacRimi 83f49742b6 Update notification service 2026-03-06 14:32:23 +01:00
ProxMenuxBot 50d07f81fd Update helpers_cache.json 2026-03-06 12:08:42 +00:00
MacRimi 594ee21fcd Update notification_events.py 2026-03-06 12:16:06 +01:00
MacRimi ea2763c48c Update notification service 2026-03-06 12:06:53 +01:00
MacRimi 925fe1cce0 Update notification service 2026-03-05 21:28:48 +01:00
MacRimi d927b462b6 Update notification service 2026-03-05 20:44:51 +01:00
MacRimi 5a79556ab2 Update flask_server.py 2026-03-05 19:59:17 +01:00
MacRimi 260870ad8a Update notification service 2026-03-05 19:49:35 +01:00
ProxMenuxBot 7d69e64adc Update helpers_cache.json 2026-03-05 18:34:30 +00:00
MacRimi 5af51096d8 Update notification service 2026-03-05 19:25:05 +01:00
MacRimi 898392725a Update notification service 2026-03-05 17:29:07 +01:00
MacRimi 9089035f18 Update notification service 2026-03-04 19:11:38 +01:00
MacRimi 66d2a68167 Update notification service 2026-03-03 21:12:19 +01:00
MacRimi 4a41e40592 Update notification_channels.py 2026-03-03 20:59:43 +01:00
MacRimi 2a75b920a0 Update notification_channels.py 2026-03-03 20:49:36 +01:00
MacRimi 2851eae423 Update notification_channels.py 2026-03-03 20:21:43 +01:00
MacRimi efc2295b8d Update notification service 2026-03-03 20:09:38 +01:00
MacRimi 2bee28a1d8 Update notification service 2026-03-03 19:45:54 +01:00
MacRimi a8cc995558 Update notification service 2026-03-03 19:36:33 +01:00
MacRimi 9a11c41424 Update notification service 2026-03-03 19:19:56 +01:00
MacRimi 2a4d056b59 Update notification service 2026-03-03 18:48:54 +01:00
MacRimi 5a77a398bd Update notification-settings.tsx 2026-03-03 17:50:56 +01:00
MacRimi 4cf2238c99 Update notification-settings.tsx 2026-03-03 17:23:38 +01:00
MacRimi 58df4f1481 update notification service 2026-03-03 16:42:12 +01:00
MacRimi da3f99a254 Update notification service 2026-03-03 13:40:46 +01:00
ProxMenuxBot c2fa6095cc Update helpers_cache.json 2026-03-03 12:08:48 +00:00
MacRimi f0b8ed20a2 update notification service 2026-03-02 23:21:40 +01:00
ProxMenuxBot 0b8b72be5c Update helpers_cache.json 2026-03-02 18:12:26 +00:00
MacRimi 18c6455837 Update notification service 2026-03-02 18:55:02 +01:00
MacRimi e0477015c4 Update notification-settings.tsx 2026-03-02 18:42:58 +01:00
MacRimi e99a4e2b08 Update notification-settings.tsx 2026-03-02 18:36:50 +01:00
MacRimi c44d06b0dc Update notification-settings.tsx 2026-03-02 18:26:23 +01:00
MacRimi 0e8327c085 Update notification-settings.tsx 2026-03-02 18:12:47 +01:00
MacRimi 5c5a86c7fc Update notification-settings.tsx 2026-03-02 18:06:27 +01:00
MacRimi a785213cb2 Update notification service 2026-03-02 18:01:34 +01:00
MacRimi e041440c97 Update notification_events.py 2026-03-02 17:27:45 +01:00
MacRimi 688ca8a604 Update notification service 2026-03-02 17:16:22 +01:00
ProxMenuxBot fd6f0967b0 Update helpers_cache.json 2026-03-02 12:08:55 +00:00
MacRimi 9fe58935c4 Update notification service 2026-03-01 22:56:25 +01:00
MacRimi 0dfb35730f Update notification service 2026-03-01 22:29:58 +01:00
MacRimi dc52f4c692 Update notification service 2026-03-01 18:44:11 +01:00
MacRimi bcf5395868 update notification service 2026-03-01 17:24:13 +01:00
MacRimi 3e96a89adf update notification service 2026-02-28 20:32:58 +01:00
MacRimi c0a882251d Update notification service 2026-02-28 20:22:24 +01:00
MacRimi 6a53b895e5 Update health-status-modal.tsx 2026-02-28 19:40:14 +01:00
MacRimi c5354d014c Update notification service 2026-02-28 19:18:13 +01:00
MacRimi 5e9ef37646 Update notification service 2026-02-28 18:07:00 +01:00
MacRimi cb96bea73d Update health_monitor.py 2026-02-28 17:31:03 +01:00
MacRimi 95fa2440ce Update notification service 2026-02-28 17:18:03 +01:00
ProxMenuxBot ca9698f75d Update helpers_cache.json 2026-02-28 12:05:10 +00:00
MacRimi 0f1413f130 Update notification service 2026-02-28 00:01:01 +01:00
MacRimi 52a4b604dd Update notification service 2026-02-27 23:49:26 +01:00
MacRimi 3c64ee7af2 Update notification service 2026-02-27 23:45:18 +01:00
MacRimi 026719cd88 Update health_monitor.py 2026-02-27 23:28:27 +01:00
MacRimi 9bac00ee29 Update health-status-modal.tsx 2026-02-27 23:11:57 +01:00
MacRimi 828c0f66a6 Update health_monitor.py 2026-02-27 20:07:01 +01:00
MacRimi 9841e92634 Update health_monitor.py 2026-02-27 19:55:02 +01:00
MacRimi 171e7ddcae Update notification service 2026-02-27 19:47:36 +01:00
ProxMenuxBot 968a5bd789 Update helpers_cache.json 2026-02-27 18:10:15 +00:00
MacRimi be119a69af Update health_monitor.py 2026-02-27 18:42:44 +01:00
MacRimi 800c3c11be Update health_monitor.py 2026-02-27 18:36:51 +01:00
MacRimi 1242da5ed1 Update notification service 2026-02-27 18:27:24 +01:00
MacRimi 8bf4fa0cf1 Update notification service 2026-02-26 21:28:14 +01:00
MacRimi 0693acc07b Update notification service 2026-02-26 20:58:40 +01:00
MacRimi 17eecfca9d Update notification service 2026-02-26 20:31:44 +01:00
MacRimi 4d24d6d17b Update notification service 2026-02-26 18:21:01 +01: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
MacRimi ffc202f6a3 Update notification_events.py 2026-02-24 20:11:29 +01:00
MacRimi f7fd728683 Update notification service 2026-02-24 19:52:27 +01:00
MacRimi 46c04e5a81 Update notification service 2026-02-24 19:27:43 +01:00
MacRimi f43feb825f Update notification service 2026-02-24 18:20:43 +01:00
MacRimi 05cd21d44e Update notification service 2026-02-24 18:10:12 +01:00
MacRimi 4182af75ff Update notification service 2026-02-24 17:55:03 +01: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
MacRimi 507f769357 Update notification service 2026-02-21 22:36:58 +01:00
MacRimi e3f7e8c97a Update notification service 2026-02-21 22:19:45 +01:00
MacRimi 49e9e26bff Update flask_notification_routes.py 2026-02-21 21:55:37 +01:00
MacRimi fccd4c12ca Update notification service 2026-02-21 21:36:27 +01:00
MacRimi 06c9ff481e Update flask_notification_routes.py 2026-02-21 21:11:57 +01:00
MacRimi 50e5775062 Update flask_notification_routes.py 2026-02-21 20:55:18 +01:00
MacRimi 91da8db589 Update flask_notification_routes.py 2026-02-21 20:49:59 +01:00
MacRimi 0d854ae42b Update notification service 2026-02-21 20:33:22 +01:00
MacRimi ec21050fad Update notification service 2026-02-21 19:56:50 +01:00
MacRimi 67c61a5829 Update notification service 2026-02-21 18:47:15 +01:00
MacRimi e685668959 update notificacion service 2026-02-21 18:13:38 +01:00
MacRimi de13eb5b96 Update notification service 2026-02-21 17:23:03 +01:00
ProxMenuxBot c0f54c334e Update helpers_cache.json 2026-02-21 00:16:10 +00:00
MacRimi f134fcb528 Update notification service 2026-02-20 17:55:05 +01:00
MacRimi d5954a3a32 Update notification service 2026-02-19 20:51:54 +01:00
MacRimi bd28e312fc Update notification-settings.tsx 2026-02-19 20:30:11 +01:00
MacRimi 7208d5b2bf Update notification service 2026-02-19 19:56:20 +01:00
ProxMenuxBot 5c2d4e4718 Update helpers_cache.json 2026-02-19 18:16:51 +00:00
MacRimi 8cdeae6c3f Update notification service 2026-02-19 18:37:42 +01:00
MacRimi e7bc6d09f2 Update flask_notification_routes.py 2026-02-19 17:46:50 +01:00
MacRimi 4ce2699a48 Update notication service 2026-02-19 17:26:36 +01:00
MacRimi 7c5cdb9161 Update notification service 2026-02-19 17:02:02 +01:00
ProxMenuxBot 64a0aa6157 Update helpers_cache.json 2026-02-19 12:11:34 +00:00
MacRimi 34d04e57dd Update notification service 2026-02-18 17:24:26 +01:00
MacRimi 1317c5bddc Update health monitor 2026-02-17 20:01:45 +01:00
MacRimi 74b6f565e9 Update health monitor 2026-02-17 17:57:49 +01:00
MacRimi 08f49d4d0b Update health-status-modal.tsx 2026-02-17 17:24:47 +01:00
MacRimi 99605b6a55 Update health monitor 2026-02-17 17:09:00 +01:00
MacRimi beeeabc377 Update health monitor 2026-02-17 16:49:26 +01:00
ProxMenuxBot ff2e40d49a Update helpers_cache.json 2026-02-17 12:10:47 +00:00
MacRimi 31c5eeb6c3 Update health monitor 2026-02-17 11:35:11 +01:00
MacRimi 8004ee48c9 Update health monitor 2026-02-16 22:53:16 +01:00
MacRimi a1d48a28e9 Update health monitor 2026-02-16 22:26:43 +01:00
MacRimi 0f81f45c5f update health monitor 2026-02-16 22:07:10 +01:00
MacRimi 05f7957557 Update health_monitor.py 2026-02-16 18:39:36 +01:00
MacRimi 1ed8f5d124 Update health monitor 2026-02-16 18:19:29 +01:00
MacRimi 2ee5be7402 Update health monitor 2026-02-16 15:48:41 +01:00
ProxMenuxBot 1226e7bee1 Update helpers_cache.json 2026-02-16 12:10:34 +00:00
MacRimi dcbc52efc6 Update spinner 2026-02-16 12:11:37 +01:00
MacRimi 92b0a1478a Update log 2026-02-16 11:43:12 +01:00
MacRimi f27c7fdf31 Update system-logs.tsx 2026-02-16 10:55:26 +01:00
MacRimi 18a427b501 Update logs 2026-02-16 09:52:33 +01:00
MacRimi b7951b730d Update spinner 2026-02-16 09:48:03 +01: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
MacRimi f75e30afd0 Update security.tsx 2026-02-14 18:34:36 +01:00
MacRimi 9f11238d43 Update security.tsx 2026-02-14 18:21:53 +01:00
MacRimi 070a1b47e5 Update security.tsx 2026-02-14 17:59:44 +01:00
MacRimi 3e8661f5ca Update temperature-detail-modal.tsx 2026-02-14 17:40:09 +01:00
MacRimi 9f8c27ddc1 Update modal 2026-02-14 17:28:35 +01:00
MacRimi bafaaf9c47 Update security.tsx 2026-02-14 17:01:14 +01:00
MacRimi 1ee5863da7 Update firewall 2026-02-14 16:49:08 +01:00
MacRimi ace4d83789 Update security.tsx 2026-02-14 16:39:24 +01:00
MacRimi 1da1c178d0 Update security.tsx 2026-02-14 16:08:11 +01:00
MacRimi c429cb2ed1 Update flask_server.py 2026-02-14 14:32:30 +01:00
MacRimi 40c40f81fc Update flask_auth_routes.py 2026-02-14 12:23:08 +01:00
MacRimi 6647a3b083 Update security.tsx 2026-02-14 12:07:51 +01:00
MacRimi 782eaef440 Update flask_server.py 2026-02-14 11:12:54 +01:00
MacRimi 6003310a39 Update system-overview.tsx 2026-02-13 20:34:09 +01:00
MacRimi 229ac5006b Update temperature-detail-modal.tsx 2026-02-13 20:23:56 +01:00
MacRimi 322687c658 Update modal temperature 2026-02-13 20:09:50 +01:00
MacRimi fe3963dfe2 Update temperature-detail-modal.tsx 2026-02-13 19:58:57 +01:00
MacRimi e4a57b97b7 Update modal temperature 2026-02-13 19:40:22 +01:00
MacRimi 9b48c498f5 new modal temperature 2026-02-13 19:07:24 +01:00
MacRimi 4228177920 Update auto_post_install.sh 2026-02-13 18:21:28 +01:00
MacRimi e98637321d Update auto_post_install.sh 2026-02-13 18:17:32 +01:00
ProxMenuxBot 5941bd4b68 Update helpers_cache.json 2026-02-13 12:08:41 +00:00
MacRimi a686360c1f Update memory speed 2026-02-13 12:30:27 +01:00
MacRimi 20ee9da1ec Update flask_auth_routes.py 2026-02-13 11:02:53 +01:00
MacRimi c89baf34a8 Update 2FA 2026-02-13 10:51:27 +01:00
MacRimi 00230d1b8f Update security_manager.py 2026-02-12 20:13:53 +01:00
MacRimi 4396d57e3d Update security_manager.py 2026-02-12 19:43:52 +01:00
MacRimi 86789f677a Update security_manager.py 2026-02-12 19:23:55 +01:00
MacRimi 8fb2deeab0 Update security_manager.py 2026-02-12 19:15:08 +01:00
MacRimi 2099bbe58f Update security_manager.py 2026-02-12 18:58:39 +01: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
MacRimi c4b1820d08 Update security_manager.py 2026-02-10 19:32:49 +01:00
MacRimi 59cc2741b8 Update security_manager.py 2026-02-10 19:04:04 +01:00
MacRimi cc34d33090 Update security 2026-02-10 18:28:43 +01: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
MacRimi 06a3e6b472 Update root access 2026-02-09 18:20:09 +01:00
MacRimi 9108882921 Update security.tsx 2026-02-09 17:18:42 +01:00
ProxMenuxBot 7796f7d3bc Update helpers_cache.json 2026-02-09 12:14:11 +00:00
MacRimi 00a0ae6561 Update security.tsx 2026-02-09 10:55:42 +01:00
MacRimi 6310293190 Update security.tsx 2026-02-08 20:54:04 +01:00
MacRimi 809930df9a Update security.tsx 2026-02-08 20:38:27 +01:00
MacRimi f1874d4ab1 Update security.tsx 2026-02-08 20:25:20 +01:00
MacRimi cc0f401855 Update Lynis 2026-02-08 20:00:23 +01:00
MacRimi 42626f3bce Update security_manager.py 2026-02-08 19:37:44 +01:00
MacRimi 22d570b024 Update lynis 2026-02-08 19:24:40 +01:00
MacRimi 6d0a07f212 Update security_manager.py 2026-02-08 18:52:18 +01:00
MacRimi a512b5a110 Update security 2026-02-08 18:30:18 +01:00
MacRimi bde3dade14 Update lynis 2026-02-08 17:51:20 +01:00
MacRimi de2058d966 Update fail2ban 2026-02-08 17:23:53 +01:00
MacRimi 7f191764be Update security 2026-02-08 17:01:49 +01:00
MacRimi 7f9da757aa update firewall 2026-02-08 16:19:48 +01:00
MacRimi f07e8cfe14 Update security 2026-02-08 13:19:24 +01: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
MacRimi 3ad5b72ebf Update flask_server.py 2026-02-07 19:02:08 +01:00
MacRimi 567e2e5d6d Update flask_server.py 2026-02-07 18:53:21 +01:00
MacRimi 616bd0ac91 Backend SSL config manager and API endpoints 2026-02-07 18:36:14 +01:00
MacRimi 108a169e7c Update 2FA 2026-02-07 18:03:46 +01:00
MacRimi eab902d68e Update Log 2026-02-07 17:37:55 +01:00
MacRimi 985f6e89ec Update modal LXC 2026-02-07 11:45:28 +01:00
MacRimi 0480989fd2 Update virtual-machines.tsx 2026-02-07 11:25:33 +01:00
MacRimi 72ffe420b7 Update virtual-machines.tsx 2026-02-07 10:55:06 +01: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
MacRimi f5d169eaa2 Update flask_server.py 2026-02-03 23:17:44 +01:00
MacRimi b3b921e1ae Update flask_server.py 2026-02-03 23:02:19 +01:00
MacRimi 91f15b723e Update modal lxc 2026-02-03 22:53:37 +01:00
MacRimi 303dcb1eb6 Update flask_server.py 2026-02-03 22:30:55 +01:00
MacRimi 4eaeb1b020 Create textarea.tsx 2026-02-03 22:18:00 +01:00
MacRimi f13427ca27 Update modal lxc 2026-02-03 22:10:53 +01:00
MacRimi 458f2cdf16 Update virtual-machines.tsx 2026-02-03 18:29:00 +01:00
MacRimi df588f25bf Update virtual-machines.tsx 2026-02-03 18:14:40 +01:00
MacRimi bd0fdff29c Update virtual-machines.tsx 2026-02-03 18:12:37 +01:00
MacRimi 774da61da1 Update virtual-machines.tsx 2026-02-03 18:07:55 +01:00
MacRimi 42e67e01aa Update virtual-machines.tsx 2026-02-03 17:41:34 +01:00
MacRimi 36e201e824 Update virtual-machines.tsx 2026-02-03 17:33:18 +01:00
MacRimi 497233c9f1 Update virtual-machines.tsx 2026-02-03 17:10:19 +01:00
MacRimi 4b7c9a1bd3 Update virtual-machines.tsx 2026-02-03 17:00:43 +01:00
MacRimi 7c2d6d6618 Update virtual-machines.tsx 2026-02-03 16:57:50 +01:00
MacRimi c44e0afb81 Update virtual-machines.tsx 2026-02-03 16:50:29 +01:00
MacRimi d3ef3c7452 Update virtual-machines.tsx 2026-02-03 16:45:01 +01:00
ProxMenuxBot 1935c76f30 Update helpers_cache.json 2026-02-02 18:11:22 +00:00
MacRimi 71056d8f15 Update virtual-machines.tsx 2026-02-02 19:01:07 +01:00
MacRimi 8d34119e7a Update modal lxc 2026-02-02 18:49:18 +01:00
MacRimi f159ee77cd Update modal vm 2026-02-02 17:46:23 +01:00
MacRimi d336c4f5b7 Update modal vm 2026-02-02 17:29:14 +01:00
ProxMenuxBot 81b7a3e665 Update helpers_cache.json 2026-02-02 12:08:58 +00:00
MacRimi 1870f74f0c Update terminal-panel.tsx 2026-02-01 01:32:20 +01:00
MacRimi d19f9c6888 Create dropdown-menu.tsx 2026-02-01 01:18:20 +01:00
ProxMenuxBot a68bf6fc8f Update helpers_cache.json 2026-02-01 00:16:53 +00:00
MacRimi c2d2745777 Update terminal modal 2026-02-01 01:13:13 +01:00
MacRimi fc8bf841bf Update lxc-terminal-modal.tsx 2026-02-01 00:50:53 +01:00
MacRimi 08f435597a Update lxc-terminal-modal.tsx 2026-02-01 00:42:51 +01:00
MacRimi 58a4e475ad Update lxc-terminal-modal.tsx 2026-01-31 18:29:19 +01:00
MacRimi 5bfc911e1b Update lxc-terminal-modal.tsx 2026-01-31 18:17:50 +01:00
MacRimi d2c7362736 Update terminal modal 2026-01-31 18:10:55 +01:00
MacRimi 82cac690fa Update terminal panel 2026-01-31 17:40:08 +01:00
MacRimi 3c3c902087 Update terminal modal 2026-01-31 17:19:50 +01:00
MacRimi 964538eb43 Update lxc-terminal-modal.tsx 2026-01-31 17:02:11 +01:00
MacRimi 5e8b2bdb50 Update terminal panel 2026-01-31 16:48:22 +01:00
MacRimi e3d10495f3 Update terminal modal 2026-01-31 16:17:36 +01:00
MacRimi 6c3886ad24 Update virtual-machines.tsx 2026-01-31 15:57:59 +01:00
MacRimi ba727f53c4 Update flask_server.py 2026-01-31 15:53:56 +01:00
MacRimi 35a4737e43 Update node-metrics-charts.tsx 2026-01-31 15:34:11 +01:00
MacRimi abde8652b2 Update system-overview.tsx 2026-01-31 15:25:15 +01:00
MacRimi cadef0bf81 Update terminal-panel.tsx 2026-01-31 14:34:26 +01:00
MacRimi caac696244 Update terminal panel 2026-01-31 12:25:23 +01:00
MacRimi 6910a0b4bd Update intel_gpu_tools.sh 2026-01-31 11:52:34 +01:00
MacRimi 74e2584e4d Update flask_server.py 2026-01-31 11:44:32 +01:00
ProxMenuxBot 459dd2d9c7 Update helpers_cache.json 2026-01-31 00:14:20 +00:00
MacRimi 0f5c83c1c2 Update intel_gpu_tools.sh 2026-01-29 21:26:11 +01:00
ProxMenuxBot fed4cc2a97 Update helpers_cache.json 2026-01-29 19:56:29 +00:00
MacRimi c238711b3e Update hardware.tsx 2026-01-29 20:03:39 +01:00
MacRimi f42334917e Create intel_gpu_tools.sh 2026-01-29 19:59:12 +01:00
MacRimi 09004d4c09 Update share-common.func 2026-01-29 19:54:54 +01:00
MacRimi 68da9b2f69 Create amd_gpu_tools.sh 2026-01-29 19:41:48 +01:00
MacRimi 454ff37a72 Update gpu monitor 2026-01-29 19:04:52 +01:00
MacRimi ca13d18d7d Update backend monitor 2026-01-29 18:27:36 +01:00
MacRimi 1657a7dbe3 Update hardware_monitor.py 2026-01-29 18:21:23 +01:00
MacRimi 61e925eaab Update hardware_monitor.py 2026-01-29 18:07:49 +01:00
MacRimi 09d3313e15 Update flask_server.py 2026-01-29 17:59:04 +01:00
MacRimi a20d61037e Update backend monitor 2026-01-29 17:47:10 +01:00
ProxMenuxBot 7eaa692712 Update helpers_cache.json 2026-01-28 18:06:50 +00:00
ProxMenuxBot 691bae9a96 Update helpers_cache.json 2026-01-28 12:05:34 +00:00
ProxMenuxBot d5a8c9b7d1 Update helpers_cache.json 2026-01-26 18:05:37 +00:00
ProxMenuxBot 8c20e7c661 Update helpers_cache.json 2026-01-25 00:14:01 +00:00
ProxMenuxBot 47a2d28c6a Update helpers_cache.json 2026-01-24 00:12:27 +00:00
ProxMenuxBot 31f8961e27 Update helpers_cache.json 2026-01-23 18:04:28 +00:00
ProxMenuxBot 424bd0bc28 Update helpers_cache.json 2026-01-23 12:05:47 +00:00
ProxMenuxBot 9c078583dd Update helpers_cache.json 2026-01-22 12:05:55 +00:00
ProxMenuxBot ca27048679 Update helpers_cache.json 2026-01-22 00:13:22 +00:00
MacRimi 4e65663748 Update coral lxc 2026-01-20 20:17:53 +01:00
ProxMenuxBot c7c5cbde83 Update helpers_cache.json 2026-01-20 00:12:48 +00:00
ProxMenuxBot a4905ad207 Update helpers_cache.json 2026-01-19 18:04:06 +00:00
MacRimi bebf0e692a Update License 2026-01-19 17:15:00 +01:00
ProxMenuxBot 8ff9a87dfe Update helpers_cache.json 2026-01-19 12:06:36 +00:00
ProxMenuxBot 62f2d8ac16 Update helpers_cache.json 2026-01-19 00:12:53 +00:00
ProxMenuxBot 8fef2a6232 Update helpers_cache.json 2026-01-18 18:04:16 +00:00
ProxMenuxBot 94064fe78c Update helpers_cache.json 2026-01-17 12:05:12 +00:00
ProxMenuxBot 2ffcc43adc Update helpers_cache.json 2026-01-16 18:05:23 +00:00
ProxMenuxBot 3846fce73a Update helpers_cache.json 2026-01-16 12:05:44 +00:00
ProxMenuxBot ea950e9dbc Update helpers_cache.json 2026-01-15 18:07:27 +00:00
ProxMenuxBot f2639c4ff1 Update helpers_cache.json 2026-01-14 18:05:47 +00:00
ProxMenuxBot 32c1798eb8 Update helpers_cache.json 2026-01-13 12:05:29 +00:00
ProxMenuxBot 75e3167b65 Update helpers_cache.json 2026-01-12 18:04:41 +00:00
ProxMenuxBot ad07a61aa7 Update helpers_cache.json 2026-01-08 18:04:25 +00:00
ProxMenuxBot c91b6329f3 Update helpers_cache.json 2026-01-07 00:11:18 +00:00
ProxMenuxBot 9cc60efd5a Update helpers_cache.json 2026-01-03 12:04:28 +00:00
ProxMenuxBot 08eeea6b9c Update helpers_cache.json 2026-01-02 12:05:29 +00:00
ProxMenuxBot 8dea7335de Update helpers_cache.json 2025-12-30 18:04:18 +00:00
ProxMenuxBot 2ad6d43422 Update helpers_cache.json 2025-12-29 00:12:44 +00:00
ProxMenuxBot 12c2e7aefb Update helpers_cache.json 2025-12-28 12:04:17 +00:00
ProxMenuxBot 6b62e46950 Update helpers_cache.json 2025-12-28 00:13:42 +00:00
ProxMenuxBot 853c58e0a0 Update helpers_cache.json 2025-12-26 18:03:55 +00:00
ProxMenuxBot eb0abc425a Update helpers_cache.json 2025-12-26 12:05:50 +00:00
ProxMenuxBot c808e40bf6 Update helpers_cache.json 2025-12-25 12:29:08 +00:00
ProxMenuxBot f0bbb14f3f Update helpers_cache.json 2025-12-24 18:20:56 +00:00
ProxMenuxBot 95dd0ea6fb Update helpers_cache.json 2025-12-24 01:06:53 +00:00
ProxMenuxBot 7f34102ae6 Update helpers_cache.json 2025-12-23 01:07:03 +00:00
ProxMenuxBot 7623962da5 Update helpers_cache.json 2025-12-22 01:10:38 +00:00
ProxMenuxBot cfb34b59df Update helpers_cache.json 2025-12-21 12:25:59 +00:00
ProxMenuxBot e5004bb55e Update helpers_cache.json 2025-12-20 01:03:45 +00:00
ProxMenuxBot c0193fdf73 Update helpers_cache.json 2025-12-19 01:08:01 +00:00
ProxMenuxBot 6cbafd557c Update helpers_cache.json 2025-12-16 12:30:30 +00:00
ProxMenuxBot ee8ab75907 Update helpers_cache.json 2025-12-15 12:32:47 +00:00
MacRimi f2e93ad69e Update route.ts 2025-12-14 02:37:02 +01:00
MacRimi 5faf3fd61c Update route.ts 2025-12-14 02:20:07 +01:00
MacRimi 956a8f4864 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-12-14 02:12:40 +01:00
MacRimi d26bc56b5c Update route.ts 2025-12-14 02:12:26 +01:00
ProxMenuxBot 7457770ef8 Update helpers_cache.json 2025-12-14 01:11:59 +00:00
MacRimi 54af9073cb Update web 2025-12-14 01:57:36 +01:00
MacRimi a8dcf5e8f5 Update web 2025-12-14 01:38:07 +01:00
MacRimi 9e3334d75f Update web 2025-12-13 21:19:08 +01:00
MacRimi cca6e71911 Update web 2025-12-13 20:20:21 +01:00
ProxMenuxBot 7fbd377ab2 Update helpers_cache.json 2025-12-13 18:18:09 +00:00
MacRimi 24417feba3 Update next.config.mjs 2025-12-13 19:09:26 +01:00
ProxMenuxBot f8c24964e3 Update helpers_cache.json 2025-12-13 01:02:58 +00:00
MacRimi 1ae2ebfaf0 Update nvidia_installer.sh 2025-12-11 22:07:01 +01:00
MacRimi 4feea6d153 Update script-terminal-modal.tsx 2025-12-11 21:30:30 +01:00
MacRimi ec6b658685 Update script-terminal-modal.tsx 2025-12-11 21:22:15 +01:00
MacRimi fb0f05a08d Update script-terminal-modal.tsx 2025-12-11 19:56:20 +01:00
MacRimi 11bc477f1f Update script-terminal-modal.tsx 2025-12-11 19:06:53 +01:00
MacRimi 9760375855 Update script-terminal-modal.tsx 2025-12-11 18:44:56 +01:00
MacRimi a6e20bd9f0 Update script-terminal-modal.tsx 2025-12-11 18:27:11 +01:00
MacRimi 90fedbf9a2 Update script-terminal-modal.tsx 2025-12-11 18:19:38 +01:00
MacRimi eb03262abc Update script-terminal-modal.tsx 2025-12-11 18:13:37 +01:00
MacRimi 59eb6e5f1b Update script-terminal-modal.tsx 2025-12-11 18:06:24 +01:00
MacRimi edf513aca9 Update script-terminal-modal.tsx 2025-12-11 17:55:19 +01:00
MacRimi efed63519a Update script-terminal-modal.tsx 2025-12-11 17:44:23 +01:00
MacRimi d78f781506 Update script-terminal-modal.tsx 2025-12-11 17:30:12 +01:00
ProxMenuxBot 93fe269b09 Update helpers_cache.json 2025-12-11 12:31:23 +00:00
ProxMenuxBot 8cad6c4e56 Update helpers_cache.json 2025-12-11 01:07:29 +00:00
MacRimi f92049dc71 Update script-terminal-modal.tsx 2025-12-10 23:48:43 +01:00
MacRimi a3497a9d39 Update script-terminal-modal.tsx 2025-12-10 23:43:05 +01:00
MacRimi bfc0a2ed57 Update script-terminal-modal.tsx 2025-12-10 23:34:30 +01:00
MacRimi c49b45d262 Update script-terminal-modal.tsx 2025-12-10 23:26:11 +01:00
MacRimi 15678cf96a Update script-terminal-modal.tsx 2025-12-10 23:15:50 +01:00
MacRimi feeaaa7f2b Update script-terminal-modal.tsx 2025-12-10 23:06:03 +01:00
MacRimi 50df1a2212 Update script-terminal-modal.tsx 2025-12-10 22:57:19 +01:00
MacRimi ac9254d049 Update script-terminal-modal.tsx 2025-12-10 22:48:07 +01:00
MacRimi e15eeb36a5 Update script-terminal-modal.tsx 2025-12-10 22:30:11 +01:00
MacRimi e275e03d4e Update script-terminal-modal.tsx 2025-12-10 22:19:41 +01:00
MacRimi 41c8826ca8 Update script-terminal-modal.tsx 2025-12-10 22:10:41 +01:00
MacRimi d8af31ba5b Update script-terminal-modal.tsx 2025-12-10 21:57:16 +01:00
MacRimi 2eb7cb1687 Update AppImage 2025-12-10 21:47:52 +01:00
MacRimi 207e75f5b9 Update script-terminal-modal.tsx 2025-12-10 20:17:13 +01:00
MacRimi 7b9e1a71a3 Update script-terminal-modal.tsx 2025-12-10 20:12:03 +01:00
MacRimi 345838c6ce Update script-terminal-modal.tsx 2025-12-10 19:54:39 +01:00
MacRimi b02a60f4b3 Update script-terminal-modal.tsx 2025-12-10 19:44:39 +01:00
MacRimi ecd3a4e490 Update script-terminal-modal.tsx 2025-12-10 19:26:19 +01:00
MacRimi 8c0c9bd60a Update script-terminal-modal.tsx 2025-12-10 19:14:57 +01:00
MacRimi 943a8bf02d Update script-terminal-modal.tsx 2025-12-10 18:54:35 +01:00
MacRimi d3beb72652 Update script-terminal-modal.tsx 2025-12-10 18:36:31 +01:00
MacRimi c62dd2014e Update script-terminal-modal.tsx 2025-12-10 18:22:30 +01:00
MacRimi 62fee7827b Update script-terminal-modal.tsx 2025-12-10 18:15:45 +01:00
MacRimi 80b9d16494 Update AppImage 2025-12-10 18:09:21 +01:00
MacRimi cb5581c49f Update AppImage 2025-12-10 17:56:11 +01:00
MacRimi 0098000ae0 Update terminal-panel.tsx 2025-12-10 17:45:38 +01:00
MacRimi ddc8429499 Update AppImage 2025-12-10 17:35:43 +01:00
MacRimi 0424961d46 Update AppImage 2025-12-10 17:08:41 +01:00
MacRimi cbf510cfd1 Update script-terminal-modal.tsx 2025-12-10 17:01:17 +01:00
MacRimi cbb44ae253 Update script-terminal-modal.tsx 2025-12-10 16:56:56 +01:00
MacRimi 4dd4f045aa Update AppImage 2025-12-10 16:50:52 +01:00
ProxMenuxBot ab0d7f8dc6 Update helpers_cache.json 2025-12-09 12:29:33 +00:00
MacRimi 69f93fcb59 Actualizar select_nas_iso.sh 2025-12-08 22:52:34 +01:00
ProxMenuxBot de68e0d7c2 Update helpers_cache.json 2025-12-08 01:05:59 +00:00
MacRimi cdbcb451e1 Update script-terminal-modal.tsx 2025-12-06 23:41:34 +01:00
MacRimi 105c543a98 Update script-terminal-modal.tsx 2025-12-06 23:25:35 +01:00
MacRimi ab421e3184 Update AppImage 2025-12-06 23:06:18 +01:00
MacRimi d76b7a99b8 Apdate AppImage 2025-12-06 22:40:24 +01:00
MacRimi e8dae63e05 Update script-terminal-modal.tsx 2025-12-06 22:33:17 +01:00
MacRimi ea58b70435 Update script-terminal-modal.tsx 2025-12-06 22:26:42 +01:00
MacRimi f90f6f364a Update script-terminal-modal.tsx 2025-12-06 22:20:34 +01:00
MacRimi 7fc967c64c Update script-terminal-modal.tsx 2025-12-06 22:09:54 +01:00
MacRimi 8969a229d1 Update script-terminal-modal.tsx 2025-12-06 22:03:12 +01:00
MacRimi 9601e0428e Update script-terminal-modal.tsx 2025-12-06 21:55:31 +01:00
MacRimi 94fd91ce4a Update script-terminal-modal.tsx 2025-12-06 21:50:15 +01:00
MacRimi 310f972c7f Update script-terminal-modal.tsx 2025-12-06 21:37:43 +01:00
MacRimi 4378a5843c Update AppImage 2025-12-06 21:30:17 +01:00
MacRimi 9bd403ec51 Update script-terminal-modal.tsx 2025-12-06 21:20:23 +01:00
MacRimi 2f53786ca9 Update terminal-panel.tsx 2025-12-06 20:56:36 +01:00
MacRimi 07ed213c94 Update script-terminal-modal.tsx 2025-12-06 20:46:13 +01:00
MacRimi 05a2eca9a7 Update AppImage 2025-12-06 20:27:00 +01:00
MacRimi d30c836d04 Update AppImage 2025-12-06 20:10:24 +01:00
MacRimi 8c623adad8 Update AppImage 2025-12-06 19:43:41 +01:00
MacRimi 5191edfc0c Update AppImage 2025-12-06 19:31:07 +01:00
MacRimi ff99663d5c Update AppImage 2025-12-06 19:19:54 +01:00
MacRimi 360335a608 Update AppImage 2025-12-06 19:03:19 +01:00
MacRimi 1c83e5eeab Update AppImage 2025-12-06 18:36:34 +01:00
MacRimi 122ebb12f4 Update AppImage 2025-12-06 13:54:37 +01:00
MacRimi fed242315d Update AppImage 2025-12-06 13:48:46 +01:00
MacRimi 84e8e18ef8 Update script-terminal-modal.tsx 2025-12-06 13:38:19 +01:00
MacRimi 36a1916b5f Update script-terminal-modal.tsx 2025-12-06 13:28:56 +01:00
MacRimi a1089460d7 Update AppImage 2025-12-06 13:11:04 +01:00
MacRimi c62f0dea6f Update script-terminal-modal.tsx 2025-12-06 13:00:29 +01:00
MacRimi a6c121dc33 Update AppImage 2025-12-06 12:46:41 +01:00
MacRimi c627c65a7d Update AppImage 2025-12-06 12:25:57 +01:00
MacRimi 72006aff21 Update hardware.tsx 2025-12-06 12:14:08 +01:00
MacRimi 68338ebeff Update flask_terminal_routes.py 2025-12-06 12:06:12 +01:00
MacRimi 49b8503b64 Update AppImage 2025-12-06 11:54:36 +01:00
MacRimi 0fc41df7e7 Update flask_terminal_routes.py 2025-12-06 11:37:16 +01:00
MacRimi bb82c52747 Update AppImage 2025-12-06 11:30:49 +01:00
MacRimi a79367fb1c Update script-terminal-modal.tsx 2025-12-06 11:25:27 +01:00
MacRimi 4dbc6db6f0 Update script-terminal-modal.tsx 2025-12-06 11:19:26 +01:00
ProxMenuxBot a50cee62be Update helpers_cache.json 2025-12-05 18:18:53 +00:00
ProxMenuxBot 382aa5cb16 Update helpers_cache.json 2025-12-05 01:05:55 +00:00
ProxMenuxBot d1c2ff277b Update helpers_cache.json 2025-12-03 01:05:21 +00:00
ProxMenuxBot 92b08b5550 Update helpers_cache.json 2025-12-02 12:30:00 +00:00
ProxMenuxBot da85470fef Update helpers_cache.json 2025-12-02 01:04:45 +00:00
MacRimi 9d1e7d94cc Update script-terminal-modal.tsx 2025-12-01 01:40:04 +01:00
MacRimi 65438286ec Update script-terminal-modal.tsx 2025-12-01 01:26:29 +01:00
MacRimi ffa7d27148 Update script-terminal-modal.tsx 2025-12-01 01:22:04 +01:00
MacRimi 4a7d951d0d Update script-terminal-modal.tsx 2025-12-01 01:15:19 +01:00
MacRimi 89f1911a6e Update package.json 2025-12-01 01:07:41 +01:00
MacRimi b990bd1792 Update AppImage 2025-12-01 01:04:31 +01:00
MacRimi 88667416d8 Update hybrid-script-monitor.tsx 2025-12-01 00:38:42 +01:00
MacRimi 216491012e Update hybrid-script-monitor.tsx 2025-12-01 00:31:23 +01:00
MacRimi c88f3dcf75 Update flask_script_runner.py 2025-12-01 00:17:04 +01:00
MacRimi 6c3e21339d Update hybrid-script-monitor.tsx 2025-11-30 23:57:08 +01:00
MacRimi e7f9f9f13d Update hybrid-script-monitor.tsx 2025-11-30 23:47:33 +01:00
MacRimi 6b8d6da5be Update AppImage 2025-11-30 23:40:13 +01:00
MacRimi 8c73c5d662 Update hybrid-script-monitor.tsx 2025-11-30 23:27:32 +01:00
MacRimi f7dc2c9a9e Update hybrid-script-monitor.tsx 2025-11-30 23:20:34 +01:00
MacRimi eadf825b67 Update hybrid-script-monitor.tsx 2025-11-30 23:13:37 +01:00
MacRimi 150999d71b Update AppImage 2025-11-30 23:00:02 +01:00
MacRimi 7cd89a594e Update AppImage 2025-11-30 22:50:40 +01:00
MacRimi b67f1cb4b8 Update hardware.tsx 2025-11-30 22:12:51 +01:00
MacRimi 4678f8c7da Update hardware.tsx 2025-11-30 21:55:37 +01:00
MacRimi 0577f48437 Update hardware.tsx 2025-11-30 21:49:13 +01:00
MacRimi 0c079482f0 Update hardware.tsx 2025-11-30 21:38:49 +01:00
MacRimi 684fe3945d Update AppImage 2025-11-30 21:19:38 +01:00
MacRimi d91d325744 Update route.ts 2025-11-30 20:36:25 +01:00
MacRimi 040d7564ed Update AppImage 2025-11-30 20:25:44 +01:00
MacRimi d1db34445e Update AppImage 2025-11-30 19:40:42 +01:00
MacRimi 9639dd422a Update AppImage 2025-11-30 19:15:07 +01:00
MacRimi f60bfe8c54 Update nvidia installer 2025-11-30 18:29:05 +01:00
MacRimi fe53c11447 Update utils.sh 2025-11-30 18:05:56 +01:00
MacRimi 9bd17bdf6f Update utils.sh 2025-11-30 17:47:30 +01:00
MacRimi 4b64308951 Update utils.sh 2025-11-30 17:12:11 +01:00
MacRimi bb7dacea91 Update AppImage 2025-11-30 16:02:44 +01:00
MacRimi 0a369621a3 Update nvidia_installer.sh 2025-11-30 15:41:40 +01:00
MacRimi e0ee1a50ae new script nvidia 2025-11-30 15:31:19 +01:00
MacRimi 6b49fc4294 Update utils.sh 2025-11-30 12:57:29 +01:00
ProxMenuxBot ed20ea6af4 Update helpers_cache.json 2025-11-30 01:11:38 +00:00
ProxMenuxBot 73fe4dc7a0 Update helpers_cache.json 2025-11-29 18:18:19 +00:00
MacRimi c4967de530 Update utils.sh 2025-11-29 11:04:28 +01:00
ProxMenuxBot bcf3d36ba1 Update helpers_cache.json 2025-11-28 18:19:11 +00:00
MacRimi d52bd7f012 Update update-pve9_2.sh 2025-11-28 19:10:26 +01:00
MacRimi e6232be244 Update update-pve9_2.sh 2025-11-28 17:31:32 +01:00
MacRimi b33f313e2e Update update-pve9_2.sh 2025-11-28 17:06:59 +01:00
ProxMenuxBot 0b4372fe88 Update helpers_cache.json 2025-11-27 18:19:16 +00:00
MacRimi 4e07c7f2dc Update system-overview.tsx 2025-11-27 18:11:56 +01:00
MacRimi 941e194df3 Update system-overview.tsx 2025-11-27 17:50:26 +01:00
MacRimi 2b8f94f457 Update health_monitor.py 2025-11-27 17:30:19 +01:00
MacRimi 7ec8c0cea5 Update health_monitor.py 2025-11-27 14:56:12 +01:00
MacRimi c69384dabd Update health_monitor.py 2025-11-27 13:39:02 +01:00
MacRimi 8c92216a1d Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-11-27 13:29:27 +01:00
MacRimi 41537c0bad Update health_monitor.py 2025-11-27 13:29:15 +01:00
ProxMenuxBot c112f56b37 Update helpers_cache.json 2025-11-27 12:28:29 +00:00
MacRimi f22de50527 Update flask_server.py 2025-11-27 12:45:57 +01:00
MacRimi a22e08f39d Update AppImage 2025-11-27 12:34:51 +01:00
MacRimi 210d470473 Update AppImage 2025-11-27 12:17:52 +01:00
MacRimi 0eebb77438 Update AppImage 2025-11-27 11:58:20 +01:00
MacRimi f819cb9c5f Update hardware.tsx 2025-11-27 09:32:41 +01:00
MacRimi 240963f1f3 Update hardware.tsx 2025-11-27 09:29:12 +01:00
MacRimi 16819d98fa Update AppImage 2025-11-27 09:19:45 +01:00
MacRimi 8be7e0f0cb Update hardware.tsx 2025-11-26 21:33:25 +01:00
MacRimi 3a51daf51b Update hardware.tsx 2025-11-26 21:27:56 +01:00
MacRimi 7622e72b70 Update flask_server.py 2025-11-26 21:15:35 +01:00
MacRimi b59173cac4 Update flask_server.py 2025-11-26 20:46:56 +01:00
MacRimi 18411ee5bd Update AppImage 2025-11-26 20:31:09 +01:00
MacRimi 6e1c6fab2d Update flask_server.py 2025-11-26 20:22:10 +01:00
MacRimi 98eb2d8836 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-11-26 19:38:28 +01:00
MacRimi 504e32f922 Update flask_server.py 2025-11-26 19:38:24 +01:00
ProxMenuxBot c096054b1f Update helpers_cache.json 2025-11-26 18:17:30 +00:00
MacRimi ac2f198851 Update flask_server.py 2025-11-26 18:57:01 +01:00
MacRimi 9aed659f17 Update AppImage 2025-11-26 18:44:37 +01:00
MacRimi 0b8f5d3b22 Update AppImage 2025-11-26 18:00:01 +01:00
MacRimi 55c74e8891 Update AppImage 2025-11-26 17:36:23 +01:00
MacRimi 3a49aa6a67 Update hardware_monitor.py 2025-11-26 16:48:24 +01:00
MacRimi 10770b6fe1 Update AppImage 2025-11-26 12:27:25 +01:00
MacRimi c81ea08f42 Update terminal-panel.tsx 2025-11-25 22:54:27 +01:00
MacRimi 73b6ab4a18 Update terminal-panel.tsx 2025-11-25 22:42:14 +01:00
MacRimi 7497235d7b Update terminal-panel.tsx 2025-11-25 22:35:23 +01:00
MacRimi 27191e4234 Update terminal-panel.tsx 2025-11-25 22:23:02 +01:00
MacRimi 7b0110ce42 Update AppImage 2025-11-25 19:44:40 +01:00
MacRimi 117a635a1e Update sidebar.tsx 2025-11-25 19:31:22 +01:00
MacRimi 98c922fb3e Update AppImage 2025-11-25 19:26:50 +01:00
MacRimi bf84d04f1f Update AppImage 2025-11-25 19:08:00 +01:00
MacRimi f4e358b509 Update AppImge 2025-11-25 19:04:54 +01:00
MacRimi 060ad7966e Update proxmox-dashboard.tsx 2025-11-25 17:21:29 +01:00
MacRimi f0301fd1a4 Update AppImage 2025-11-25 17:21:20 +01:00
MacRimi ae8212a51d Update terminal-panel.tsx 2025-11-24 23:54:46 +01:00
MacRimi 393a0d5cdc Update terminal-panel.tsx 2025-11-24 23:26:36 +01:00
MacRimi 4cf43a8d74 Update terminal-panel.tsx 2025-11-24 23:10:50 +01:00
MacRimi 74b2f47e3a Update terminal-panel.tsx 2025-11-24 22:57:09 +01:00
ProxMenuxBot 1e727db09a Update helpers_cache.json 2025-11-24 18:21:47 +00:00
MacRimi 1daa120d06 Update terminal-panel.tsx 2025-11-24 19:11:11 +01:00
MacRimi a1d2445ae6 Update terminal-panel.tsx 2025-11-24 19:01:15 +01:00
MacRimi 4d4e35e24b Update terminal-panel.tsx 2025-11-24 18:41:42 +01:00
MacRimi 400cc599e3 Update terminal-panel.tsx 2025-11-24 18:28:06 +01:00
MacRimi e55352346b Update terminal-panel.tsx 2025-11-24 18:18:10 +01:00
MacRimi cca226dec0 Update terminal-panel.tsx 2025-11-24 17:50:41 +01:00
MacRimi fec95c91f8 Update terminal-panel.tsx 2025-11-24 17:29:38 +01:00
MacRimi 9955418a8e Remove 'Contributing' section from README 2025-11-24 15:43:10 +01:00
MacRimi 90c7539956 Remove contributing and development setup sections
Removed the contributing section and development setup instructions from the README.
2025-11-24 15:41:52 +01:00
MacRimi a751e45602 Update README.md 2025-11-24 15:40:49 +01:00
MacRimi b50d388f9e Update terminal-panel.tsx 2025-11-24 13:37:17 +01:00
MacRimi fd60292b5d Update terminal-panel.tsx 2025-11-24 13:25:20 +01:00
MacRimi 4ebb0c432e Update terminal-panel.tsx 2025-11-24 13:16:35 +01:00
MacRimi 897b2478e8 Update terminal-panel.tsx 2025-11-24 13:02:04 +01:00
MacRimi b8ebb7f6c4 Update terminal-panel.tsx 2025-11-24 12:24:16 +01:00
MacRimi f32dba72b4 Update terminal-panel.tsx 2025-11-24 12:10:07 +01:00
MacRimi 498ad280e0 Update terminal-panel.tsx 2025-11-24 11:49:20 +01:00
MacRimi 32358de718 Update AppImage 2025-11-24 11:37:00 +01:00
MacRimi 2474a6ce01 Update terminal-panel.tsx 2025-11-24 11:21:50 +01:00
MacRimi 1ba45200ee Update AppImage 2025-11-24 11:01:48 +01:00
MacRimi da793856ce Update terminal-panel.tsx 2025-11-23 23:46:34 +01:00
MacRimi d950588c36 Update terminal-panel.tsx 2025-11-23 23:42:19 +01:00
MacRimi 2b4a5d2ce7 Update terminal-panel.tsx 2025-11-23 23:29:49 +01:00
MacRimi 86daedc802 Update AppImage 2025-11-23 23:23:59 +01:00
MacRimi 3788487196 Update terminal-panel.tsx 2025-11-23 23:10:23 +01:00
MacRimi 25559b7e3e Update terminal-panel.tsx 2025-11-23 22:51:37 +01:00
MacRimi 246db33ee6 Update terminal-panel.tsx 2025-11-23 22:47:30 +01:00
MacRimi d435e9b58b Update terminal-panel.tsx 2025-11-23 22:45:09 +01:00
MacRimi 09ecc79050 Update terminal-panel.tsx 2025-11-23 22:39:46 +01:00
MacRimi 1914435707 Update terminal-panel.tsx 2025-11-23 22:34:50 +01:00
MacRimi f6c237afc5 Update terminal-panel.tsx 2025-11-23 22:30:14 +01:00
MacRimi a1f2579047 Update terminal-panel.tsx 2025-11-23 22:25:21 +01:00
MacRimi 1ea6617a5d Update terminal-panel.tsx 2025-11-23 22:21:24 +01:00
MacRimi 489175aa45 Update AppImage 2025-11-23 22:17:45 +01:00
MacRimi cb72f43b03 Update globals.css 2025-11-23 22:01:50 +01:00
MacRimi 4bbbcc7c39 Update terminal-panel.tsx 2025-11-23 21:43:17 +01:00
MacRimi af1e4884b7 Update AppImage 2025-11-23 21:39:45 +01:00
MacRimi 5213d6255a Update globals.css 2025-11-23 21:32:46 +01:00
MacRimi a9af689aa5 Update terminal-panel.tsx 2025-11-23 21:22:52 +01:00
MacRimi 407a9f7780 Update terminal-panel.tsx 2025-11-23 21:15:54 +01:00
MacRimi a0ca667ca7 Update terminal-panel.tsx 2025-11-23 21:08:14 +01:00
MacRimi c2f6f97c34 Update terminal-panel.tsx 2025-11-23 20:59:50 +01:00
MacRimi 2daefbe2f4 Update terminal-panel.tsx 2025-11-23 20:47:42 +01:00
MacRimi 84b0c9d4b7 Update terminal-panel.tsx 2025-11-23 20:26:26 +01:00
MacRimi 0d848569f0 Update terminal-panel.tsx 2025-11-23 20:19:07 +01:00
MacRimi 611f8397ca Update terminal-panel.tsx 2025-11-23 20:16:05 +01:00
MacRimi 11ed0a1367 Update terminal-panel.tsx 2025-11-23 20:06:33 +01:00
MacRimi ff51966fbb Update terminal-panel.tsx 2025-11-23 19:58:31 +01:00
MacRimi 5491d51eba Update terminal-panel.tsx 2025-11-23 19:53:39 +01:00
MacRimi 61a5a7e929 Update terminal-panel.tsx 2025-11-23 19:46:57 +01:00
MacRimi 3de000bc94 Update terminal-panel.tsx 2025-11-23 19:41:36 +01:00
MacRimi ef456e6ea0 Update terminal-panel.tsx 2025-11-23 19:30:01 +01:00
MacRimi 2a8b67e22a Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-11-23 19:25:58 +01:00
MacRimi c35b66f6e1 Update terminal-panel.tsx 2025-11-23 19:25:49 +01:00
ProxMenuxBot c8348dcaaa Update helpers_cache.json 2025-11-23 18:18:06 +00:00
MacRimi e38174110e Update terminal-panel.tsx 2025-11-23 19:14:18 +01:00
MacRimi a95130c01f Update terminal-panel.tsx 2025-11-23 19:10:22 +01:00
MacRimi 0e93417090 Update terminal-panel.tsx 2025-11-23 18:56:14 +01:00
MacRimi 07054bf55a Update terminal-panel.tsx 2025-11-23 18:51:46 +01:00
MacRimi 368eab476a Update AppImage 2025-11-23 18:41:58 +01:00
MacRimi 996679a2d2 Update AppImage 2025-11-23 18:34:47 +01:00
MacRimi 85a6943cd5 Update terminal-panel.tsx 2025-11-23 18:17:00 +01:00
MacRimi 0b96893f3b Update terminal-panel.tsx 2025-11-23 18:12:09 +01:00
MacRimi 846e2e27ba Update terminal-panel.tsx 2025-11-23 17:57:46 +01:00
MacRimi 43ea9b7696 Update globals.css 2025-11-23 17:42:00 +01:00
MacRimi 9dd4df2ca9 Update terminal-panel.tsx 2025-11-23 17:29:11 +01:00
MacRimi 2b4fb55526 Update globals.css 2025-11-23 17:17:26 +01:00
MacRimi 72cf16301f Update AppImage 2025-11-23 17:09:25 +01:00
MacRimi c512dde028 Update terminal-panel.tsx 2025-11-23 16:56:41 +01:00
MacRimi 1e13c7ab31 Update globals.css 2025-11-23 16:48:32 +01:00
MacRimi cdbab86dee Update AppImage 2025-11-23 16:32:01 +01:00
MacRimi fec03d1fd4 Update AppImage 2025-11-23 16:10:41 +01:00
MacRimi 6aa24e23c0 Update terminal-panel.tsx 2025-11-23 14:16:21 +01:00
MacRimi 78770d1da5 Update terminal-panel.tsx 2025-11-23 14:04:43 +01:00
MacRimi 6f72447e2e Update terminal-panel.tsx 2025-11-23 13:52:11 +01:00
MacRimi cb75a15a6f Update terminal-panel.tsx 2025-11-23 13:47:40 +01:00
MacRimi c3555237b3 Update terminal-panel.tsx 2025-11-23 13:39:54 +01:00
MacRimi e4a2cc7ac8 Update terminal-panel.tsx 2025-11-23 13:28:30 +01:00
MacRimi 3900d305b9 Update terminal-panel.tsx 2025-11-23 12:15:55 +01:00
MacRimi cb3d501649 Update terminal-panel.tsx 2025-11-23 12:02:34 +01:00
MacRimi 28323a486a Update terminal-panel.tsx 2025-11-23 11:56:38 +01:00
MacRimi dfcad4b9fd Update terminal-panel.tsx 2025-11-23 11:51:51 +01:00
MacRimi 6fb2869cd8 Update terminal-panel.tsx 2025-11-23 11:42:30 +01:00
MacRimi e764e39ba9 Update terminal-panel.tsx 2025-11-23 11:21:17 +01:00
MacRimi 128077dcbc Update AppImage 2025-11-23 10:57:28 +01:00
MacRimi 1c51107f1e Update AppImage 2025-11-22 23:59:55 +01:00
MacRimi d154cab054 Update proxmox-dashboard.tsx 2025-11-22 23:55:10 +01:00
MacRimi 7ed4368d5b Update appImage 2025-11-22 23:46:43 +01:00
MacRimi ee64df2376 Update AppImage 2025-11-22 23:34:09 +01:00
MacRimi b13f03eb97 Update terminal-panel.tsx 2025-11-22 23:19:10 +01:00
MacRimi 8d20829428 Update AppImage 2025-11-22 23:08:11 +01:00
MacRimi 97401f609e Update terminal-panel.tsx 2025-11-22 22:57:49 +01:00
MacRimi fe074729ea Update AppImage 2025-11-22 22:50:23 +01:00
MacRimi db5141e010 Update AppImage 2025-11-22 22:28:34 +01:00
MacRimi 4564fdc6aa Update AppImage 2025-11-22 22:15:39 +01:00
MacRimi a477b36a57 Update terminal-panel.tsx 2025-11-22 22:06:07 +01:00
MacRimi 3b8ae2c879 Update terminal-panel.tsx 2025-11-22 21:58:29 +01:00
MacRimi ebe3a51398 Update appImage 2025-11-22 21:43:14 +01:00
MacRimi 76d22f0cb5 Update AppImage 2025-11-22 21:35:22 +01:00
MacRimi c61d676dfb Update terminal-panel.tsx 2025-11-22 21:26:35 +01:00
MacRimi b1913e7204 Update terminal-panel.tsx 2025-11-22 21:12:05 +01:00
MacRimi b6609e0a14 Update AppImage 2025-11-22 21:06:44 +01:00
MacRimi 55fa759344 Update AppImage 2025-11-22 20:58:06 +01:00
MacRimi 8992a713cc Update AppImage 2025-11-22 20:50:05 +01:00
MacRimi c55dcec252 Update AppImage 2025-11-22 20:41:36 +01:00
MacRimi e3dd6cbef5 Update AppImage 2025-11-22 20:27:41 +01:00
MacRimi dd3e5ea368 Update globals.css 2025-11-22 20:20:57 +01:00
MacRimi ac2e77e0d6 Update globals.css 2025-11-22 20:17:10 +01:00
MacRimi 9f57622f54 Update globals.css 2025-11-22 20:13:02 +01:00
MacRimi cfed460eba Update globals.css 2025-11-22 20:09:36 +01:00
MacRimi 06f97b671f Update globals.css 2025-11-22 20:06:43 +01:00
MacRimi aebf83d735 Update AppImage 2025-11-22 20:02:42 +01:00
MacRimi 31894dd117 Update globals.css 2025-11-22 19:44:00 +01:00
MacRimi e041d802ec Update globals.css 2025-11-22 19:37:31 +01:00
MacRimi 82ea15388c Update globals.css 2025-11-22 19:33:58 +01:00
MacRimi bf9ed8ff00 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-11-22 19:27:57 +01:00
MacRimi c02606df6a Update Appimage 2025-11-22 19:27:45 +01:00
ProxMenuxBot 7372e2e385 Update helpers_cache.json 2025-11-22 18:17:41 +00:00
MacRimi ba86fa6d3e Update terminal-panel.tsx 2025-11-22 19:07:27 +01:00
MacRimi 0e434cbd1c Update AppImage 2025-11-22 18:15:12 +01:00
MacRimi c89300022a Update AppImage 2025-11-22 17:54:30 +01:00
MacRimi 1300756d6f Update AppImage 2025-11-22 17:40:47 +01:00
MacRimi c4ad02ff92 Update terminal-panel.tsx 2025-11-22 17:32:47 +01:00
MacRimi b3f47f140a Update AppImage 2025-11-22 17:16:18 +01:00
MacRimi 2206b3d5b5 Update terminal-panel.tsx 2025-11-22 11:56:29 +01:00
MacRimi b08f8a450d Update terminal-panel.tsx 2025-11-22 11:43:17 +01:00
MacRimi 37c8be8a6e Update terminal-panel.tsx 2025-11-22 11:21:11 +01:00
MacRimi ae58c265a0 Update AppImage 2025-11-22 11:04:21 +01:00
MacRimi 54e6d1aa16 Update AppImage 2025-11-22 10:33:35 +01:00
MacRimi 4ddb5f14d9 Update flask_terminal_routes.py 2025-11-21 20:12:07 +01:00
MacRimi 623aec495b Update proxmox-dashboard.tsx 2025-11-21 19:56:22 +01:00
MacRimi f6d2b9bad0 Update AppImage 2025-11-21 19:49:42 +01:00
MacRimi 08b5a278f3 Update proxmox-dashboard.tsx 2025-11-21 19:44:15 +01:00
MacRimi f62b30b50d Update AppImage 2025-11-21 19:32:27 +01:00
MacRimi 50e3b8e7d4 Update terminal-panel.tsx 2025-11-21 19:25:23 +01:00
MacRimi e26956dbe8 Update terminal-panel.tsx 2025-11-21 19:15:35 +01:00
MacRimi cff2c12d70 Update build_appimage.sh 2025-11-21 18:53:30 +01:00
MacRimi 5781d532a4 Update build_appimage.sh 2025-11-21 18:47:56 +01:00
MacRimi f161a593f8 Update page.tsx 2025-11-21 18:40:19 +01:00
MacRimi 5725d5a2fe Update AppImage 2025-11-21 18:36:09 +01:00
MacRimi 23280fd97b Update AppImage 2025-11-21 18:32:10 +01:00
MacRimi fe6679f16a Update update-pve9_2.sh 2025-11-21 18:01:34 +01:00
MacRimi 19a95a3670 Update network-traffic-chart.tsx 2025-11-19 22:54:41 +01:00
MacRimi 90cffb3791 Update format-network.ts 2025-11-19 22:47:24 +01:00
MacRimi 31168fbeca Update network-metrics.tsx 2025-11-19 22:33:28 +01:00
MacRimi c4cce5d184 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-11-19 22:05:49 +01:00
MacRimi 08b59dd082 Update network-metrics.tsx 2025-11-19 22:05:45 +01:00
ProxMenuxBot 4aaf1a5868 Update helpers_cache.json 2025-11-19 18:20:10 +00:00
MacRimi 6e78fa0b1f Update AppImage 2025-11-19 18:43:08 +01:00
MacRimi e1a42189a6 Update system-overview.tsx 2025-11-19 18:18:47 +01:00
MacRimi 386e0c9b6b Update format-network.ts 2025-11-19 18:09:52 +01:00
MacRimi 3b1b423936 Update AppImage 2025-11-19 17:58:03 +01:00
MacRimi 8e8e8161bb Update AppImage 2025-11-19 17:30:01 +01:00
MacRimi b368fde82d Update appImage 2025-11-19 17:15:32 +01:00
ProxMenuxBot 7267111083 Update helpers_cache.json 2025-11-19 12:28:30 +00:00
MacRimi d05dab6633 Update AppImage 2025-11-18 22:05:54 +01:00
MacRimi e1409a8045 Update AppImage 2025-11-18 21:27:24 +01:00
MacRimi ae69fec7ce Update AppImage 2025-11-18 21:11:56 +01:00
MacRimi a2862f22f6 Update AppImage 2025-11-18 20:56:15 +01:00
MacRimi 7db8e18bcc Update AppImage 2025-11-18 19:43:18 +01:00
MacRimi 0ffe1272fe Update virtual-machines.tsx 2025-11-18 19:24:23 +01:00
MacRimi 92b54075c4 Update AppImage 2025-11-18 19:11:14 +01:00
MacRimi ce5c679d6b Update AppImage 2025-11-18 19:00:51 +01:00
MacRimi 4f61386b21 Update flask_server.py 2025-11-18 18:33:57 +01:00
MacRimi 2738ae1abc Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-11-18 17:14:46 +01:00
MacRimi f5e43ff7b4 Update flask_server.py 2025-11-18 17:14:30 +01:00
MacRimi 63c499bf2c Merge pull request #93 from riri-314/networkGraph
Add option to change network unit
2025-11-18 17:00:23 +01:00
riri-314 9e72720bda revert code formating 2025-11-18 13:30:37 +01:00
ProxMenuxBot bbe10b2dab Update helpers_cache.json 2025-11-18 12:29:38 +00:00
riri-314 f3b0784651 Network metrics take network unit into acount 2025-11-18 13:19:23 +01:00
riri-314 9c0ea9b1c7 System oberview take network unit into account 2025-11-18 13:08:39 +01:00
riri-314 620a088c6c Removed debug code 2025-11-18 11:32:32 +01:00
riri-314 867a74cffb Added optional prop to display network traffic in bits or bytes 2025-11-18 11:26:49 +01:00
riri-314 f2316fdd3a Add setting to change network unit 2025-11-18 10:58:06 +01:00
MacRimi 7d49d4f948 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-11-17 19:26:44 +01:00
MacRimi f85b2b889c Update flask_server.py 2025-11-17 19:26:30 +01:00
ProxMenuxBot 9471ac4a52 Update helpers_cache.json 2025-11-17 18:20:20 +00:00
MacRimi db520c39e3 Update flask_server.py 2025-11-17 18:48:20 +01:00
MacRimi cc59fbe2ba Delete ProxMenux-1.0.2-deb.AppImage 2025-11-17 18:22:15 +01:00
MacRimi e260af58f2 Create ProxMenux-1.0.2-deb.AppImage 2025-11-17 17:53:50 +01:00
MacRimi 166fc6dad9 Update AppImage 2025-11-17 17:47:58 +01:00
MacRimi 959433d737 Update install_proxmenux.sh 2025-11-17 17:11:41 +01:00
MacRimi f9fa9ce6d8 Update install_proxmenux.sh 2025-11-17 16:58:45 +01:00
MacRimi 6b3a41dfe0 Update install_proxmenux.sh 2025-11-17 16:40:56 +01:00
ProxMenuxBot 37428ecca4 Update helpers_cache.json 2025-11-16 18:18:01 +00:00
MacRimi 6934df253f update menu 2025-11-16 10:19:50 +01:00
MacRimi 00782598a4 Update menu 2025-11-16 01:21:07 +01:00
MacRimi 565c500810 Update menu 2025-11-16 01:17:54 +01:00
MacRimi e3c16166e6 Update menu 2025-11-16 01:15:48 +01:00
MacRimi cfa8d1b689 Update menu 2025-11-16 01:13:24 +01:00
MacRimi a19397f9b5 Update menu 2025-11-16 01:11:49 +01:00
MacRimi ddfc80b45f Update menu 2025-11-16 01:07:25 +01:00
MacRimi 8591f9b2a1 Update menu 2025-11-16 01:04:16 +01:00
MacRimi cb26a55e65 Update menu 2025-11-16 00:59:54 +01:00
MacRimi ef92394685 Update menu 2025-11-16 00:56:04 +01:00
MacRimi d588ef438e Update menu 2025-11-16 00:53:40 +01:00
MacRimi 09cd363b11 Update menu 2025-11-16 00:51:12 +01:00
MacRimi 2d5c7fdbb5 Update menu 2025-11-16 00:47:26 +01:00
MacRimi 2f0e28368d Update menu 2025-11-16 00:44:00 +01:00
MacRimi f7f1a2a3b3 Update menu 2025-11-16 00:33:26 +01:00
MacRimi 30afb85260 Update menu 2025-11-16 00:23:14 +01:00
MacRimi 78d883a1b4 Update menu 2025-11-16 00:17:22 +01:00
MacRimi 7913b673a3 Update menu 2025-11-16 00:13:52 +01:00
MacRimi 5edc27297f Update menu 2025-11-16 00:02:52 +01:00
MacRimi ebc24c2476 Update menu 2025-11-15 23:58:26 +01:00
MacRimi ed7dd037e5 Update menu 2025-11-15 23:52:39 +01:00
MacRimi 277924c04d Update menu 2025-11-15 23:48:50 +01:00
MacRimi 26ea0feddb Update menu 2025-11-15 23:46:02 +01:00
MacRimi 63c1eab930 Update menu 2025-11-15 23:42:17 +01:00
MacRimi 813e7711df Update menu 2025-11-15 23:40:46 +01:00
MacRimi 6c1f50a230 Remove Nginx configuration example from README
Removed example Nginx configuration from README.
2025-11-15 16:17:31 +01:00
MacRimi 470b6359ba Remove onboarding image sections from README
Removed multiple image sections related to storage management, network monitoring, virtual machines, hardware information, and system logs from the README.
2025-11-15 16:16:30 +01:00
MacRimi 2f45233748 Fix formatting issues in menu file 2025-11-15 15:20:12 +01:00
MacRimi 82fd52f572 Add LOCAL_SCRIPTS variable to menu configuration 2025-11-15 14:30:34 +01:00
MacRimi ed6331e6a4 Update config_menu.sh 2025-11-14 22:17:11 +01:00
MacRimi 2ae9188535 Update version.txt 2025-11-14 21:44:50 +01:00
MacRimi 1a55a5394a Update menu 2025-11-14 21:43:38 +01:00
MacRimi 99d2f37cfc Update version.txt 2025-11-14 21:42:15 +01:00
MacRimi 09b531e0c1 Update version.txt 2025-11-14 21:40:38 +01:00
MacRimi 232e872c0d Update menu 2025-11-14 21:37:55 +01:00
MacRimi acdb0d2838 Update menu 2025-11-14 21:30:58 +01:00
MacRimi bd0ea1379f Update menu 2025-11-14 21:02:43 +01:00
MacRimi 5461ea1a3a Update menu 2025-11-14 20:48:38 +01:00
MacRimi 200ee075b5 Update menu 2025-11-14 20:38:07 +01:00
MacRimi 79e9e5fcf1 Update version.txt 2025-11-14 20:33:07 +01:00
MacRimi a2df23d562 Update version.txt 2025-11-14 20:23:30 +01:00
MacRimi 55af3d7f65 Update version.txt 2025-11-14 20:21:12 +01:00
MacRimi ef54f3fe59 Update ChangeLog 2025-11-14 20:14:03 +01:00
MacRimi 9d84ff6aa7 Update version.txt 2025-11-14 20:10:57 +01:00
MacRimi ee26006f3c Update CHANGELOG.md 2025-11-14 20:08:07 +01:00
239 changed files with 101878 additions and 32090 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())
@@ -1,24 +1,29 @@
name: Build ProxMenux Monitor AppImage
name: Build AppImage Release
on:
workflow_dispatch:
workflow_dispatch:
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Checkout main
uses: actions/checkout@v6
with:
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20'
node-version: '22'
- name: Install dependencies
working-directory: AppImage
@@ -45,13 +50,6 @@ jobs:
id: version
working-directory: AppImage
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Upload AppImage artifact
uses: actions/upload-artifact@v4
with:
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
path: AppImage/dist/*.AppImage
retention-days: 30
- name: Generate SHA256 checksum
run: |
@@ -60,22 +58,26 @@ jobs:
echo "Generated SHA256:"
cat ProxMenux-Monitor.AppImage.sha256
- name: Upload AppImage and checksum to /AppImage folder in main
- 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"
git fetch origin main
git checkout main
rm -f AppImage/*.AppImage AppImage/*.sha256 || true
# Copy new files
cp AppImage/dist/*.AppImage AppImage/
cp AppImage/dist/ProxMenux-Monitor.AppImage.sha256 AppImage/
git add AppImage/*.AppImage AppImage/*.sha256
git commit -m "Update AppImage build ($(date +'%Y-%m-%d %H:%M:%S'))" || echo "No changes to commit"
git commit -m "Update AppImage release build ($(date +'%Y-%m-%d %H:%M:%S'))" || echo "No changes to commit"
git push origin main
+83
View File
@@ -0,0 +1,83 @@
name: Build AppImage Beta
on:
workflow_dispatch:
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build:
runs-on: ubuntu-22.04
steps:
- name: Checkout develop
uses: actions/checkout@v6
with:
ref: develop
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 }}-beta-AppImage
path: |
AppImage/dist/*.AppImage
AppImage/dist/*.sha256
retention-days: 30
- name: Commit AppImage to develop
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 beta build ($(date +'%Y-%m-%d %H:%M:%S'))" || echo "No changes to commit"
git push origin develop
+7 -4
View File
@@ -9,18 +9,21 @@ on:
paths: [ 'AppImage/**' ]
workflow_dispatch:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20'
node-version: '22'
- name: Install dependencies
working-directory: AppImage
@@ -49,7 +52,7 @@ jobs:
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Upload AppImage artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
path: AppImage/dist/*.AppImage
+1 -1
View File
@@ -1 +1 @@
f35de512c1a19843d15a9a3263a5104759d041ffc9d01249450babe0b0c3f889 ProxMenux-1.0.1.AppImage
3b28537fe679b87f166bd5b01de05c71d1b4303f765c87f3a23b216d433120c2 ProxMenux-1.2.0.AppImage
-59
View File
@@ -21,7 +21,6 @@ A modern, responsive dashboard for monitoring Proxmox VE systems built with Next
- [Integration Examples](#integration-examples)
- [Homepage Integration](#homepage-integration)
- [Home Assistant Integration](#home-assistant-integration)
- [Contributing](#contributing)
- [License](#license)
---
@@ -43,35 +42,6 @@ Get a quick overview of ProxMenux Monitor's main features:
<em>System Overview - Monitor CPU, memory, temperature, and uptime in real-time</em>
</p>
<p align="center">
<img src="public/images/onboarding/imagen2.png" alt="Storage Management" width="800"/>
<br/>
<em>Storage Management - Visual representation of disk usage and health</em>
</p>
<p align="center">
<img src="public/images/onboarding/imagen3.png" alt="Network Monitoring" width="800"/>
<br/>
<em>Network Monitoring - Real-time traffic graphs and interface statistics</em>
</p>
<p align="center">
<img src="public/images/onboarding/imagen4.png" alt="Virtual Machines & LXC" width="800"/>
<br/>
<em>VMs & LXC Containers - Comprehensive view with resource usage and controls</em>
</p>
<p align="center">
<img src="public/images/onboarding/imagen5.png" alt="Hardware Information" width="800"/>
<br/>
<em>Hardware Information - Detailed specs for CPU, GPU, and PCIe devices</em>
</p>
<p align="center">
<img src="public/images/onboarding/imagen6.png" alt="System Logs" width="800"/>
<br/>
<em>System Logs - Real-time monitoring with filtering and search</em>
</p>
---
@@ -122,17 +92,6 @@ ProxMenux Monitor includes built-in support for reverse proxy configurations. If
- Adjust API endpoints to work correctly through the proxy
- Maintain full functionality for all features including authentication and API access
**Example Nginx configuration:**
```nginx
location /proxmenux-monitor/ {
proxy_pass http://localhost:8008/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
```
## Authentication & Security
@@ -770,18 +729,6 @@ entities:
![Home Assistant Integration Example](AppImage/public/images/docs/homeassistant-integration.png)
---
## Contributing
Contributions are welcome! Please feel free to submit issues, feature requests, or pull requests.
### Development Setup
1. Clone the repository
2. Install dependencies: `npm install`
3. Run development server: `npm run dev`
4. Build AppImage: `./build_appimage.sh`
---
@@ -799,13 +746,7 @@ Under the following terms:
For more details, see the [full license](https://creativecommons.org/licenses/by-nc/4.0/).
---
## Support
For support, feature requests, or bug reports, please visit:
- GitHub Issues: [github.com/your-repo/issues](https://github.com/your-repo/issues)
- Documentation: [github.com/your-repo/wiki](https://github.com/your-repo/wiki)
---
+31
View File
@@ -144,3 +144,34 @@
stroke: var(--border);
}
}
/* ===================== */
/* Ajustes para xterm.js */
/* ===================== */
/* Quitar padding para que la terminal ocupe el 100% del ancho */
.xterm {
padding: 0 !important;
}
/* Por si acaso el viewport añade padding extra */
.xterm .xterm-viewport {
padding: 0 !important;
}
/* Opcional: asegurar que no haya margen raro */
.xterm-rows {
margin: 0 !important;
}
/* ===================== */
/* Progress Animations */
/* ===================== */
@keyframes indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}
+8 -2
View File
@@ -1,5 +1,5 @@
import type React from "react"
import type { Metadata } from "next"
import type { Metadata, Viewport } from "next"
import { GeistSans } from "geist/font/sans"
import { GeistMono } from "geist/font/mono"
import { ThemeProvider } from "../components/theme-provider"
@@ -20,7 +20,13 @@ export const metadata: Metadata = {
shortcut: "/favicon.ico",
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
},
viewport: "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no",
}
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#2b2f36" },
+20 -7
View File
@@ -29,10 +29,19 @@ 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()
console.log("[v0] Auth status:", data)
const authenticated = data.auth_enabled ? data.authenticated : true
setAuthStatus({
@@ -41,8 +50,8 @@ export default function Home() {
authConfigured: data.auth_configured,
authenticated,
})
} catch (error) {
console.error("[v0] Failed to check auth status:", error)
} catch {
// API not available - assume no auth configured (silent fail, no console error)
setAuthStatus({
loading: false,
authEnabled: false,
@@ -63,9 +72,13 @@ export default function Home() {
if (authStatus.loading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground">Loading...</p>
<div className="flex flex-col items-center 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...</div>
<p className="text-xs text-muted-foreground">Connecting to ProxMenux Monitor</p>
</div>
</div>
)
+39 -10
View File
@@ -2,10 +2,10 @@
import { useState, useEffect } from "react"
import { Button } from "./ui/button"
import { Dialog, DialogContent } from "./ui/dialog"
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Shield, Lock, User, AlertCircle } from "lucide-react"
import { Shield, Lock, User, AlertCircle, Eye, EyeOff } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
interface AuthSetupProps {
@@ -20,23 +20,33 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
useEffect(() => {
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
}
}
@@ -135,6 +145,9 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogTitle className="sr-only">
{step === "choice" ? "Setup Dashboard Protection" : "Create Password"}
</DialogTitle>
{step === "choice" ? (
<div className="space-y-6 py-2">
<div className="text-center space-y-2">
@@ -210,7 +223,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type="password"
type={showPassword ? "text" : "password"}
placeholder="Enter password"
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -218,6 +231,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
disabled={loading}
autoComplete="new-password"
/>
<Button
variant="ghost"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2"
disabled={loading}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
@@ -229,7 +250,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="confirm-password"
type="password"
type={showConfirmPassword ? "text" : "password"}
placeholder="Confirm password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
@@ -237,6 +258,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
disabled={loading}
autoComplete="new-password"
/>
<Button
variant="ghost"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2"
disabled={loading}
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
@@ -0,0 +1,255 @@
"use client"
import { cn } from "@/lib/utils"
interface GpuSwitchModeIndicatorProps {
mode: "lxc" | "vm" | "unknown"
isEditing?: boolean
pendingMode?: "lxc" | "vm" | null
onToggle?: (e: React.MouseEvent) => void
className?: string
}
export function GpuSwitchModeIndicator({
mode,
isEditing = false,
pendingMode = null,
onToggle,
className,
}: GpuSwitchModeIndicatorProps) {
const displayMode = pendingMode ?? mode
const isLxcActive = displayMode === "lxc"
const isVmActive = displayMode === "vm"
const hasChanged = pendingMode !== null && pendingMode !== mode
// Colors
const activeColor = isLxcActive ? "#3b82f6" : isVmActive ? "#a855f7" : "#6b7280"
const inactiveColor = "#374151" // gray-700 for dark theme
const lxcColor = isLxcActive ? "#3b82f6" : inactiveColor
const vmColor = isVmActive ? "#a855f7" : inactiveColor
const handleClick = (e: React.MouseEvent) => {
// Only stop propagation and handle toggle when in editing mode
if (isEditing) {
e.stopPropagation()
if (onToggle) {
onToggle(e)
}
}
// When not editing, let the click propagate to the card to open the modal
}
return (
<div
className={cn(
"flex items-center gap-6",
isEditing && "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 ? "#f59e0b20" : `${activeColor}20`}
stroke={isEditing ? "#f59e0b" : activeColor}
strokeWidth="3"
className="transition-all duration-300"
/>
<circle
cx="95"
cy="50"
r="6"
fill={isEditing ? "#f59e0b" : activeColor}
className="transition-all duration-300"
/>
{/* LXC Branch Line - going up-right */}
<path
d="M 109 42 L 135 20"
fill="none"
stroke={lxcColor}
strokeWidth={isLxcActive ? "3.5" : "2"}
strokeLinecap="round"
className="transition-all duration-300"
/>
{/* VM Branch Line - going down-right */}
<path
d="M 109 58 L 135 80"
fill="none"
stroke={vmColor}
strokeWidth={isVmActive ? "3.5" : "2"}
strokeLinecap="round"
className="transition-all duration-300"
/>
{/* LXC Container Icon - Server/Stack icon */}
<g transform="translate(138, 2)">
{/* Container box */}
<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"
/>
{/* Container layers/lines */}
<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" />
{/* Status dots */}
<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>
{/* LXC Label */}
<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>
{/* VM Monitor Icon */}
<g transform="translate(138, 65)">
{/* Monitor screen */}
<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"
/>
{/* Screen inner/shine */}
<rect
x="5"
y="3"
width="22"
height="12"
rx="1"
fill={isVmActive ? `${vmColor}30` : `${vmColor}10`}
className="transition-all duration-300"
/>
{/* Monitor stand */}
<line x1="16" y1="18" x2="16" y2="24" stroke={vmColor} strokeWidth={isVmActive ? "2.5" : "1.5"} strokeLinecap="round" className="transition-all duration-300" />
{/* Monitor base */}
<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>
{/* VM Label */}
<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>
</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",
isLxcActive ? "text-blue-500" : isVmActive ? "text-purple-500" : "text-muted-foreground"
)}
>
{isLxcActive
? "Ready for LXC containers"
: isVmActive
? "Ready for VM passthrough"
: "Mode unknown"}
</span>
<span className="text-sm text-muted-foreground">
{isLxcActive
? "Native driver active"
: isVmActive
? "VFIO-PCI driver active"
: "No driver detected"}
</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
+621 -142
View File
@@ -2,7 +2,8 @@
import type React from "react"
import { useState, useEffect } from "react"
import { useState, useEffect, useCallback } from "react"
import { getAuthToken } from "@/lib/api-config"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
@@ -11,6 +12,7 @@ import {
CheckCircle2,
AlertTriangle,
XCircle,
Info,
Activity,
Cpu,
MemoryStick,
@@ -23,15 +25,42 @@ import {
RefreshCw,
Shield,
X,
Clock,
BellOff,
ChevronRight,
Settings2,
HelpCircle,
} from "lucide-react"
interface CategoryCheck {
status: string
reason?: string
details?: any
checks?: Record<string, { status: string; detail: string; [key: string]: any }>
dismissable?: boolean
[key: string]: any
}
interface DismissedError {
error_key: string
category: string
severity: string
reason: string
dismissed: boolean
permanent?: boolean
suppression_remaining_hours: number
suppression_hours?: number
resolved_at: string
}
interface CustomSuppression {
key: string
label: string
category: string
icon: string
hours: number
}
interface HealthDetails {
overall: string
summary: string
@@ -50,6 +79,14 @@ interface HealthDetails {
timestamp: string
}
interface FullHealthData {
health: HealthDetails
active_errors: any[]
dismissed: DismissedError[]
custom_suppressions: CustomSuppression[]
timestamp: string
}
interface HealthStatusModalProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -57,65 +94,174 @@ interface HealthStatusModalProps {
}
const CATEGORIES = [
{ key: "cpu", label: "CPU Usage & Temperature", Icon: Cpu },
{ key: "memory", label: "Memory & Swap", Icon: MemoryStick },
{ key: "storage", label: "Storage Mounts & Space", Icon: HardDrive },
{ key: "disks", label: "Disk I/O & Errors", Icon: Disc },
{ key: "network", label: "Network Interfaces", Icon: Network },
{ key: "vms", label: "VMs & Containers", Icon: Box },
{ key: "services", label: "PVE Services", Icon: Settings },
{ key: "logs", label: "System Logs", Icon: FileText },
{ key: "updates", label: "System Updates", Icon: RefreshCw },
{ key: "security", label: "Security & Certificates", Icon: Shield },
{ key: "cpu", category: "temperature", label: "CPU Usage & Temperature", Icon: Cpu },
{ key: "memory", category: "memory", label: "Memory & Swap", Icon: MemoryStick },
{ key: "storage", category: "storage", label: "Storage Mounts & Space", Icon: HardDrive },
{ key: "disks", category: "disks", label: "Disk I/O & Errors", Icon: Disc },
{ key: "network", category: "network", label: "Network Interfaces", Icon: Network },
{ key: "vms", category: "vms", label: "VMs & Containers", Icon: Box },
{ key: "services", category: "pve_services", label: "PVE Services", Icon: Settings },
{ key: "logs", category: "logs", label: "System Logs", Icon: FileText },
{ key: "updates", category: "updates", label: "System Updates", Icon: RefreshCw },
{ key: "security", category: "security", label: "Security & Certificates", Icon: Shield },
]
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
const [loading, setLoading] = useState(true)
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
const [customSuppressions, setCustomSuppressions] = useState<CustomSuppression[]>([])
const [error, setError] = useState<string | null>(null)
const [dismissingKey, setDismissingKey] = useState<string | null>(null)
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
useEffect(() => {
if (open) {
fetchHealthDetails()
}
}, [open])
const fetchHealthDetails = async () => {
const fetchHealthDetails = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await fetch(getApiUrl("/api/health/details"))
if (!response.ok) {
throw new Error("Failed to fetch health details")
let newOverallStatus = "OK"
// Use the new combined endpoint for fewer round-trips
const token = getAuthToken()
const authHeaders: Record<string, string> = {}
if (token) {
authHeaders["Authorization"] = `Bearer ${token}`
}
const data = await response.json()
console.log("[v0] Health data received:", data)
setHealthData(data)
const response = await fetch(getApiUrl("/api/health/full"), { headers: authHeaders })
let infoCount = 0
if (!response.ok) {
// Fallback to legacy endpoint
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders })
if (!legacyResponse.ok) throw new Error("Failed to fetch health details")
const data = await legacyResponse.json()
setHealthData(data)
setDismissedItems([])
setCustomSuppressions([])
newOverallStatus = data?.overall || "OK"
// Count INFO categories from legacy data
if (data?.details) {
CATEGORIES.forEach(({ key }) => {
const cat = data.details[key as keyof typeof data.details]
if (cat && cat.status?.toUpperCase() === "INFO") {
infoCount++
}
})
}
} else {
const fullData: FullHealthData = await response.json()
setHealthData(fullData.health)
setDismissedItems(fullData.dismissed || [])
setCustomSuppressions(fullData.custom_suppressions || [])
newOverallStatus = fullData.health?.overall || "OK"
// Get categories that have dismissed items (these become INFO)
const customCats = new Set((fullData.custom_suppressions || []).map((cs: { category: string }) => cs.category))
const filteredDismissed = (fullData.dismissed || []).filter((item: { category: string }) => !customCats.has(item.category))
const categoriesWithDismissed = new Set<string>()
filteredDismissed.forEach((item: { category: string }) => {
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
if (catMeta) {
categoriesWithDismissed.add(catMeta.key)
}
})
// Count effective INFO categories (original INFO + OK categories with dismissed)
if (fullData.health?.details) {
CATEGORIES.forEach(({ key }) => {
const cat = fullData.health.details[key as keyof typeof fullData.health.details]
if (cat) {
const originalStatus = cat.status?.toUpperCase()
// Count as INFO if: originally INFO OR (originally OK and has dismissed items)
if (originalStatus === "INFO" || (originalStatus === "OK" && categoriesWithDismissed.has(key))) {
infoCount++
}
}
})
}
}
const totalInfoCount = infoCount
// Emit event with the FRESH data from the response, not the stale state
const event = new CustomEvent("healthStatusUpdated", {
detail: { status: data.overall },
detail: { status: newOverallStatus, infoCount: totalInfoCount },
})
window.dispatchEvent(event)
} catch (err) {
console.error("[v0] Error fetching health data:", err)
setError(err instanceof Error ? err.message : "Unknown error")
} finally {
setLoading(false)
}
}, [getApiUrl])
// Tick counter to force re-render every 30s so "X minutes ago" stays current
const [, setTick] = useState(0)
useEffect(() => {
if (!open) return
const tickInterval = setInterval(() => setTick(t => t + 1), 30000)
return () => clearInterval(tickInterval)
}, [open])
useEffect(() => {
if (open) {
fetchHealthDetails()
// Auto-refresh every 5 minutes while modal is open
const refreshInterval = setInterval(fetchHealthDetails, 300000)
return () => clearInterval(refreshInterval)
}
}, [open, fetchHealthDetails])
// Auto-expand non-OK categories when data loads
useEffect(() => {
if (healthData?.details) {
const nonOkCategories = new Set<string>()
CATEGORIES.forEach(({ key }) => {
const cat = healthData.details[key as keyof typeof healthData.details]
if (cat && cat.status?.toUpperCase() !== "OK") {
// Updates section: only auto-expand on WARNING+, not INFO
if (key === "updates" && cat.status?.toUpperCase() === "INFO") {
return
}
nonOkCategories.add(key)
}
})
setExpandedCategories(nonOkCategories)
}
}, [healthData])
const toggleCategory = (key: string) => {
setExpandedCategories(prev => {
const next = new Set(prev)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
return next
})
}
const getStatusIcon = (status: string) => {
const getStatusIcon = (status: string, size: "sm" | "md" = "md") => {
const statusUpper = status?.toUpperCase()
const cls = size === "sm" ? "h-4 w-4" : "h-5 w-5"
switch (statusUpper) {
case "OK":
return <CheckCircle2 className="h-5 w-5 text-green-500" />
return <CheckCircle2 className={`${cls} text-green-500`} />
case "INFO":
return <Info className={`${cls} text-blue-500`} />
case "WARNING":
return <AlertTriangle className="h-5 w-5 text-yellow-500" />
return <AlertTriangle className={`${cls} text-yellow-500`} />
case "CRITICAL":
return <XCircle className="h-5 w-5 text-red-500" />
return <XCircle className={`${cls} text-red-500`} />
case "UNKNOWN":
return <HelpCircle className={`${cls} text-amber-400`} />
default:
return <Activity className="h-5 w-5 text-gray-500" />
return <Activity className={`${cls} text-muted-foreground`} />
}
}
@@ -124,45 +270,76 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
switch (statusUpper) {
case "OK":
return <Badge className="bg-green-500 text-white hover:bg-green-500">OK</Badge>
case "INFO":
return <Badge className="bg-blue-500 text-white hover:bg-blue-500">Info</Badge>
case "WARNING":
return <Badge className="bg-yellow-500 text-white hover:bg-yellow-500">Warning</Badge>
case "CRITICAL":
return <Badge className="bg-red-500 text-white hover:bg-red-500">Critical</Badge>
case "UNKNOWN":
return <Badge className="bg-amber-500 text-white hover:bg-amber-500">UNKNOWN</Badge>
default:
return <Badge>Unknown</Badge>
}
}
const getHealthStats = () => {
if (!healthData?.details) {
return { total: 0, healthy: 0, warnings: 0, critical: 0 }
// Get categories that have dismissed items (to show as INFO)
const getCategoriesWithDismissed = () => {
const customCats = new Set(customSuppressions.map(cs => cs.category))
const filteredDismissed = dismissedItems.filter(item => !customCats.has(item.category))
const categoriesWithDismissed = new Set<string>()
filteredDismissed.forEach(item => {
// Map dismissed category to our CATEGORIES keys
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
if (catMeta) {
categoriesWithDismissed.add(catMeta.key)
}
})
return categoriesWithDismissed
}
const categoriesWithDismissed = getCategoriesWithDismissed()
// Get effective status for a category (considers dismissed items)
const getEffectiveStatus = (key: string, originalStatus: string) => {
// If category has dismissed items and original status is OK, show as INFO
if (categoriesWithDismissed.has(key) && originalStatus?.toUpperCase() === "OK") {
return "INFO"
}
return originalStatus?.toUpperCase() || "UNKNOWN"
}
const getHealthStats = () => {
if (!healthData?.details) return { total: 0, healthy: 0, info: 0, warnings: 0, critical: 0, unknown: 0 }
let healthy = 0
let info = 0
let warnings = 0
let critical = 0
let unknown = 0
CATEGORIES.forEach(({ key }) => {
const categoryData = healthData.details[key as keyof typeof healthData.details]
if (categoryData) {
const status = categoryData.status?.toUpperCase()
if (status === "OK") healthy++
else if (status === "WARNING") warnings++
else if (status === "CRITICAL") critical++
const effectiveStatus = getEffectiveStatus(key, categoryData.status)
if (effectiveStatus === "OK") healthy++
else if (effectiveStatus === "INFO") info++
else if (effectiveStatus === "WARNING") warnings++
else if (effectiveStatus === "CRITICAL") critical++
else if (effectiveStatus === "UNKNOWN") unknown++
}
})
return { total: CATEGORIES.length, healthy, warnings, critical }
return { total: CATEGORIES.length, healthy, info, warnings, critical, unknown }
}
const stats = getHealthStats()
const handleCategoryClick = (categoryKey: string, status: string) => {
if (status === "OK") return // No navegar si está OK
if (status === "OK" || status === "INFO") return
onOpenChange(false) // Cerrar el modal
onOpenChange(false)
// Mapear categorías a tabs
const categoryToTab: Record<string, string> = {
storage: "storage",
disks: "storage",
@@ -175,55 +352,222 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
const targetTab = categoryToTab[categoryKey]
if (targetTab) {
// Disparar evento para cambiar tab
const event = new CustomEvent("changeTab", { detail: { tab: targetTab } })
window.dispatchEvent(event)
}
}
const handleAcknowledge = async (errorKey: string, e: React.MouseEvent) => {
e.stopPropagation() // Prevent navigation
console.log("[v0] Dismissing error:", errorKey)
e.stopPropagation()
setDismissingKey(errorKey)
try {
const response = await fetch(getApiUrl("/api/health/acknowledge"), {
const url = getApiUrl("/api/health/acknowledge")
const token = getAuthToken()
const headers: Record<string, string> = { "Content-Type": "application/json" }
if (token) {
headers["Authorization"] = `Bearer ${token}`
}
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers,
body: JSON.stringify({ error_key: errorKey }),
})
const responseData = await response.json().catch(() => ({}))
if (!response.ok) {
const errorData = await response.json()
console.error("[v0] Acknowledge failed:", errorData)
throw new Error(errorData.error || "Failed to acknowledge error")
throw new Error(responseData.error || `Failed to dismiss error (${response.status})`)
}
const result = await response.json()
console.log("[v0] Acknowledge success:", result)
// Refresh health data
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("[v0] Error acknowledging:", err)
alert("Failed to dismiss error. Please try again.")
console.error("Error dismissing:", err)
} finally {
setDismissingKey(null)
}
}
const getTimeSinceCheck = () => {
if (!healthData?.timestamp) return null
const checkTime = new Date(healthData.timestamp)
const now = new Date()
const diffMs = now.getTime() - checkTime.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return "just now"
if (diffMin === 1) return "1 minute ago"
if (diffMin < 60) return `${diffMin} minutes ago`
const diffHours = Math.floor(diffMin / 60)
return `${diffHours}h ${diffMin % 60}m ago`
}
const getCategoryRowStyle = (status: string) => {
const s = status?.toUpperCase()
if (s === "CRITICAL") return "bg-red-500/5 border-red-500/20 hover:bg-red-500/10 cursor-pointer"
if (s === "WARNING") return "bg-yellow-500/5 border-yellow-500/20 hover:bg-yellow-500/10 cursor-pointer"
if (s === "UNKNOWN") return "bg-amber-500/5 border-amber-500/20 hover:bg-amber-500/10 cursor-pointer"
if (s === "INFO") return "bg-blue-500/5 border-blue-500/20 hover:bg-blue-500/10"
return "bg-card border-border hover:bg-muted/30"
}
const getOutlineBadgeStyle = (status: string) => {
const s = status?.toUpperCase()
if (s === "OK") return "border-green-500 text-green-500 bg-transparent"
if (s === "INFO") return "border-blue-500 text-blue-500 bg-blue-500/5"
if (s === "WARNING") return "border-yellow-500 text-yellow-500 bg-yellow-500/5"
if (s === "CRITICAL") return "border-red-500 text-red-500 bg-red-500/5"
if (s === "UNKNOWN") return "border-amber-400 text-amber-400 bg-amber-500/5"
return ""
}
const formatCheckLabel = (key: string): string => {
const labels: Record<string, string> = {
// CPU
cpu_usage: "CPU Usage",
cpu_temperature: "Temperature",
// Memory
ram_usage: "RAM Usage",
swap_usage: "Swap Usage",
// Disk I/O
root_filesystem: "Root Filesystem",
smart_health: "SMART Health",
io_errors: "I/O Errors",
zfs_pools: "ZFS Pools",
lvm_volumes: "LVM Volumes",
lvm_check: "LVM Status",
// Network
connectivity: "Connectivity",
// VMs & CTs
qmp_communication: "QMP Communication",
container_startup: "Container Startup",
vm_startup: "VM Startup",
oom_killer: "OOM Killer",
// Services
cluster_mode: "Cluster Mode",
// Logs (prefixed with log_)
log_error_cascade: "Error Cascade",
log_error_spike: "Error Spike",
log_persistent_errors: "Persistent Errors",
log_critical_errors: "Critical Errors",
// Updates
pve_version: "Proxmox VE Version",
security_updates: "Security Updates",
system_age: "System Age",
pending_updates: "Pending Updates",
kernel_pve: "Kernel / PVE",
// Security
uptime: "Uptime",
certificates: "Certificates",
login_attempts: "Login Attempts",
fail2ban: "Fail2Ban",
// Storage (Proxmox)
proxmox_storages: "Proxmox Storages",
}
if (labels[key]) return labels[key]
// Convert snake_case or camelCase to Title Case
return key
.replace(/_/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\b\w/g, (c) => c.toUpperCase())
}
const renderChecks = (
checks: Record<string, { status: string; detail: string; dismissable?: boolean; [key: string]: any }>,
categoryKey: string
) => {
if (!checks || Object.keys(checks).length === 0) return null
return (
<div className="mt-2 space-y-0.5">
{Object.entries(checks)
.filter(([, checkData]) => checkData.installed !== false)
.map(([checkKey, checkData]) => {
const isDismissable = checkData.dismissable === true
const checkStatus = checkData.status?.toUpperCase() || "OK"
return (
<div
key={checkKey}
className="flex items-center justify-between gap-1.5 sm:gap-2 text-[10px] sm:text-xs py-1.5 px-2 sm:px-3 rounded-md hover:bg-muted/40 transition-colors"
>
<div className="flex items-start gap-1.5 sm:gap-2 min-w-0 flex-1">
<span className="mt-0.5 shrink-0">{getStatusIcon(checkData.dismissed ? "INFO" : checkData.status, "sm")}</span>
<span className="font-medium shrink-0">{formatCheckLabel(checkKey)}</span>
<span className="text-muted-foreground break-words whitespace-pre-wrap min-w-0">{checkData.detail}</span>
{checkData.dismissed && (
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-blue-400 border-blue-400/30">
Dismissed
</Badge>
)}
</div>
<div className="flex items-center gap-1 sm:gap-1.5 shrink-0">
{(checkStatus === "WARNING" || checkStatus === "CRITICAL" || checkStatus === "UNKNOWN") && isDismissable && !checkData.dismissed && (
<Button
size="sm"
variant="outline"
className="h-5 px-1 sm:px-1.5 shrink-0 hover:bg-red-500/10 hover:border-red-500/50 bg-transparent text-[10px]"
disabled={dismissingKey === (checkData.error_key || checkKey)}
onClick={(e) => {
e.stopPropagation()
handleAcknowledge(checkData.error_key || checkKey, e)
}}
>
{dismissingKey === (checkData.error_key || checkKey) ? (
<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>
</div>
)
})}
</div>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogContent className="max-w-3xl w-[calc(100vw-2rem)] sm:w-[95vw] max-h-[85vh] overflow-y-auto overflow-x-hidden p-4 sm:p-6">
<DialogHeader>
<div className="flex items-center justify-between gap-3">
<DialogTitle className="flex items-center gap-2 flex-1">
<Activity className="h-6 w-6" />
System Health Status
{healthData && <div className="ml-2">{getStatusBadge(healthData.overall)}</div>}
<DialogTitle className="flex items-center gap-2 flex-1 min-w-0">
<Activity className="h-5 w-5 sm:h-6 sm:w-6 shrink-0" />
<span className="truncate text-base sm:text-lg">System Health Status</span>
{healthData && <div className="shrink-0">{getStatusBadge(healthData.overall)}</div>}
</DialogTitle>
</div>
<DialogDescription>Detailed health checks for all system components</DialogDescription>
<DialogDescription className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs sm:text-sm">
<span>Detailed health checks for all system components</span>
{getTimeSinceCheck() && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{getTimeSinceCheck()}
</span>
)}
</DialogDescription>
</DialogHeader>
{loading && (
@@ -242,114 +586,249 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{healthData && !loading && (
<div className="space-y-4">
{/* Overall Stats Summary */}
<div className="grid grid-cols-4 gap-3 p-4 rounded-lg bg-muted/30 border">
<div className={`grid gap-2 sm:gap-3 p-3 sm:p-4 rounded-lg bg-muted/30 border ${stats.info > 0 ? "grid-cols-5" : "grid-cols-4"}`}>
<div className="text-center">
<div className="text-2xl font-bold">{stats.total}</div>
<div className="text-xs text-muted-foreground">Total Checks</div>
<div className="text-lg sm:text-2xl font-bold">{stats.total}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Total</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-500">{stats.healthy}</div>
<div className="text-xs text-muted-foreground">Healthy</div>
<div className="text-lg sm:text-2xl font-bold text-green-500">{stats.healthy}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Healthy</div>
</div>
{stats.info > 0 && (
<div className="text-center">
<div className="text-lg sm:text-2xl font-bold text-blue-500">{stats.info}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Info</div>
</div>
)}
<div className="text-center">
<div className="text-lg sm:text-2xl font-bold text-yellow-500">{stats.warnings}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Warn</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-yellow-500">{stats.warnings}</div>
<div className="text-xs text-muted-foreground">Warnings</div>
<div className="text-lg sm:text-2xl font-bold text-red-500">{stats.critical}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Critical</div>
</div>
{stats.unknown > 0 && (
<div className="text-center">
<div className="text-2xl font-bold text-red-500">{stats.critical}</div>
<div className="text-xs text-muted-foreground">Critical</div>
<div className="text-lg sm:text-2xl font-bold text-amber-400">{stats.unknown}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Unknown</div>
</div>
)}
</div>
{healthData.summary && healthData.summary !== "All systems operational" && (
<div className="text-sm p-3 rounded-lg bg-muted/20 border">
<span className="font-medium text-foreground">{healthData.summary}</span>
<div className="text-xs sm:text-sm p-3 rounded-lg bg-muted/20 border overflow-hidden max-w-full">
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{healthData.summary}</p>
</div>
)}
{/* Category List */}
<div className="space-y-2">
{CATEGORIES.map(({ key, label, Icon }) => {
const categoryData = healthData.details[key as keyof typeof healthData.details]
const status = categoryData?.status || "UNKNOWN"
const originalStatus = categoryData?.status || "UNKNOWN"
const status = getEffectiveStatus(key, originalStatus)
const reason = categoryData?.reason
const details = categoryData?.details
const checks = categoryData?.checks
const isExpanded = expandedCategories.has(key)
const hasChecks = checks && Object.keys(checks).length > 0
return (
<div
key={key}
onClick={() => handleCategoryClick(key, status)}
className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
status === "OK"
? "bg-card border-border hover:bg-muted/30"
: status === "WARNING"
? "bg-yellow-500/5 border-yellow-500/20 hover:bg-yellow-500/10 cursor-pointer"
: status === "CRITICAL"
? "bg-red-500/5 border-red-500/20 hover:bg-red-500/10 cursor-pointer"
: "bg-muted/30 hover:bg-muted/50"
}`}
className={`rounded-lg border transition-colors overflow-hidden ${getCategoryRowStyle(status)}`}
>
<div className="mt-0.5 flex-shrink-0 flex items-center gap-2">
<Icon className="h-4 w-4 text-blue-500" />
{getStatusIcon(status)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<p className="font-medium text-sm">{label}</p>
<Badge
variant="outline"
className={`shrink-0 text-xs ${
status === "OK"
? "border-green-500 text-green-500 bg-transparent"
: status === "WARNING"
? "border-yellow-500 text-yellow-500 bg-yellow-500/5"
: status === "CRITICAL"
? "border-red-500 text-red-500 bg-red-500/5"
: ""
}`}
>
{/* Clickable header row */}
<div
className="flex items-center gap-2 sm:gap-3 p-2 sm:p-3 cursor-pointer select-none overflow-hidden"
onClick={() => toggleCategory(key)}
>
<div className="shrink-0 flex items-center gap-1.5 sm:gap-2">
<Icon className="h-4 w-4 text-blue-500 hidden sm:block" />
{getStatusIcon(status)}
</div>
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-1.5 sm:gap-2">
<p className="font-medium text-xs sm:text-sm truncate">{label}</p>
{hasChecks && (
<span className="text-[10px] text-muted-foreground shrink-0">
({Object.values(checks).filter(c => c.installed !== false).length})
</span>
)}
</div>
{reason && !isExpanded && (
<p className="text-[10px] sm:text-xs text-muted-foreground mt-0.5 line-clamp-2 break-words">{reason}</p>
)}
</div>
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
<Badge variant="outline" className={`text-[10px] sm:text-xs px-1.5 sm:px-2.5 ${getOutlineBadgeStyle(status)}`}>
{status}
</Badge>
<ChevronRight
className={`h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground transition-transform duration-200 ${
isExpanded ? "rotate-90" : ""
}`}
/>
</div>
{reason && <p className="text-xs text-muted-foreground mt-1">{reason}</p>}
{details && typeof details === "object" && (
<div className="mt-2 space-y-1">
{Object.entries(details).map(([detailKey, detailValue]: [string, any]) => {
if (typeof detailValue === "object" && detailValue !== null) {
return (
<div
key={detailKey}
className="flex items-start justify-between gap-2 text-xs pl-3 border-l-2 border-muted py-1"
>
<div className="flex-1">
<span className="font-medium">{detailKey}:</span>
{detailValue.reason && (
<span className="ml-1 text-muted-foreground">{detailValue.reason}</span>
)}
</div>
{(status === "WARNING" || status === "CRITICAL") && (
<Button
size="sm"
variant="outline"
className="h-6 px-2 shrink-0 hover:bg-red-500/10 hover:border-red-500/50 bg-transparent"
onClick={(e) => handleAcknowledge(detailKey, e)}
>
<X className="h-3 w-3 mr-1" />
<span className="text-xs">Dismiss</span>
</Button>
)}
</div>
)
}
return null
})}
</div>
)}
</div>
{/* Expandable checks section */}
{isExpanded && (
<div className="border-t border-border/50 bg-muted/5 px-1.5 sm:px-2 py-1.5 overflow-hidden">
{reason && (
<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)
) : (
<div className="flex items-center gap-2 text-xs text-muted-foreground px-3 py-2">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
No issues detected
</div>
)}
</div>
)}
</div>
)
})}
</div>
{/* Dismissed Items Section -- hide items whose category has custom suppression */}
{(() => {
const customCats = new Set(customSuppressions.map(cs => cs.category))
const filteredDismissed = dismissedItems.filter(item => !customCats.has(item.category))
if (filteredDismissed.length === 0) return null
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground pt-2">
<BellOff className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
Dismissed Items ({filteredDismissed.length})
</div>
{filteredDismissed.map((item) => {
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
const CatIcon = catMeta?.Icon || BellOff
const catLabel = catMeta?.label || item.category
const isPermanent = item.permanent || item.suppression_remaining_hours === -1
return (
<div
key={item.error_key}
className="flex items-start gap-2 sm:gap-3 p-2 sm:p-3 rounded-lg border bg-muted/10 border-muted opacity-75"
>
<div className="mt-0.5 shrink-0 flex items-center gap-1.5 sm:gap-2">
<CatIcon className="h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<div className="min-w-0 flex-1 overflow-hidden">
<p className="font-medium text-xs sm:text-sm text-muted-foreground truncate">{catLabel}</p>
<p className="text-[10px] sm:text-xs text-muted-foreground/70 break-words line-clamp-2">{item.reason}</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{isPermanent ? (
<Badge variant="outline" className="text-[9px] sm:text-xs border-amber-500/50 text-amber-500/70 bg-transparent whitespace-nowrap">
Permanent
</Badge>
) : (
<Badge variant="outline" className="text-[9px] sm:text-xs border-blue-500/50 text-blue-500/70 bg-transparent whitespace-nowrap">
Dismissed
</Badge>
)}
<Badge variant="outline" className={`text-[9px] sm:text-xs whitespace-nowrap ${getOutlineBadgeStyle(item.severity)}`}>
was {item.severity}
</Badge>
</div>
</div>
<p className="text-[10px] sm:text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{isPermanent
? "Permanently suppressed"
: `Suppressed for ${
item.suppression_remaining_hours < 24
? `${Math.round(item.suppression_remaining_hours)}h`
: item.suppression_remaining_hours < 720
? `${Math.round(item.suppression_remaining_hours / 24)} days`
: `${Math.round(item.suppression_remaining_hours / 720)} month(s)`
} more`
}
</p>
</div>
</div>
)
})}
</div>
)
})()}
{/* Custom Suppression Settings Summary */}
{customSuppressions.length > 0 && (
<div className="space-y-2 pt-2">
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground">
<Settings2 className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
Custom Suppression Settings
</div>
<div className="rounded-lg border border-blue-500/20 bg-blue-500/5 p-2.5 sm:p-3">
<div className="space-y-1.5">
{customSuppressions.map((cs) => {
const catMeta = CATEGORIES.find(c => c.category === cs.category || c.key === cs.category || c.label === cs.label)
const CatIcon = catMeta?.Icon || Settings2
const durationLabel = cs.hours === -1
? "Permanent"
: cs.hours >= 8760
? `${Math.floor(cs.hours / 8760)} year(s)`
: cs.hours >= 720
? `${Math.floor(cs.hours / 720)} month(s)`
: cs.hours >= 168
? `${Math.floor(cs.hours / 168)} week(s)`
: cs.hours >= 72
? `${Math.floor(cs.hours / 24)} days`
: `${cs.hours}h`
return (
<div key={cs.key} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<CatIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-blue-400/70 shrink-0" />
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{cs.label}</span>
</div>
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
{durationLabel}
</Badge>
</div>
)
})}
</div>
<p className="text-[10px] text-muted-foreground/60 mt-2 pt-1.5 border-t border-blue-500/10">
Alerts in these categories are auto-suppressed when detected.
</p>
</div>
</div>
)}
{healthData.timestamp && (
<div className="text-xs text-muted-foreground text-center pt-2">
Last updated: {new Date(healthData.timestamp).toLocaleString()}
File diff suppressed because it is too large Load Diff
+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.1</p>
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.0</p>
</div>
</div>
)
+857
View File
@@ -0,0 +1,857 @@
"use client"
import type React from "react"
import { useState, useEffect, useRef, useCallback } from "react"
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import {
Activity,
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
CornerDownLeft,
GripHorizontal,
ChevronDown,
Search,
Send,
Lightbulb,
Terminal,
Trash2,
X,
} from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu"
import { DialogHeader, DialogDescription } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Dialog as SearchDialog, DialogContent as SearchDialogContent, DialogTitle as SearchDialogTitle } from "@/components/ui/dialog"
import "xterm/css/xterm.css"
import { API_PORT, fetchApi } from "@/lib/api-config"
interface LxcTerminalModalProps {
open: boolean
onClose: () => void
vmid: number
vmName: string
}
interface CheatSheetResult {
command: string
description: string
examples: string[]
}
const proxmoxCommands = [
{ cmd: "ls -la", desc: "List all files with details" },
{ cmd: "cd /path/to/dir", desc: "Change directory" },
{ cmd: "cat filename", desc: "Display file contents" },
{ cmd: "grep 'pattern' file", desc: "Search for pattern in file" },
{ cmd: "find . -name 'file'", desc: "Find files by name" },
{ cmd: "df -h", desc: "Show disk usage" },
{ cmd: "du -sh *", desc: "Show directory sizes" },
{ cmd: "free -h", desc: "Show memory usage" },
{ cmd: "top", desc: "Show running processes" },
{ cmd: "ps aux | grep process", desc: "Find running process" },
{ cmd: "systemctl status service", desc: "Check service status" },
{ cmd: "systemctl restart service", desc: "Restart a service" },
{ cmd: "apt update && apt upgrade", desc: "Update packages" },
{ cmd: "apt install package", desc: "Install package" },
{ cmd: "tail -f /var/log/syslog", desc: "Follow log file" },
{ cmd: "chmod 755 file", desc: "Change file permissions" },
{ cmd: "chown user:group file", desc: "Change file owner" },
{ cmd: "tar -xzf file.tar.gz", desc: "Extract tar.gz archive" },
{ cmd: "docker ps", desc: "List running containers" },
{ cmd: "docker images", desc: "List Docker images" },
{ cmd: "ip addr show", desc: "Show IP addresses" },
{ cmd: "ping host", desc: "Test network connectivity" },
{ cmd: "curl -I url", desc: "Get HTTP headers" },
{ cmd: "history", desc: "Show command history" },
{ cmd: "clear", desc: "Clear terminal screen" },
]
function getWebSocketUrl(): string {
if (typeof window === "undefined") {
return "ws://localhost:8008/ws/terminal"
}
const { protocol, hostname, port } = window.location
const isStandardPort = port === "" || port === "80" || port === "443"
const wsProtocol = protocol === "https:" ? "wss:" : "ws:"
if (isStandardPort) {
return `${wsProtocol}//${hostname}/ws/terminal`
} else {
return `${wsProtocol}//${hostname}:${API_PORT}/ws/terminal`
}
}
export function LxcTerminalModal({
open: isOpen,
onClose,
vmid,
vmName,
}: LxcTerminalModalProps) {
const termRef = useRef<any>(null)
const wsRef = useRef<WebSocket | null>(null)
const fitAddonRef = useRef<any>(null)
const terminalContainerRef = useRef<HTMLDivElement>(null)
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [connectionStatus, setConnectionStatus] = useState<"connecting" | "online" | "offline">("connecting")
const [isMobile, setIsMobile] = useState(false)
const [isTablet, setIsTablet] = useState(false)
const isInsideLxcRef = useRef(false)
const outputBufferRef = useRef<string>("")
const [modalHeight, setModalHeight] = useState(500)
const [isResizing, setIsResizing] = useState(false)
const resizeBarRef = useRef<HTMLDivElement>(null)
const modalHeightRef = useRef(500)
// Search state
const [searchModalOpen, setSearchModalOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(proxmoxCommands)
const [isSearching, setIsSearching] = useState(false)
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
const [useOnline, setUseOnline] = useState(true)
// Detect mobile/tablet
useEffect(() => {
const checkDevice = () => {
const width = window.innerWidth
setIsMobile(width < 640)
setIsTablet(width >= 640 && width < 1024)
}
checkDevice()
window.addEventListener("resize", checkDevice)
return () => window.removeEventListener("resize", checkDevice)
}, [])
// Cleanup on close
useEffect(() => {
if (!isOpen) {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
pingIntervalRef.current = null
}
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
if (termRef.current) {
termRef.current.dispose()
termRef.current = null
}
setConnectionStatus("connecting")
isInsideLxcRef.current = false
outputBufferRef.current = ""
}
}, [isOpen])
// Initialize terminal
useEffect(() => {
if (!isOpen) return
// Small delay to ensure Dialog content is rendered
const initTimeout = setTimeout(() => {
if (!terminalContainerRef.current) return
initTerminal()
}, 100)
const initTerminal = async () => {
const [TerminalClass, FitAddonClass] = await Promise.all([
import("xterm").then((mod) => mod.Terminal),
import("xterm-addon-fit").then((mod) => mod.FitAddon),
])
const fontSize = window.innerWidth < 768 ? 12 : 16
const term = new TerminalClass({
rendererType: "dom",
fontFamily: '"Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace',
fontSize: fontSize,
lineHeight: 1,
cursorBlink: true,
scrollback: 2000,
disableStdin: false,
customGlyphs: true,
fontWeight: "500",
fontWeightBold: "700",
theme: {
background: "#000000",
foreground: "#ffffff",
cursor: "#ffffff",
cursorAccent: "#000000",
black: "#2e3436",
red: "#cc0000",
green: "#4e9a06",
yellow: "#c4a000",
blue: "#3465a4",
magenta: "#75507b",
cyan: "#06989a",
white: "#d3d7cf",
brightBlack: "#555753",
brightRed: "#ef2929",
brightGreen: "#8ae234",
brightYellow: "#fce94f",
brightBlue: "#729fcf",
brightMagenta: "#ad7fa8",
brightCyan: "#34e2e2",
brightWhite: "#eeeeec",
},
})
const fitAddon = new FitAddonClass()
term.loadAddon(fitAddon)
if (terminalContainerRef.current) {
term.open(terminalContainerRef.current)
fitAddon.fit()
}
termRef.current = term
fitAddonRef.current = fitAddon
// Connect WebSocket to host terminal
const wsUrl = getWebSocketUrl()
const ws = new WebSocket(wsUrl)
wsRef.current = ws
// Reset state for new connection
isInsideLxcRef.current = false
outputBufferRef.current = ""
ws.onopen = () => {
setConnectionStatus("online")
// Start heartbeat ping
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }))
} else {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
}
}
}, 25000)
// Sync terminal size
fitAddon.fit()
ws.send(JSON.stringify({
type: "resize",
cols: term.cols,
rows: term.rows,
}))
// Auto-execute pct enter after connection is ready
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(`pct enter ${vmid}\r`)
}
}, 300)
}
ws.onerror = () => {
setConnectionStatus("offline")
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m")
}
ws.onclose = () => {
setConnectionStatus("offline")
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
}
term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m")
}
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data)
}
})
ws.onmessage = (event) => {
// Filter out pong responses
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
return
}
// Helper to strip ANSI escape codes for pattern matching
const stripAnsi = (str: string) => str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
// Buffer output until we detect we're inside the LXC
// pct enter always enters directly without login prompt when run as root
if (!isInsideLxcRef.current) {
outputBufferRef.current += event.data
const buffer = outputBufferRef.current
const cleanBuffer = stripAnsi(buffer)
// Look for pct enter command followed by a new prompt
const pctEnterMatch = cleanBuffer.match(/pct enter (\d+)\r?\n/)
if (pctEnterMatch) {
const afterPctEnter = cleanBuffer.substring(cleanBuffer.indexOf(pctEnterMatch[0]) + pctEnterMatch[0].length)
// Extract the host name from the prompt BEFORE pct enter (e.g., "root@amd")
const hostPromptMatch = cleanBuffer.match(/@([a-zA-Z0-9_-]+).*pct enter/)
const hostName = hostPromptMatch ? hostPromptMatch[1] : null
// Look for a new prompt after pct enter that ends with # or $
// This works for both bash (user@host:~#) and ash/Alpine ([user@host /]#)
const promptMatch = afterPctEnter.match(/[@\[]([a-zA-Z0-9_-]+)[^\r\n]*[#$]\s*$/)
if (promptMatch) {
const lxcHostname = promptMatch[1]
// If we found a prompt with a DIFFERENT hostname than the Proxmox host,
// we're inside the LXC container
if (!hostName || lxcHostname !== hostName) {
isInsideLxcRef.current = true
// Find the original prompt with ANSI codes to display it properly
const afterPctEnterWithAnsi = buffer.substring(buffer.indexOf('pct enter') + pctEnterMatch[0].length)
// Write the LXC prompt (last line with # or $)
const lastPromptMatch = afterPctEnterWithAnsi.match(/[^\r\n]*[#$]\s*$/)
if (lastPromptMatch) {
term.write(lastPromptMatch[0])
}
// Detect if this is Alpine/ash shell by checking prompt format
// Alpine uses: [root@hostname ~]# or [root@hostname /]#
// Other distros use: root@hostname:/# or root@hostname:~#
const isAlpine = afterPctEnter.match(/\[[^\]]+@[^\]]+\s+[^\]]*\][#$]/)
if (isAlpine) {
// Send an extra Enter ONLY for Alpine containers (ash shell)
// This forces the prompt to refresh properly
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send('\r')
}
}, 100)
}
return
}
}
}
} else {
// Already inside LXC, write directly
term.write(event.data)
}
}
}
return () => {
clearTimeout(initTimeout)
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
}
if (wsRef.current) {
wsRef.current.close()
}
if (termRef.current) {
termRef.current.dispose()
}
}
}, [isOpen, vmid])
// Resize handling
useEffect(() => {
if (termRef.current && fitAddonRef.current && isOpen) {
setTimeout(() => {
fitAddonRef.current?.fit()
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: "resize",
cols: termRef.current.cols,
rows: termRef.current.rows,
}))
}
}, 100)
}
}, [modalHeight, isOpen])
// Resize bar handlers
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault()
setIsResizing(true)
modalHeightRef.current = modalHeight
}, [modalHeight])
useEffect(() => {
if (!isResizing) return
const handleMove = (e: MouseEvent | TouchEvent) => {
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
const windowHeight = window.innerHeight
const newHeight = windowHeight - clientY - 20
const clampedHeight = Math.max(300, Math.min(windowHeight - 100, newHeight))
modalHeightRef.current = clampedHeight
setModalHeight(clampedHeight)
}
const handleEnd = () => {
setIsResizing(false)
}
document.addEventListener("mousemove", handleMove)
document.addEventListener("mouseup", handleEnd)
document.addEventListener("touchmove", handleMove)
document.addEventListener("touchend", handleEnd)
return () => {
document.removeEventListener("mousemove", handleMove)
document.removeEventListener("mouseup", handleEnd)
document.removeEventListener("touchmove", handleMove)
document.removeEventListener("touchend", handleEnd)
}
}, [isResizing])
// Send key helpers for mobile/tablet
const sendKey = useCallback((key: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(key)
}
}, [])
const sendEsc = useCallback(() => sendKey("\x1b"), [sendKey])
const sendTab = useCallback(() => sendKey("\t"), [sendKey])
const sendArrowUp = useCallback(() => sendKey("\x1b[A"), [sendKey])
const sendArrowDown = useCallback(() => sendKey("\x1b[B"), [sendKey])
const sendArrowLeft = useCallback(() => sendKey("\x1b[D"), [sendKey])
const sendArrowRight = useCallback(() => sendKey("\x1b[C"), [sendKey])
const sendEnter = useCallback(() => sendKey("\r"), [sendKey])
const sendCtrlC = useCallback(() => sendKey("\x03"), [sendKey]) // Ctrl+C
// Search effect - debounced search with cheat.sh
useEffect(() => {
const searchCheatSh = async (query: string) => {
if (!query.trim()) {
setSearchResults([])
setFilteredCommands(proxmoxCommands)
return
}
try {
setIsSearching(true)
const searchEndpoint = `/api/terminal/search-command?q=${encodeURIComponent(query)}`
const data = await fetchApi<{ success: boolean; examples: any[] }>(searchEndpoint, {
method: "GET",
signal: AbortSignal.timeout(10000),
})
if (!data.success || !data.examples || data.examples.length === 0) {
throw new Error("No examples found")
}
const formattedResults: CheatSheetResult[] = data.examples.map((example: any) => ({
command: example.command,
description: example.description || "",
examples: [example.command],
}))
setUseOnline(true)
setSearchResults(formattedResults)
} catch (error) {
const filtered = proxmoxCommands.filter(
(item) =>
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
item.desc.toLowerCase().includes(query.toLowerCase()),
)
setFilteredCommands(filtered)
setSearchResults([])
setUseOnline(false)
} finally {
setIsSearching(false)
}
}
const debounce = setTimeout(() => {
if (searchQuery && searchQuery.length >= 2) {
searchCheatSh(searchQuery)
} else {
setSearchResults([])
setFilteredCommands(proxmoxCommands)
}
}, 800)
return () => clearTimeout(debounce)
}, [searchQuery])
const handleClear = useCallback(() => {
if (termRef.current) {
termRef.current.clear()
}
}, [])
const sendToTerminal = useCallback((command: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(command)
setTimeout(() => {
setSearchModalOpen(false)
}, 100)
}
}, [])
const showMobileControls = isMobile || isTablet
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
className="max-w-4xl w-[95vw] p-0 gap-0 bg-black border-border overflow-hidden flex flex-col"
style={{ height: `${modalHeight}px` }}
hideClose
>
{/* Resize bar */}
<div
ref={resizeBarRef}
className="h-3 w-full cursor-ns-resize flex items-center justify-center bg-zinc-900 hover:bg-zinc-800 transition-colors touch-none"
onMouseDown={handleResizeStart}
onTouchStart={handleResizeStart}
>
<GripHorizontal className="h-4 w-4 text-zinc-500" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-zinc-800">
<DialogTitle className="text-sm font-medium text-white">
Terminal: {vmName} (ID: {vmid})
</DialogTitle>
<div className="flex gap-2">
<Button
onClick={() => setSearchModalOpen(true)}
variant="outline"
size="sm"
disabled={connectionStatus !== "online"}
className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50"
>
<Search className="h-4 w-4" />
<span className="hidden sm:inline">Search</span>
</Button>
<Button
onClick={handleClear}
variant="outline"
size="sm"
disabled={connectionStatus !== "online"}
className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">Clear</span>
</Button>
</div>
</div>
{/* Terminal container */}
<div className="flex-1 overflow-hidden bg-black p-1">
<div
ref={terminalContainerRef}
className="w-full h-full"
style={{ minHeight: "200px" }}
/>
</div>
{/* Mobile/Tablet control buttons */}
{showMobileControls && (
<div className="px-2 py-2 bg-zinc-900 border-t border-zinc-800">
<div className="flex items-center justify-center gap-1.5">
<Button
variant="outline"
size="sm"
onClick={sendEsc}
className="h-8 px-2 text-xs bg-zinc-800 border-zinc-700 text-zinc-300"
>
ESC
</Button>
<Button
variant="outline"
size="sm"
onClick={sendTab}
className="h-8 px-2 text-xs bg-zinc-800 border-zinc-700 text-zinc-300"
>
TAB
</Button>
<Button
variant="outline"
size="sm"
onClick={sendArrowUp}
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={sendArrowDown}
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={sendArrowLeft}
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={sendArrowRight}
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
>
<ArrowRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={sendEnter}
className="h-8 px-2 text-xs bg-blue-600/20 border-blue-600/50 text-blue-400 hover:bg-blue-600/30"
>
<CornerDownLeft className="h-4 w-4 mr-1" />
Enter
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-2 text-xs bg-zinc-800 border-zinc-700 text-zinc-300 gap-1"
>
Ctrl
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => sendKey("\x03")}>
<span className="font-mono text-xs mr-2">Ctrl+C</span>
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendKey("\x18")}>
<span className="font-mono text-xs mr-2">Ctrl+X</span>
<span className="text-muted-foreground text-xs">Exit (nano)</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendKey("\x12")}>
<span className="font-mono text-xs mr-2">Ctrl+R</span>
<span className="text-muted-foreground text-xs">Search history</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
{/* Status bar at bottom */}
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-t border-zinc-800">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-blue-500" />
<div
className={`w-2 h-2 rounded-full ${
connectionStatus === "online"
? "bg-green-500"
: connectionStatus === "connecting"
? "bg-yellow-500 animate-pulse"
: "bg-red-500"
}`}
/>
<span className="text-xs text-zinc-400 capitalize">{connectionStatus}</span>
</div>
<Button
onClick={onClose}
variant="outline"
size="sm"
className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
>
<X className="h-4 w-4" />
<span className="hidden sm:inline">Close</span>
</Button>
</div>
</DialogContent>
{/* Search Commands Modal */}
<SearchDialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
<SearchDialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800">
<SearchDialogTitle className="text-xl font-semibold">Search Commands</SearchDialogTitle>
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`}
title={useOnline ? "Online - Using cheat.sh API" : "Offline - Using local commands"}
/>
</div>
</DialogHeader>
<DialogDescription className="sr-only">Search for Linux commands</DialogDescription>
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<Input
placeholder="Search commands... (e.g., tar, docker, systemctl)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
/>
</div>
{isSearching && (
<div className="text-center py-4 text-zinc-400">
<div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" />
<p className="text-sm">Searching cheat.sh...</p>
</div>
)}
<div className="flex-1 overflow-y-auto space-y-2 pr-2 max-h-[50vh]">
{searchResults.length > 0 ? (
<>
{searchResults.map((result, index) => (
<div
key={index}
className="p-4 rounded-lg border border-zinc-700 bg-zinc-800/50 hover:border-zinc-600 transition-colors"
>
{result.description && (
<p className="text-xs text-zinc-400 mb-2 leading-relaxed"># {result.description}</p>
)}
<div
onClick={() => sendToTerminal(result.command)}
className="flex items-start justify-between gap-2 cursor-pointer group hover:bg-zinc-800/50 rounded p-2 -m-2"
>
<code className="text-sm text-blue-400 font-mono break-all flex-1">{result.command}</code>
<Send className="h-4 w-4 text-zinc-600 group-hover:text-blue-400 flex-shrink-0 mt-0.5 transition-colors" />
</div>
</div>
))}
<div className="text-center py-2">
<p className="text-xs text-zinc-500">
<Lightbulb className="inline-block w-3 h-3 mr-1" />
Powered by cheat.sh
</p>
</div>
</>
) : filteredCommands.length > 0 && !useOnline ? (
filteredCommands.map((item, index) => (
<div
key={index}
onClick={() => sendToTerminal(item.cmd)}
className="p-3 rounded-lg border border-zinc-700 bg-zinc-800/50 hover:bg-zinc-800 hover:border-blue-500 cursor-pointer transition-colors"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<code className="text-sm text-blue-400 font-mono break-all">{item.cmd}</code>
<p className="text-xs text-zinc-400 mt-1">{item.desc}</p>
</div>
<Button
onClick={(e) => {
e.stopPropagation()
sendToTerminal(item.cmd)
}}
size="sm"
variant="ghost"
className="shrink-0 h-7 px-2 text-xs"
>
<Send className="h-3 w-3 mr-1" />
Send
</Button>
</div>
</div>
))
) : !isSearching && !searchQuery && !useOnline ? (
proxmoxCommands.map((item, index) => (
<div
key={index}
onClick={() => sendToTerminal(item.cmd)}
className="p-3 rounded-lg border border-zinc-700 bg-zinc-800/50 hover:bg-zinc-800 hover:border-blue-500 cursor-pointer transition-colors"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<code className="text-sm text-blue-400 font-mono break-all">{item.cmd}</code>
<p className="text-xs text-zinc-400 mt-1">{item.desc}</p>
</div>
<Button
onClick={(e) => {
e.stopPropagation()
sendToTerminal(item.cmd)
}}
size="sm"
variant="ghost"
className="shrink-0 h-7 px-2 text-xs"
>
<Send className="h-3 w-3 mr-1" />
Send
</Button>
</div>
</div>
))
) : !isSearching ? (
<div className="text-center py-12 space-y-4">
{searchQuery ? (
<>
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
<div>
<p className="text-zinc-400 font-medium">{"No results found for \""}{searchQuery}{"\""}</p>
<p className="text-xs text-zinc-500 mt-1">Try a different command or check your spelling</p>
</div>
</>
) : (
<>
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
<div>
<p className="text-zinc-400 font-medium mb-2">Search for any command</p>
<div className="text-sm text-zinc-500 space-y-1">
<p>Try searching for:</p>
<div className="flex flex-wrap justify-center gap-2 mt-2">
{["tar", "grep", "docker", "systemctl", "curl"].map((cmd) => (
<code
key={cmd}
onClick={() => setSearchQuery(cmd)}
className="px-2 py-1 bg-zinc-800 rounded text-blue-400 cursor-pointer hover:bg-zinc-700"
>
{cmd}
</code>
))}
</div>
</div>
</div>
{useOnline && (
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
<Lightbulb className="w-3 h-3" />
<span>Powered by cheat.sh</span>
</div>
)}
</>
)}
</div>
) : null}
</div>
<div className="pt-2 border-t border-zinc-800 flex items-center justify-between text-xs text-zinc-500">
<div className="flex items-center gap-2">
<Lightbulb className="w-3 h-3" />
<span>Tip: Search for any Linux command</span>
</div>
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">Powered by cheat.sh</span>}
</div>
</div>
</SearchDialogContent>
</SearchDialog>
</Dialog>
)
}
+22 -23
View File
@@ -2,9 +2,10 @@
import { Card, CardContent } from "./ui/card"
import { Badge } from "./ui/badge"
import { Wifi, Zap } from "lucide-react"
import { Wifi, Zap } from 'lucide-react'
import { useState, useEffect } from "react"
import { fetchApi } from "../lib/api-config"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
interface NetworkCardProps {
interface_: {
@@ -59,39 +60,37 @@ const getVMTypeBadge = (vmType: string | undefined) => {
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" }
}
const formatBytes = (bytes: number | undefined): string => {
if (!bytes || bytes === 0) return "0 B"
const k = 1024
const sizes = ["B", "KB", "MB", "GB", "TB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`
}
const formatSpeed = (speed: number): string => {
if (speed === 0) return "N/A"
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps`
return `${speed} Mbps`
}
const formatStorage = (bytes: number): string => {
if (bytes === 0) return "0 B"
const k = 1024
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
const value = bytes / Math.pow(k, i)
const decimals = value >= 10 ? 1 : 2
return `${value.toFixed(decimals)} ${sizes[i]}`
}
export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps) {
const typeBadge = getInterfaceTypeBadge(interface_.type)
const vmTypeBadge = interface_.vm_type ? getVMTypeBadge(interface_.vm_type) : null
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(getNetworkUnit())
const [trafficData, setTrafficData] = useState<{ received: number; sent: number }>({
received: 0,
sent: 0,
})
useEffect(() => {
const handleUnitChange = () => {
setNetworkUnit(getNetworkUnit())
}
window.addEventListener("networkUnitChanged", handleUnitChange)
window.addEventListener("storage", handleUnitChange)
return () => {
window.removeEventListener("networkUnitChanged", handleUnitChange)
window.removeEventListener("storage", handleUnitChange)
}
}, [])
useEffect(() => {
const fetchTrafficData = async () => {
try {
@@ -207,15 +206,15 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
<div className="font-medium text-foreground text-xs">
{interface_.status.toLowerCase() === "up" && interface_.vm_type !== "vm" ? (
<>
<span className="text-green-500"> {formatStorage(trafficData.received * 1024 * 1024 * 1024)}</span>
<span className="text-green-500"> {formatNetworkTraffic(trafficData.received * 1024 * 1024 * 1024, networkUnit)}</span>
{" / "}
<span className="text-blue-500"> {formatStorage(trafficData.sent * 1024 * 1024 * 1024)}</span>
<span className="text-blue-500"> {formatNetworkTraffic(trafficData.sent * 1024 * 1024 * 1024, networkUnit)}</span>
</>
) : (
<>
<span className="text-green-500"> {formatBytes(interface_.bytes_recv)}</span>
<span className="text-green-500"> {formatNetworkTraffic(interface_.bytes_recv || 0, networkUnit)}</span>
{" / "}
<span className="text-blue-500"> {formatBytes(interface_.bytes_sent)}</span>
<span className="text-blue-500"> {formatNetworkTraffic(interface_.bytes_sent || 0, networkUnit)}</span>
</>
)}
</div>
+151 -51
View File
@@ -1,14 +1,17 @@
"use client"
import { useState } from "react"
import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
import { Wifi, Activity, Network, Router, AlertCircle, Zap } from "lucide-react"
import { Wifi, Activity, Network, Router, AlertCircle, Zap, Timer } from 'lucide-react'
import useSWR from "swr"
import { NetworkTrafficChart } from "./network-traffic-chart"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { fetchApi } from "../lib/api-config"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { LatencyDetailModal } from "./latency-detail-modal"
import { AreaChart, Area, LineChart, Line, ResponsiveContainer, YAxis } from "recharts"
interface NetworkData {
interfaces: NetworkInterface[]
@@ -132,14 +135,15 @@ const fetcher = async (url: string): Promise<NetworkData> => {
return fetchApi<NetworkData>(url)
}
export function NetworkMetrics() {
const {
data: networkData,
error,
isLoading,
} = useSWR<NetworkData>("/api/network", fetcher, {
refreshInterval: 53000,
revalidateOnFocus: false,
refreshInterval: 15000,
revalidateOnFocus: true,
revalidateOnReconnect: true,
})
@@ -148,6 +152,30 @@ export function NetworkMetrics() {
const [modalTimeframe, setModalTimeframe] = useState<"hour" | "day" | "week" | "month" | "year">("day")
const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
const [interfaceTotals, setInterfaceTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
const [latencyModalOpen, setLatencyModalOpen] = useState(false)
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(() => getNetworkUnit())
// Latency history for sparkline (last hour)
const { data: latencyData } = useSWR<{
data: Array<{ timestamp: number; value: number }>
stats: { min: number; max: number; avg: number; current: number }
target: string
}>("/api/network/latency/history?target=gateway&timeframe=hour",
(url: string) => fetchApi(url),
{ refreshInterval: 60000, revalidateOnFocus: false }
)
useEffect(() => {
setNetworkUnit(getNetworkUnit())
const handleUnitChange = (e: CustomEvent) => {
setNetworkUnit(e.detail === "Bits" ? "Bits" : "Bytes")
}
window.addEventListener("networkUnitChanged" as any, handleUnitChange)
return () => window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
}, [])
const { data: modalNetworkData } = useSWR<NetworkData>(selectedInterface ? "/api/network" : null, fetcher, {
refreshInterval: 17000,
@@ -162,10 +190,13 @@ export function NetworkMetrics() {
if (isLoading) {
return (
<div className="space-y-6">
<div className="text-center py-8">
<div className="text-lg font-medium text-foreground mb-2">Loading network data...</div>
<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 network data...</div>
<p className="text-xs text-muted-foreground">Scanning interfaces, bridges and traffic</p>
</div>
)
}
@@ -191,8 +222,16 @@ export function NetworkMetrics() {
)
}
const trafficInFormatted = formatStorage(networkTotals.received * 1024 * 1024 * 1024) // Convert GB to bytes
const trafficOutFormatted = formatStorage(networkTotals.sent * 1024 * 1024 * 1024)
const trafficInFormatted = formatNetworkTraffic(
networkTotals.received * 1024 ** 3,
networkUnit,
2
)
const trafficOutFormatted = formatNetworkTraffic(
networkTotals.sent * 1024 ** 3,
networkUnit,
2
)
const packetsRecvK = networkData.traffic.packets_recv ? (networkData.traffic.packets_recv / 1000).toFixed(0) : "0"
const totalErrors = (networkData.traffic.errin || 0) + (networkData.traffic.errout || 0)
@@ -304,48 +343,95 @@ export function NetworkMetrics() {
</CardContent>
</Card>
{/* Merged Network Config & Health Card */}
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Network Configuration</CardTitle>
<Network className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium text-muted-foreground">Network Status</CardTitle>
<Badge variant="outline" className={healthColor}>
{healthStatus}
</Badge>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex flex-col">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Hostname</span>
<span className="text-sm font-medium text-foreground truncate">{hostname}</span>
<span className="text-xs font-medium text-foreground truncate max-w-[120px]">{hostname}</span>
</div>
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Domain</span>
<span className="text-sm font-medium text-foreground truncate">{domain}</span>
</div>
<div className="flex flex-col">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Primary DNS</span>
<span className="text-sm font-medium text-foreground truncate">{primaryDNS}</span>
<span className="text-xs font-medium text-foreground font-mono">{primaryDNS}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Packet Loss</span>
<span className="text-xs font-medium text-foreground">{avgPacketLoss}%</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Errors</span>
<span className="text-xs font-medium text-foreground">{totalErrors}</span>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-card border-border">
{/* Latency Card with Sparkline */}
<Card
className="bg-card border-border cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => setLatencyModalOpen(true)}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Network Health</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium text-muted-foreground">Network Latency</CardTitle>
<Timer className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<Badge variant="outline" className={healthColor}>
{healthStatus}
</Badge>
<div className="flex flex-col gap-1 mt-2 text-xs">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Packet Loss:</span>
<span className="font-medium text-foreground">{avgPacketLoss}%</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Errors:</span>
<span className="font-medium text-foreground">{totalErrors}</span>
<div className="flex items-center justify-between mb-2">
<div className="text-xl lg:text-2xl font-bold text-foreground">
{latencyData?.stats?.current ?? 0} <span className="text-sm font-normal text-muted-foreground">ms</span>
</div>
<Badge
variant="outline"
className={
(latencyData?.stats?.current ?? 0) < 50
? "bg-green-500/10 text-green-500 border-green-500/20"
: (latencyData?.stats?.current ?? 0) < 100
? "bg-green-500/10 text-green-500 border-green-500/20"
: (latencyData?.stats?.current ?? 0) < 200
? "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20"
}
>
{(latencyData?.stats?.current ?? 0) < 50 ? "Excellent" :
(latencyData?.stats?.current ?? 0) < 100 ? "Good" :
(latencyData?.stats?.current ?? 0) < 200 ? "Fair" : "Poor"}
</Badge>
</div>
{/* Sparkline */}
{latencyData?.data && latencyData.data.length > 0 && (
<div className="h-[40px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={latencyData.data.slice(-30)} margin={{ top: 2, right: 0, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="latencySparkGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.4} />
<stop offset="100%" stopColor="#3b82f6" stopOpacity={0.05} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="value"
stroke="#3b82f6"
strokeWidth={1.5}
fill="url(#latencySparkGradient)"
dot={false}
isAnimationActive={false}
baseValue="dataMin"
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
<p className="text-xs text-muted-foreground mt-1">
Avg: {latencyData?.stats?.avg ?? 0}ms | Max: {latencyData?.stats?.max ?? 0}ms
</p>
</CardContent>
</Card>
</div>
@@ -375,7 +461,7 @@ export function NetworkMetrics() {
</CardTitle>
</CardHeader>
<CardContent>
<NetworkTrafficChart timeframe={timeframe} onTotalsCalculated={setNetworkTotals} />
<NetworkTrafficChart timeframe={timeframe} onTotalsCalculated={setNetworkTotals} networkUnit={networkUnit} />
</CardContent>
</Card>
@@ -712,13 +798,6 @@ export function NetworkMetrics() {
const displayInterface = currentInterfaceData || selectedInterface
console.log("[v0] Selected Interface:", selectedInterface.name)
console.log("[v0] Selected Interface bytes_recv:", selectedInterface.bytes_recv)
console.log("[v0] Selected Interface bytes_sent:", selectedInterface.bytes_sent)
console.log("[v0] Display Interface bytes_recv:", displayInterface.bytes_recv)
console.log("[v0] Display Interface bytes_sent:", displayInterface.bytes_sent)
console.log("[v0] Modal Network Data available:", !!modalNetworkData)
return (
<>
{/* Basic Information */}
@@ -869,29 +948,40 @@ export function NetworkMetrics() {
)
</h3>
<div className="space-y-4">
{/* Traffic Data - Top Row */}
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-muted-foreground">Bytes Received</div>
<div className="text-sm text-muted-foreground">
{networkUnit === "Bits" ? "Bits Received" : "Bytes Received"}
</div>
<div className="font-medium text-green-500 text-lg">
{formatStorage(interfaceTotals.received * 1024 * 1024 * 1024)}
{formatNetworkTraffic(
interfaceTotals.received * 1024 ** 3,
networkUnit,
2
)}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Bytes Sent</div>
<div className="text-sm text-muted-foreground">
{networkUnit === "Bits" ? "Bits Sent" : "Bytes Sent"}
</div>
<div className="font-medium text-blue-500 text-lg">
{formatStorage(interfaceTotals.sent * 1024 * 1024 * 1024)}
{formatNetworkTraffic(
interfaceTotals.sent * 1024 ** 3,
networkUnit,
2
)}
</div>
</div>
</div>
{/* Network Traffic Chart - Full Width Below */}
<div className="bg-muted/30 rounded-lg p-4">
<NetworkTrafficChart
timeframe={modalTimeframe}
interfaceName={displayInterface.name}
onTotalsCalculated={setInterfaceTotals}
refreshInterval={60000}
networkUnit={networkUnit}
/>
</div>
@@ -932,15 +1022,19 @@ export function NetworkMetrics() {
<h3 className="text-sm font-semibold text-muted-foreground mb-4">Traffic since last boot</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-muted-foreground">Bytes Received</div>
<div className="text-sm text-muted-foreground">
{networkUnit === "Bits" ? "Bits Received" : "Bytes Received"}
</div>
<div className="font-medium text-green-500 text-lg">
{formatBytes(displayInterface.bytes_recv)}
{formatNetworkTraffic(displayInterface.bytes_recv || 0, networkUnit)}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Bytes Sent</div>
<div className="text-sm text-muted-foreground">
{networkUnit === "Bits" ? "Bits Sent" : "Bytes Sent"}
</div>
<div className="font-medium text-blue-500 text-lg">
{formatBytes(displayInterface.bytes_sent)}
{formatNetworkTraffic(displayInterface.bytes_sent || 0, networkUnit)}
</div>
</div>
<div>
@@ -1057,6 +1151,12 @@ export function NetworkMetrics() {
)}
</DialogContent>
</Dialog>
{/* Latency Detail Modal */}
<LatencyDetailModal
open={latencyModalOpen}
onOpenChange={setLatencyModalOpen}
/>
</div>
)
}
+64 -11
View File
@@ -2,8 +2,9 @@
import { useState, useEffect } from "react"
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
import { Loader2 } from "lucide-react"
import { fetchApi } from "@/lib/api-config"
import { Loader2 } from 'lucide-react'
import { fetchApi } from "../lib/api-config"
import { getNetworkUnit } from "../lib/format-network"
interface NetworkMetricsData {
time: string
@@ -17,9 +18,10 @@ interface NetworkTrafficChartProps {
interfaceName?: string
onTotalsCalculated?: (totals: { received: number; sent: number }) => void
refreshInterval?: number // En milisegundos, por defecto 60000 (60 segundos)
networkUnit?: "Bytes" | "Bits" // Added networkUnit prop
}
const CustomNetworkTooltip = ({ active, payload, label }: any) => {
const CustomNetworkTooltip = ({ active, payload, label, networkUnit }: any) => {
if (active && payload && payload.length) {
return (
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
@@ -29,7 +31,9 @@ const CustomNetworkTooltip = ({ active, payload, label }: any) => {
<div key={index} className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
<span className="text-sm font-semibold text-white">{entry.value.toFixed(3)} GB</span>
<span className="text-sm font-semibold text-white">
{entry.value.toFixed(3)} {networkUnit === "Bits" ? "Gb" : "GB"}
</span>
</div>
))}
</div>
@@ -44,6 +48,7 @@ export function NetworkTrafficChart({
interfaceName,
onTotalsCalculated,
refreshInterval = 60000,
networkUnit: networkUnitProp, // Rename prop to avoid conflict
}: NetworkTrafficChartProps) {
const [data, setData] = useState<NetworkMetricsData[]>([])
const [loading, setLoading] = useState(true)
@@ -53,11 +58,36 @@ export function NetworkTrafficChart({
netIn: true,
netOut: true,
})
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(
networkUnitProp || getNetworkUnit()
)
useEffect(() => {
const handleUnitChange = () => {
const newUnit = getNetworkUnit()
setNetworkUnit(newUnit)
}
window.addEventListener("networkUnitChanged", handleUnitChange)
window.addEventListener("storage", handleUnitChange)
return () => {
window.removeEventListener("networkUnitChanged", handleUnitChange)
window.removeEventListener("storage", handleUnitChange)
}
}, [])
useEffect(() => {
if (networkUnitProp) {
setNetworkUnit(networkUnitProp)
}
}, [networkUnitProp])
useEffect(() => {
setIsInitialLoad(true)
fetchMetrics()
}, [timeframe, interfaceName])
}, [timeframe, interfaceName, networkUnit])
useEffect(() => {
if (refreshInterval > 0) {
@@ -67,7 +97,7 @@ export function NetworkTrafficChart({
return () => clearInterval(interval)
}
}, [timeframe, interfaceName, refreshInterval])
}, [timeframe, interfaceName, refreshInterval, networkUnit]) // Added networkUnit to dependencies
const fetchMetrics = async () => {
if (isInitialLoad) {
@@ -138,6 +168,15 @@ export function NetworkTrafficChart({
const netInBytes = (item.netin || 0) * intervalSeconds
const netOutBytes = (item.netout || 0) * intervalSeconds
if (networkUnit === "Bits") {
return {
time: timeLabel,
timestamp: item.time,
netIn: Number(((netInBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
netOut: Number(((netOutBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
}
}
return {
time: timeLabel,
timestamp: item.time,
@@ -148,11 +187,20 @@ export function NetworkTrafficChart({
setData(transformedData)
const totalReceived = transformedData.reduce((sum: number, item: NetworkMetricsData) => sum + item.netIn, 0)
const totalSent = transformedData.reduce((sum: number, item: NetworkMetricsData) => sum + item.netOut, 0)
const totalReceivedGB = result.data.reduce((sum: number, item: any, index: number) => {
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
const netInBytes = (item.netin || 0) * intervalSeconds
return sum + (netInBytes / 1024 / 1024 / 1024)
}, 0)
const totalSentGB = result.data.reduce((sum: number, item: any, index: number) => {
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
const netOutBytes = (item.netout || 0) * intervalSeconds
return sum + (netOutBytes / 1024 / 1024 / 1024)
}, 0)
if (onTotalsCalculated) {
onTotalsCalculated({ received: totalReceived, sent: totalSent })
onTotalsCalculated({ received: totalReceivedGB, sent: totalSentGB })
}
if (isInitialLoad) {
@@ -240,10 +288,15 @@ export function NetworkTrafficChart({
stroke="currentColor"
className="text-foreground"
tick={{ fill: "currentColor", fontSize: 12 }}
label={{ value: "GB", angle: -90, position: "insideLeft", fill: "currentColor" }}
label={{
value: networkUnit === "Bits" ? "Gb" : "GB", // Dynamic label based on unit
angle: -90,
position: "insideLeft",
fill: "currentColor",
}}
domain={[0, "auto"]}
/>
<Tooltip content={<CustomNetworkTooltip />} />
<Tooltip content={<CustomNetworkTooltip networkUnit={networkUnit} />} /> // Pass networkUnit to tooltip
<Legend verticalAlign="top" height={36} content={renderLegend} />
<Area
type="monotone"
+35 -20
View File
@@ -78,6 +78,10 @@ export function NodeMetricsCharts() {
memory: { memoryTotal: true, memoryUsed: true, memoryZfsArc: true, memoryFree: true },
})
// Check if ZFS ARC or Free memory have any non-zero values to decide if we should show them
const hasZfsArc = data.some(d => d.memoryZfsArc > 0)
const hasMemoryFree = data.some(d => d.memoryFree > 0)
useEffect(() => {
console.log("[v0] NodeMetricsCharts component mounted")
fetchMetrics()
@@ -194,6 +198,11 @@ export function NodeMetricsCharts() {
return (
<div className="flex justify-center gap-4 pb-2 flex-wrap">
{payload.map((entry: any, index: number) => {
// For memory chart, hide ZFS ARC and Free from legend if they have no data
if (chartType === "memory") {
if (entry.dataKey === "memoryZfsArc" && !hasZfsArc) return null
if (entry.dataKey === "memoryFree" && !hasMemoryFree) return null
}
const isVisible = visibleLines[chartType][entry.dataKey as keyof (typeof visibleLines)[typeof chartType]]
return (
<div
@@ -428,26 +437,32 @@ export function NodeMetricsCharts() {
name="Used"
hide={!visibleLines.memory.memoryUsed}
/>
<Area
type="monotone"
dataKey="memoryZfsArc"
stroke="#f59e0b"
strokeWidth={2}
fill="#f59e0b"
fillOpacity={0.3}
name="ZFS ARC"
hide={!visibleLines.memory.memoryZfsArc}
/>
<Area
type="monotone"
dataKey="memoryFree"
stroke="#06b6d4"
strokeWidth={2}
fill="#06b6d4"
fillOpacity={0.3}
name="Available"
hide={!visibleLines.memory.memoryFree}
/>
{/* Only show ZFS ARC if there's data */}
{hasZfsArc && (
<Area
type="monotone"
dataKey="memoryZfsArc"
stroke="#f59e0b"
strokeWidth={2}
fill="#f59e0b"
fillOpacity={0.3}
name="ZFS ARC"
hide={!visibleLines.memory.memoryZfsArc}
/>
)}
{/* Only show Free memory if there's data */}
{hasMemoryFree && (
<Area
type="monotone"
dataKey="memoryFree"
stroke="#06b6d4"
strokeWidth={2}
fill="#06b6d4"
fillOpacity={0.3}
name="Free"
hide={!visibleLines.memory.memoryFree}
/>
)}
</AreaChart>
</ResponsiveContainer>
</CardContent>
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -4,7 +4,7 @@ import type React from "react"
import { useState, useEffect } from "react"
import { Button } from "./ui/button"
import { Dialog, DialogContent } from "./ui/dialog"
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
import {
ChevronLeft,
ChevronRight,
@@ -159,6 +159,7 @@ export function OnboardingCarousel() {
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl p-0 gap-0 overflow-hidden border-0 bg-transparent">
<DialogTitle className="sr-only">ProxMenux Onboarding</DialogTitle>
<div className="relative bg-card rounded-lg overflow-hidden shadow-2xl">
<Button
variant="ghost"
+194 -26
View File
@@ -11,10 +11,12 @@ import { VirtualMachines } from "./virtual-machines"
import Hardware from "./hardware"
import { SystemLogs } from "./system-logs"
import { Settings } from "./settings"
import { Security } from "./security"
import { OnboardingCarousel } from "./onboarding-carousel"
import { HealthStatusModal } from "./health-status-modal"
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
import { getApiUrl, fetchApi } from "../lib/api-config"
import { TerminalPanel } from "./terminal-panel"
import {
RefreshCw,
AlertTriangle,
@@ -29,6 +31,9 @@ import {
Cpu,
FileText,
SettingsIcon,
Terminal,
ShieldCheck,
Info,
} from "lucide-react"
import Image from "next/image"
import { ThemeToggle } from "./theme-toggle"
@@ -74,11 +79,77 @@ export function ProxmoxDashboard() {
const [componentKey, setComponentKey] = useState(0)
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)
const { showReleaseNotes, setShowReleaseNotes } = useVersionCheck()
// Category keys for health info count calculation
const HEALTH_CATEGORY_KEYS = [
{ key: "cpu", category: "temperature" },
{ key: "memory", category: "memory" },
{ key: "storage", category: "storage" },
{ key: "disks", category: "disks" },
{ key: "network", category: "network" },
{ key: "vms", category: "vms" },
{ key: "services", category: "pve_services" },
{ key: "logs", category: "logs" },
{ key: "updates", category: "updates" },
{ 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 {
const response = await fetchApi("/api/health/full")
let calculatedInfoCount = 0
if (response && response.health?.details) {
// Get categories that have dismissed items (these become INFO)
const customCats = new Set((response.custom_suppressions || []).map((cs: { category: string }) => cs.category))
const filteredDismissed = (response.dismissed || []).filter((item: { category: string }) => !customCats.has(item.category))
const categoriesWithDismissed = new Set<string>()
filteredDismissed.forEach((item: { category: string }) => {
const catMeta = HEALTH_CATEGORY_KEYS.find(c => c.category === item.category || c.key === item.category)
if (catMeta) {
categoriesWithDismissed.add(catMeta.key)
}
})
// Count effective INFO categories (original INFO + OK categories with dismissed)
HEALTH_CATEGORY_KEYS.forEach(({ key }) => {
const cat = response.health.details[key as keyof typeof response.health.details]
if (cat) {
const originalStatus = cat.status?.toUpperCase()
// Count as INFO if: originally INFO OR (originally OK and has dismissed items)
if (originalStatus === "INFO" || (originalStatus === "OK" && categoriesWithDismissed.has(key))) {
calculatedInfoCount++
}
}
})
}
setInfoCount(calculatedInfoCount)
} catch (error) {
// Silently fail - infoCount will remain at 0
}
}, [])
const fetchSystemData = useCallback(async () => {
try {
const data: FlaskSystemInfo = await fetchApi("/api/system-info")
@@ -106,7 +177,7 @@ export function ProxmoxDashboard() {
})
setIsServerConnected(true)
} catch (error) {
console.error("[v0] Failed to fetch system data from Flask server:", error)
// Expected to fail in v0 preview (no Flask server)
setIsServerConnected(false)
setSystemStatus((prev) => ({
@@ -121,22 +192,28 @@ export function ProxmoxDashboard() {
}, [])
useEffect(() => {
// Siempre fetch inicial
fetchSystemData()
// 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
let interval: ReturnType<typeof setInterval> | null = null
let healthInterval: ReturnType<typeof setInterval> | null = null
if (activeTab === "overview") {
interval = setInterval(fetchSystemData, 30000) // 30 segundos
healthInterval = setInterval(fetchHealthInfoCount, 30000) // Also refresh info count
} else {
interval = setInterval(fetchSystemData, 60000) // 60 segundos
healthInterval = setInterval(fetchHealthInfoCount, 60000) // Also refresh info count
}
return () => {
if (interval) clearInterval(interval)
if (healthInterval) clearInterval(healthInterval)
}
}, [fetchSystemData, activeTab])
}, [fetchSystemData, fetchHealthInfoCount, fetchUpdateStatus, activeTab])
useEffect(() => {
const handleChangeTab = (event: CustomEvent) => {
@@ -151,10 +228,28 @@ 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) => {
const { status } = event.detail
const { status, infoCount: newInfoCount } = event.detail
let healthStatus: "healthy" | "warning" | "critical"
if (status === "CRITICAL") {
@@ -169,6 +264,11 @@ export function ProxmoxDashboard() {
...prev,
status: healthStatus,
}))
// Update info count (INFO categories + dismissed items)
if (typeof newInfoCount === "number") {
setInfoCount(newInfoCount)
}
}
window.addEventListener("healthStatusUpdated", handleHealthStatusUpdate as EventListener)
@@ -259,10 +359,14 @@ export function ProxmoxDashboard() {
return "VMs & LXCs"
case "hardware":
return "Hardware"
case "terminal":
return "Terminal"
case "logs":
return "System Logs"
case "settings":
return "Settings"
case "security":
return "Security"
case "settings":
return "Settings"
default:
return "Navigation Menu"
}
@@ -305,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")
@@ -342,10 +445,18 @@ export function ProxmoxDashboard() {
</div>
</div>
<Badge variant="outline" className={statusColor}>
{statusIcon}
<span className="ml-1 capitalize">{systemStatus.status}</span>
</Badge>
<div className="flex items-center gap-2">
<Badge variant="outline" className={statusColor}>
{statusIcon}
<span className="ml-1 capitalize">{systemStatus.status}</span>
</Badge>
{systemStatus.status === "healthy" && infoCount > 0 && (
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
<Info className="h-4 w-4" />
<span className="ml-1">{infoCount} info</span>
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground whitespace-nowrap">
Uptime: {systemStatus.uptime || "N/A"}
@@ -371,11 +482,18 @@ export function ProxmoxDashboard() {
</div>
{/* Mobile Actions */}
<div className="flex lg:hidden items-center gap-2">
<Badge variant="outline" className={`${statusColor} text-xs px-2`}>
{statusIcon}
<span className="ml-1 capitalize hidden sm:inline">{systemStatus.status}</span>
</Badge>
<div className="flex lg:hidden items-start gap-2 pt-2">
<div className="flex flex-col items-end gap-1">
<Badge variant="outline" className={`${statusColor} text-xs px-2`}>
{statusIcon}
</Badge>
{systemStatus.status === "healthy" && infoCount > 0 && (
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20 text-xs px-2">
<Info className="h-4 w-4" />
<span className="ml-1">{infoCount}</span>
</Badge>
)}
</div>
<Button
variant="ghost"
@@ -385,12 +503,12 @@ export function ProxmoxDashboard() {
refreshData()
}}
disabled={isRefreshing}
className="h-8 w-8 p-0"
className="h-8 w-8 p-0 -mt-1"
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
</Button>
<div onClick={(e) => e.stopPropagation()}>
<div onClick={(e) => e.stopPropagation()} className="-mt-1">
<ThemeToggle />
</div>
</div>
@@ -405,14 +523,14 @@ export function ProxmoxDashboard() {
<div
className={`sticky z-40 bg-background
top-[120px] md:top-[76px]
transition-all duration-700 ease-[cubic-bezier(0.4,0,0.2,1)]
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-7 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"
@@ -449,6 +567,18 @@ export function ProxmoxDashboard() {
>
System Logs
</TabsTrigger>
<TabsTrigger
value="terminal"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
>
Terminal
</TabsTrigger>
<TabsTrigger
value="security"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
>
Security
</TabsTrigger>
<TabsTrigger
value="settings"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
@@ -458,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"
@@ -563,6 +693,36 @@ export function ProxmoxDashboard() {
<FileText className="h-5 w-5" />
<span>System Logs</span>
</Button>
<Button
variant="ghost"
onClick={() => {
setActiveTab("terminal")
setMobileMenuOpen(false)
}}
className={`w-full justify-start gap-3 ${
activeTab === "terminal"
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
: ""
}`}
>
<Terminal className="h-5 w-5" />
<span>Terminal</span>
</Button>
<Button
variant="ghost"
onClick={() => {
setActiveTab("security")
setMobileMenuOpen(false)
}}
className={`w-full justify-start gap-3 ${
activeTab === "security"
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
: ""
}`}
>
<ShieldCheck className="h-5 w-5" />
<span>Security</span>
</Button>
<Button
variant="ghost"
onClick={() => {
@@ -611,13 +771,21 @@ export function ProxmoxDashboard() {
<SystemLogs key={`logs-${componentKey}`} />
</TabsContent>
<TabsContent value="terminal" className="mt-0">
<TerminalPanel key={`terminal-${componentKey}`} />
</TabsContent>
<TabsContent value="security" className="space-y-4 md:space-y-6 mt-0">
<Security key={`security-${componentKey}`} />
</TabsContent>
<TabsContent value="settings" className="space-y-4 md:space-y-6 mt-0">
<Settings />
</TabsContent>
</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.1</p>
<p className="font-medium mb-2">ProxMenux Monitor v1.2.0</p>
<p>
<a
href="https://ko-fi.com/macrimi"
+54 -30
View File
@@ -2,11 +2,11 @@
import { useState, useEffect } from "react"
import { Button } from "./ui/button"
import { Dialog, DialogContent } from "./ui/dialog"
import { X, Sparkles, Link2, Shield, Zap, HardDrive, Gauge, Wrench, Settings } from "lucide-react"
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.1" // Sync with AppImage/package.json
const APP_VERSION = "1.2.0" // Sync with AppImage/package.json
interface ReleaseNote {
date: string
@@ -18,6 +18,32 @@ interface ReleaseNote {
}
export const CHANGELOG: Record<string, ReleaseNote> = {
"1.1.2-beta": {
date: "March 18, 2026",
changes: {
added: [
"Temperature & Latency Charts - Real-time visual monitoring with interactive graphs",
"WebSocket Terminal - Direct access to Proxmox host and LXC containers terminal",
"AI-Enhanced Notifications - Intelligent message formatting with multi-provider support (OpenAI, Groq, Anthropic, Ollama)",
"Security Section - Comprehensive security settings for ProxMenux and Proxmox",
"VPN Integration - Easy Tailscale VPN installation and configuration",
"GPU Scripts - Installation utilities for Intel, AMD and NVIDIA drivers",
"Disk Observations System - Track and document disk health observations over time",
"Enhanced Health Monitor - Configurable monitoring with advanced settings panel",
],
changed: [
"Improved overall performance with optimized data fetching",
"Notifications now support rich formatting with contextual emojis",
"Health monitor now configurable from Settings section",
"Better Proxmox service name translation for non-expert users",
],
fixed: [
"Fixed notification message truncation for large backup reports",
"Improved disk error deduplication to prevent repeated alerts",
"Corrected AI provider base URL handling for OpenAI-compatible APIs",
],
},
},
"1.0.1": {
date: "November 11, 2025",
changes: {
@@ -25,23 +51,16 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
"Proxy Support - Access ProxMenux through reverse proxies with full functionality",
"Authentication System - Secure your dashboard with password protection",
"PCIe Link Speed Detection - View NVMe drive connection speeds and detect performance issues",
"Enhanced Storage Display - Better formatting for disk sizes (auto-converts GB to TB when needed)",
"SATA/SAS Information - View detailed interface information for all storage devices",
"Two-Factor Authentication (2FA) - Enhanced security with TOTP support",
"Health Monitoring System - Comprehensive system health checks with dismissible warnings",
"Release Notes Modal - Automatic notification of new features and improvements",
],
changed: [
"Optimized VM & LXC page - Reduced CPU usage by 85% through intelligent caching",
"Storage metrics now separate local and remote storage for clarity",
"Update warnings now appear only after 365 days instead of 30 days",
"API intervals staggered to distribute server load (23s and 37s)",
],
fixed: [
"Fixed dark mode text contrast issues in various components",
"Corrected storage calculation discrepancies between Overview and Storage pages",
"Resolved JSON stringify error in VM control actions",
"Improved IP address fetching for LXC containers",
],
},
},
@@ -63,32 +82,36 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
const CURRENT_VERSION_FEATURES = [
{
icon: <Link2 className="h-5 w-5" />,
text: "Proxy Support - Access ProxMenux through reverse proxies with full functionality",
icon: <Thermometer className="h-5 w-5" />,
text: "Temperature & Latency Charts - Real-time visual monitoring with interactive historical graphs",
},
{
icon: <Terminal className="h-5 w-5" />,
text: "WebSocket Terminal - Direct terminal access to Proxmox host and LXC containers from the browser",
},
{
icon: <Activity className="h-5 w-5" />,
text: "Enhanced Health Monitor - Configurable health monitoring with advanced settings and disk observations",
},
{
icon: <Bell className="h-5 w-5" />,
text: "AI-Enhanced Notifications - Intelligent message formatting with support for OpenAI, Groq, Anthropic and Ollama",
},
{
icon: <Shield className="h-5 w-5" />,
text: "Two-Factor Authentication (2FA) - Enhanced security with TOTP support for login protection",
text: "Security Section - Comprehensive security configuration for both ProxMenux and Proxmox systems",
},
{
icon: <Globe className="h-5 w-5" />,
text: "VPN Integration - Easy Tailscale VPN installation and configuration for secure remote access",
},
{
icon: <Cpu className="h-5 w-5" />,
text: "GPU Drivers - Installation scripts for Intel, AMD and NVIDIA graphics drivers and utilities",
},
{
icon: <Zap className="h-5 w-5" />,
text: "Performance Improvements - Optimized loading times and reduced CPU usage across the application",
},
{
icon: <HardDrive className="h-5 w-5" />,
text: "Storage Enhancements - Improved disk space consumption display with local and remote storage separation",
},
{
icon: <Gauge className="h-5 w-5" />,
text: "PCIe Link Speed Detection - View NVMe drive connection speeds and identify performance bottlenecks",
},
{
icon: <Wrench className="h-5 w-5" />,
text: "Hardware Page Improvements - Enhanced hardware information display with detailed PCIe and interface data",
},
{
icon: <Settings className="h-5 w-5" />,
text: "New Settings Page - Centralized configuration for authentication, optimizations, and system preferences",
text: "Performance Improvements - Optimized data fetching and reduced resource consumption",
},
]
@@ -110,6 +133,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[85vh] p-0 gap-0 border-0 bg-transparent">
<DialogTitle className="sr-only">Release Notes - Version {APP_VERSION}</DialogTitle>
<div className="relative bg-card rounded-lg shadow-2xl h-full flex flex-col max-h-[85vh]">
<Button
variant="ghost"
@@ -0,0 +1,950 @@
"use client"
import type React from "react"
import { useState, useEffect, useRef, useCallback } from "react"
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Loader2,
Activity,
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
CornerDownLeft,
GripHorizontal,
ChevronDown,
} from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu"
import "xterm/css/xterm.css"
import { API_PORT } from "@/lib/api-config"
interface WebInteraction {
type: "yesno" | "menu" | "msgbox" | "input" | "inputbox"
id: string
title: string
message: string
options?: Array<{ label: string; value: string }>
default?: string
}
interface ScriptTerminalModalProps {
open: boolean
onClose: () => void
scriptPath: string
title: string
description: string
scriptName?: string
params?: Record<string, string>
}
export function ScriptTerminalModal({
open: isOpen,
onClose,
scriptPath,
title,
description,
params = { EXECUTION_MODE: "web" },
}: ScriptTerminalModalProps) {
const termRef = useRef<any>(null)
const wsRef = useRef<WebSocket | null>(null)
const fitAddonRef = useRef<any>(null)
const sessionIdRef = useRef<string>(Math.random().toString(36).substring(2, 8))
const [connectionStatus, setConnectionStatus] = useState<"connecting" | "online" | "offline">("connecting")
const [isComplete, setIsComplete] = useState(false)
const [currentInteraction, setCurrentInteraction] = useState<WebInteraction | null>(null)
const [interactionInput, setInteractionInput] = useState("")
const checkConnectionInterval = useRef<NodeJS.Timeout | null>(null)
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const reconnectAttemptsRef = useRef(0)
const keepAliveIntervalRef = useRef<NodeJS.Timeout | null>(null)
const [isMobile, setIsMobile] = useState(false)
const [isTablet, setIsTablet] = useState(false)
const [isWaitingNextInteraction, setIsWaitingNextInteraction] = useState(false)
const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const [modalHeight, setModalHeight] = useState(600)
const [isResizing, setIsResizing] = useState(false)
const resizeBarRef = useRef<HTMLDivElement>(null)
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) {
return
}
reconnectAttemptsRef.current++
setConnectionStatus("connecting")
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
reconnectTimeoutRef.current = setTimeout(() => {
if (wsRef.current?.readyState !== WebSocket.OPEN && termRef.current) {
if (wsRef.current) {
wsRef.current.close()
}
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
const ws = new WebSocket(wsUrl)
wsRef.current = ws
ws.onopen = () => {
setConnectionStatus("online")
reconnectAttemptsRef.current = 0
if (keepAliveIntervalRef.current) {
clearInterval(keepAliveIntervalRef.current)
}
keepAliveIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }))
}
}, 30000)
const initMessage = {
script_path: scriptPath,
params: paramsRef.current,
}
ws.send(JSON.stringify(initMessage))
setTimeout(() => {
if (fitAddonRef.current && termRef.current && ws.readyState === WebSocket.OPEN) {
const cols = termRef.current.cols
const rows = termRef.current.rows
ws.send(JSON.stringify({ type: "resize", cols, rows }))
}
}, 100)
}
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) {
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) {
clearTimeout(waitingTimeoutRef.current)
}
setCurrentInteraction({
type: msg.interaction.type,
id: msg.interaction.id,
title: msg.interaction.title || "",
message: msg.interaction.message || "",
options: msg.interaction.options,
default: msg.interaction.default,
})
return
}
if (msg.type === "error") {
termRef.current?.writeln(`\x1b[31m${msg.message}\x1b[0m`)
return
}
} catch {}
termRef.current?.write(event.data)
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) {
clearTimeout(waitingTimeoutRef.current)
}
}
ws.onerror = () => {
setConnectionStatus("offline")
}
ws.onclose = (event) => {
setConnectionStatus("offline")
if (keepAliveIntervalRef.current) {
clearInterval(keepAliveIntervalRef.current)
keepAliveIntervalRef.current = null
}
if (!isComplete && reconnectAttemptsRef.current < 3) {
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
} else {
setIsComplete(true)
}
}
}
}, 1000)
}, [isOpen, isComplete, scriptPath])
const sendKey = useCallback((key: string) => {
if (!termRef.current) return
const keyMap: Record<string, string> = {
escape: "\x1b",
tab: "\t",
up: "\x1bOA",
down: "\x1bOB",
left: "\x1bOD",
right: "\x1bOC",
enter: "\r",
ctrlc: "\x03",
}
const sequence = keyMap[key]
if (sequence && wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(sequence)
}
}, [])
const initializeTerminal = async () => {
const [TerminalClass, FitAddonClass] = await Promise.all([
import("xterm").then((mod) => mod.Terminal),
import("xterm-addon-fit").then((mod) => mod.FitAddon),
import("xterm/css/xterm.css"),
])
const fontSize = window.innerWidth < 768 ? 12 : 16
const term = new TerminalClass({
rendererType: "dom",
fontFamily: '"Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace',
fontSize: fontSize,
lineHeight: 1,
cursorBlink: true,
scrollback: 2000,
disableStdin: false,
customGlyphs: true,
fontWeight: "500",
fontWeightBold: "700",
theme: {
background: "#000000",
foreground: "#ffffff",
cursor: "#ffffff",
cursorAccent: "#000000",
black: "#2e3436",
red: "#cc0000",
green: "#4e9a06",
yellow: "#c4a000",
blue: "#3465a4",
magenta: "#75507b",
cyan: "#06989a",
white: "#d3d7cf",
brightBlack: "#555753",
brightRed: "#ef2929",
brightGreen: "#8ae234",
brightYellow: "#fce94f",
brightBlue: "#729fcf",
brightMagenta: "#ad7fa8",
brightCyan: "#34e2e2",
brightWhite: "#eeeeec",
},
})
const fitAddon = new FitAddonClass()
term.loadAddon(fitAddon)
if (terminalContainerRef.current) {
term.open(terminalContainerRef.current)
}
termRef.current = term
fitAddonRef.current = fitAddon
setTimeout(() => {
if (fitAddonRef.current && termRef.current) {
fitAddonRef.current.fit()
}
}, 100)
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
const ws = new WebSocket(wsUrl)
wsRef.current = ws
ws.onopen = () => {
setConnectionStatus("online")
if (keepAliveIntervalRef.current) {
clearInterval(keepAliveIntervalRef.current)
}
keepAliveIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }))
}
}, 30000)
const initMessage = {
script_path: scriptPath,
params: paramsRef.current,
}
ws.send(JSON.stringify(initMessage))
setTimeout(() => {
if (fitAddonRef.current && termRef.current && ws.readyState === WebSocket.OPEN) {
const cols = termRef.current.cols
const rows = termRef.current.rows
ws.send(
JSON.stringify({
type: "resize",
cols: cols,
rows: rows,
}),
)
}
}, 100)
}
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)
if (msg.type === "web_interaction" && msg.interaction) {
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) {
clearTimeout(waitingTimeoutRef.current)
}
setCurrentInteraction({
type: msg.interaction.type,
id: msg.interaction.id,
title: msg.interaction.title || "",
message: msg.interaction.message || "",
options: msg.interaction.options,
default: msg.interaction.default,
})
return
}
if (msg.type === "error") {
term.writeln(`\x1b[31m${msg.message}\x1b[0m`)
return
}
} catch {
// Not JSON, es output normal de terminal
}
term.write(event.data)
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) {
clearTimeout(waitingTimeoutRef.current)
}
}
ws.onerror = (error) => {
setConnectionStatus("offline")
term.writeln("\x1b[31mWebSocket error occurred\x1b[0m")
}
ws.onclose = (event) => {
setConnectionStatus("offline")
term.writeln("\x1b[33mConnection closed\x1b[0m")
if (keepAliveIntervalRef.current) {
clearInterval(keepAliveIntervalRef.current)
keepAliveIntervalRef.current = null
}
if (!isComplete) {
setIsComplete(true)
}
}
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data)
}
})
checkConnectionInterval.current = setInterval(() => {
if (wsRef.current) {
setConnectionStatus(
wsRef.current.readyState === WebSocket.OPEN
? "online"
: wsRef.current.readyState === WebSocket.CONNECTING
? "connecting"
: "offline",
)
}
}, 500)
let resizeTimeout: NodeJS.Timeout | null = null
const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout) clearTimeout(resizeTimeout)
resizeTimeout = setTimeout(() => {
if (fitAddonRef.current && termRef.current && wsRef.current?.readyState === WebSocket.OPEN) {
fitAddonRef.current.fit()
wsRef.current.send(
JSON.stringify({
type: "resize",
cols: termRef.current.cols,
rows: termRef.current.rows,
}),
)
}
}, 100)
})
if (terminalContainerRef.current) {
resizeObserver.observe(terminalContainerRef.current)
}
}
useEffect(() => {
const savedHeight = localStorage.getItem("scriptModalHeight")
if (savedHeight) {
const height = Number.parseInt(savedHeight, 10)
setModalHeight(height)
modalHeightRef.current = height
}
if (isOpen) {
initializeTerminal()
} else {
if (checkConnectionInterval.current) {
clearInterval(checkConnectionInterval.current)
}
if (waitingTimeoutRef.current) {
clearTimeout(waitingTimeoutRef.current)
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
if (termRef.current) {
termRef.current.dispose()
termRef.current = null
}
if (keepAliveIntervalRef.current) {
clearInterval(keepAliveIntervalRef.current)
keepAliveIntervalRef.current = null
}
sessionIdRef.current = Math.random().toString(36).substring(2, 8)
reconnectAttemptsRef.current = 0
setIsComplete(false)
setInteractionInput("")
setCurrentInteraction(null)
setIsWaitingNextInteraction(false)
setConnectionStatus("connecting")
}
}, [isOpen])
useEffect(() => {
const updateDeviceType = () => {
const width = window.innerWidth
const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0
const isTabletSize = width >= 768 && width <= 1366
setIsMobile(width < 768)
setIsTablet(isTouchDevice && isTabletSize)
}
updateDeviceType()
const handleResize = () => updateDeviceType()
window.addEventListener("resize", handleResize)
const handleVisibilityChange = () => {
if (!document.hidden && isOpen) {
if (wsRef.current?.readyState !== WebSocket.OPEN && !isComplete) {
attemptReconnect()
}
}
}
const handleFocus = () => {
if (isOpen && wsRef.current?.readyState !== WebSocket.OPEN && !isComplete) {
attemptReconnect()
}
}
let wakeLock: any = null
const requestWakeLock = async () => {
if ("wakeLock" in navigator && isOpen) {
try {
wakeLock = await (navigator as any).wakeLock.request("screen")
} catch (err) {
// Wake Lock no soportado o denegado, continuar sin él
}
}
}
requestWakeLock()
document.addEventListener("visibilitychange", handleVisibilityChange)
window.addEventListener("focus", handleFocus)
return () => {
window.removeEventListener("resize", handleResize)
document.removeEventListener("visibilitychange", handleVisibilityChange)
window.removeEventListener("focus", handleFocus)
if (wakeLock) {
wakeLock.release().catch(() => {})
}
}
}, [isOpen, isComplete, attemptReconnect])
const getScriptWebSocketUrl = (sid: string): string => {
if (typeof window === "undefined") {
return `ws://localhost:${API_PORT}/ws/script/${sid}`
}
const { protocol, hostname, port } = window.location
const isStandardPort = port === "" || port === "80" || port === "443"
const wsProtocol = protocol === "https:" ? "wss:" : "ws:"
if (isStandardPort) {
return `${wsProtocol}//${hostname}/ws/script/${sid}`
} else {
return `${wsProtocol}//${hostname}:${API_PORT}/ws/script/${sid}`
}
}
const handleInteractionResponse = (value: string) => {
if (!wsRef.current || !currentInteraction) {
return
}
if (value === "cancel" || value === "") {
setCurrentInteraction(null)
setInteractionInput("")
handleCloseModal()
return
}
const response = JSON.stringify({
type: "interaction_response",
id: currentInteraction.id,
value: value,
})
if (wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(response)
}
setCurrentInteraction(null)
setInteractionInput("")
waitingTimeoutRef.current = setTimeout(() => {
setIsWaitingNextInteraction(true)
}, 50)
}
const handleCloseModal = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.close()
}
if (checkConnectionInterval.current) {
clearInterval(checkConnectionInterval.current)
}
if (termRef.current) {
termRef.current.dispose()
}
onClose()
}
const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault()
e.stopPropagation()
setIsResizing(true)
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY
const startY = clientY
const startHeight = modalHeight
const handleMove = (moveEvent: MouseEvent | TouchEvent) => {
const currentY = "touches" in moveEvent ? moveEvent.touches[0].clientY : moveEvent.clientY
const deltaY = currentY - startY
const newHeight = Math.max(300, Math.min(window.innerHeight - 50, startHeight + deltaY))
modalHeightRef.current = newHeight
setModalHeight(newHeight)
}
const handleEnd = () => {
const finalHeight = modalHeightRef.current
setIsResizing(false)
document.removeEventListener("mousemove", handleMove as any)
document.removeEventListener("mouseup", handleEnd)
document.removeEventListener("touchmove", handleMove as any)
document.removeEventListener("touchend", handleEnd)
document.removeEventListener("touchcancel", handleEnd)
localStorage.setItem("scriptModalHeight", finalHeight.toString())
if (fitAddonRef.current && termRef.current && wsRef.current?.readyState === WebSocket.OPEN) {
setTimeout(() => {
fitAddonRef.current?.fit()
wsRef.current?.send(
JSON.stringify({
type: "resize",
cols: termRef.current.cols,
rows: termRef.current.rows,
}),
)
}, 100)
}
}
document.addEventListener("mousemove", handleMove as any)
document.addEventListener("mouseup", handleEnd)
document.addEventListener("touchmove", handleMove as any, { passive: false })
document.addEventListener("touchend", handleEnd)
document.addEventListener("touchcancel", handleEnd)
}
const sendCommand = (command: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(command)
}
}
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className="max-w-7xl p-0 flex flex-col gap-0 overflow-hidden"
style={{
height: isMobile ? "80vh" : `${modalHeight}px`,
maxHeight: "none",
}}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
hideClose
>
<DialogTitle className="sr-only">{title}</DialogTitle>
<div className="flex items-center gap-2 p-4 border-b">
<div>
<h2 className="text-lg font-semibold">{title}</h2>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
</div>
<div className="overflow-hidden relative flex-1">
<div ref={terminalContainerRef} className="w-full h-full" />
{isWaitingNextInteraction && !currentInteraction && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
<p className="text-sm text-muted-foreground">Processing...</p>
</div>
</div>
)}
</div>
{!isMobile && (
<div
ref={resizeBarRef}
onMouseDown={handleResizeStart}
onTouchStart={handleResizeStart}
className={`h-4 w-full cursor-row-resize transition-colors flex items-center justify-center group relative ${
isResizing ? "bg-blue-500" : "bg-zinc-800 hover:bg-blue-600"
}`}
style={{ touchAction: "none" }}
>
<GripHorizontal
className={`h-5 w-5 transition-colors pointer-events-none ${
isResizing ? "text-white" : "text-zinc-600 group-hover:text-white"
}`}
/>
</div>
)}
{(isMobile || isTablet) && (
<div className="flex items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
sendCommand("\x1b")
}}
variant="outline"
size="sm"
className="h-8 px-2 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white min-w-[50px]"
>
ESC
</Button>
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
sendCommand("\t")
}}
variant="outline"
size="sm"
className="h-8 px-2 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white min-w-[50px]"
>
TAB
</Button>
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
sendCommand("\x1bOA")
}}
variant="outline"
size="sm"
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
sendCommand("\x1bOB")
}}
variant="outline"
size="sm"
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
sendCommand("\x1bOD")
}}
variant="outline"
size="sm"
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
sendCommand("\x1bOC")
}}
variant="outline"
size="sm"
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
>
<ArrowRight className="h-4 w-4" />
</Button>
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
sendCommand("\r")
}}
variant="outline"
size="sm"
className="h-8 px-2.5 text-xs bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400"
>
<CornerDownLeft className="h-4 w-4 mr-1" />
Enter
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-2 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white min-w-[65px] gap-1"
>
Ctrl
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => sendCommand("\x03")}>
<span className="font-mono text-xs mr-2">Ctrl+C</span>
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendCommand("\x18")}>
<span className="font-mono text-xs mr-2">Ctrl+X</span>
<span className="text-muted-foreground text-xs">Exit (nano)</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendCommand("\x12")}>
<span className="font-mono text-xs mr-2">Ctrl+R</span>
<span className="text-muted-foreground text-xs">Search history</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
<div className="flex items-center justify-between px-4 py-3 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-blue-500" />
<div
className={`w-2 h-2 rounded-full ${
connectionStatus === "online"
? "bg-green-500"
: connectionStatus === "connecting"
? "bg-blue-500"
: "bg-red-500"
}`}
title={
connectionStatus === "online"
? "Connected"
: connectionStatus === "connecting"
? "Connecting"
: "Disconnected"
}
></div>
<span className="text-xs text-muted-foreground">
{connectionStatus === "online"
? "Online"
: connectionStatus === "connecting"
? "Connecting..."
: "Offline"}
</span>
</div>
<Button
onClick={handleCloseModal}
variant="outline"
className="bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
>
Close
</Button>
</div>
</DialogContent>
</Dialog>
{currentInteraction && (
<Dialog open={true}>
<DialogContent
className="max-w-4xl max-h-[80vh] overflow-y-auto animate-in fade-in-0 zoom-in-95 duration-100"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
hideClose
>
<DialogTitle>{currentInteraction.title}</DialogTitle>
<div className="space-y-4">
<p
className="whitespace-pre-wrap"
dangerouslySetInnerHTML={{
__html: currentInteraction.message.replace(/\\n/g, "<br/>").replace(/\n/g, "<br/>"),
}}
/>
{currentInteraction.type === "yesno" && (
<div className="flex gap-2">
<Button
onClick={() => handleInteractionResponse("yes")}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white transition-all duration-150"
>
Yes
</Button>
<Button
onClick={() => handleInteractionResponse("cancel")}
variant="outline"
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
>
Cancel
</Button>
</div>
)}
{currentInteraction.type === "menu" && currentInteraction.options && (
<div className="space-y-2">
{currentInteraction.options.map((option, index) => (
<Button
key={option.value}
onClick={() => handleInteractionResponse(option.value)}
variant="outline"
className="w-full justify-start hover:bg-blue-600 hover:text-white transition-all duration-100 animate-in fade-in-0 slide-in-from-left-2"
style={{ animationDelay: `${index * 30}ms` }}
>
{option.label}
</Button>
))}
<Button
onClick={() => handleInteractionResponse("cancel")}
variant="outline"
className="w-full hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
>
Cancel
</Button>
</div>
)}
{(currentInteraction.type === "input" || currentInteraction.type === "inputbox") && (
<div className="space-y-2">
<Label>Your input:</Label>
<Input
value={interactionInput}
onChange={(e) => setInteractionInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleInteractionResponse(interactionInput)
}
}}
placeholder={currentInteraction.default || ""}
className="transition-all duration-150"
/>
<div className="flex gap-2">
<Button
onClick={() => handleInteractionResponse(interactionInput)}
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
>
Submit
</Button>
<Button
onClick={() => handleInteractionResponse("cancel")}
variant="outline"
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
>
Cancel
</Button>
</div>
</div>
)}
{currentInteraction.type === "msgbox" && (
<div className="flex gap-2">
<Button
onClick={() => handleInteractionResponse("ok")}
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
>
OK
</Button>
<Button
onClick={() => handleInteractionResponse("cancel")}
variant="outline"
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
>
Cancel
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
)}
</>
)
}
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
+114 -2
View File
@@ -1,4 +1,6 @@
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon } from "lucide-react"
"use client"
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon, Terminal } from "lucide-react"
const menuItems = [
{ name: "Overview", href: "/", icon: LayoutDashboard },
@@ -7,7 +9,117 @@ const menuItems = [
{ name: "Virtual Machines", href: "/virtual-machines", icon: Server },
{ name: "Hardware", href: "/hardware", icon: Cpu },
{ name: "System Logs", href: "/logs", icon: FileText },
{ name: "Terminal", href: "/terminal", icon: Terminal },
{ name: "Settings", href: "/settings", icon: SettingsIcon },
]
// ... existing code ...
const Sidebar = ({ currentPath, setOpen }) => {
const handleNavigation = (tabName: string) => {
// Dispatch custom event to change tab in dashboard
const event = new CustomEvent("changeTab", { detail: { tab: tabName } })
window.dispatchEvent(event)
setOpen(false)
}
return (
<div>
<button
onClick={() => handleNavigation("overview")}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
currentPath === "/" || currentPath === "/overview"
? "bg-blue-500/10 text-blue-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
<LayoutDashboard className="h-5 w-5" />
<span>Overview</span>
</button>
<button
onClick={() => handleNavigation("storage")}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
currentPath === "/storage"
? "bg-blue-500/10 text-blue-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
<HardDrive className="h-5 w-5" />
<span>Storage</span>
</button>
<button
onClick={() => handleNavigation("network")}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
currentPath === "/network"
? "bg-blue-500/10 text-blue-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
<Network className="h-5 w-5" />
<span>Network</span>
</button>
<button
onClick={() => handleNavigation("vms")}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
currentPath === "/virtual-machines"
? "bg-blue-500/10 text-blue-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
<Server className="h-5 w-5" />
<span>VMs & LXCs</span>
</button>
<button
onClick={() => handleNavigation("hardware")}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
currentPath === "/hardware"
? "bg-blue-500/10 text-blue-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
<Cpu className="h-5 w-5" />
<span>Hardware</span>
</button>
<button
onClick={() => handleNavigation("logs")}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
currentPath === "/logs"
? "bg-blue-500/10 text-blue-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
<FileText className="h-5 w-5" />
<span>System Logs</span>
</button>
<button
onClick={() => handleNavigation("terminal")}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
currentPath === "/terminal"
? "bg-blue-500/10 text-blue-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
<Terminal className="h-5 w-5" />
<span>Terminal</span>
</button>
<button
onClick={() => handleNavigation("settings")}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
currentPath === "/settings"
? "bg-blue-500/10 text-blue-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
<SettingsIcon className="h-5 w-5" />
<span>Settings</span>
</button>
</div>
)
}
export default Sidebar
File diff suppressed because it is too large Load Diff
+100 -241
View File
@@ -30,16 +30,6 @@ import {
import { useState, useEffect, useMemo } from "react"
import { API_PORT, fetchApi } from "@/lib/api-config"
interface Log {
timestamp: string
level: string
service: string
message: string
source: string
pid?: string
hostname?: string
}
interface Backup {
volid: string
storage: string
@@ -76,6 +66,7 @@ interface SystemLog {
timestamp: string
level: string
service: string
unit?: string
message: string
source: string
pid?: string
@@ -86,6 +77,7 @@ interface CombinedLogEntry {
timestamp: string
level: string
service: string
unit?: string
message: string
source: string
pid?: string
@@ -108,161 +100,73 @@ export function SystemLogs() {
const [serviceFilter, setServiceFilter] = useState("all")
const [activeTab, setActiveTab] = useState("logs")
const [displayedLogsCount, setDisplayedLogsCount] = useState(50) // Increased from 500 to 50 for initial load, will use pagination
const [displayedLogsCount, setDisplayedLogsCount] = useState(100)
const [selectedLog, setSelectedLog] = useState<SystemLog | null>(null)
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null)
const [selectedBackup, setSelectedBackup] = useState<Backup | null>(null)
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null) // Added
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null)
const [isLogModalOpen, setIsLogModalOpen] = useState(false)
const [isEventModalOpen, setIsEventModalOpen] = useState(false)
const [isBackupModalOpen, setIsBackupModalOpen] = useState(false)
const [isNotificationModalOpen, setIsNotificationModalOpen] = useState(false) // Added
const [isNotificationModalOpen, setIsNotificationModalOpen] = useState(false)
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const [dateFilter, setDateFilter] = useState("1") // Changed from "now" to "1" to load 1 day by default
const [dateFilter, setDateFilter] = useState("1")
const [customDays, setCustomDays] = useState("1")
const [refreshCounter, setRefreshCounter] = useState(0)
const getApiUrl = (endpoint: string) => {
if (typeof window !== "undefined") {
const { protocol, hostname, port } = window.location
const isStandardPort = port === "" || port === "80" || port === "443"
if (isStandardPort) {
return endpoint
} else {
return `${protocol}//${hostname}:${API_PORT}${endpoint}`
}
}
// This part might not be strictly necessary if only running client-side, but good for SSR safety
// In a real SSR scenario, you'd need to handle API_PORT differently
const protocol = typeof window !== "undefined" ? window.location.protocol : "http:" // Defaulting to http for SSR safety
const hostname = typeof window !== "undefined" ? window.location.hostname : "localhost" // Defaulting to localhost for SSR safety
return `${protocol}//${hostname}:${API_PORT}${endpoint}`
}
// Single unified useEffect for all data loading
// Fires on mount, when filters change, or when refresh is triggered
useEffect(() => {
fetchAllData()
}, [])
// CHANGE: Simplified useEffect - always fetch logs with date filter (no more "now" option)
useEffect(() => {
console.log("[v0] Date filter changed:", dateFilter, "Custom days:", customDays)
setLoading(true)
fetchSystemLogs()
.then((newLogs) => {
console.log("[v0] Loaded logs for date filter:", dateFilter, "Count:", newLogs.length)
console.log("[v0] First log:", newLogs[0])
setLogs(newLogs)
setLoading(false)
})
.catch((err) => {
console.error("[v0] Error loading logs:", err)
setLoading(false)
})
}, [dateFilter, customDays])
useEffect(() => {
console.log("[v0] Level or service filter changed:", levelFilter, serviceFilter)
if (levelFilter !== "all" || serviceFilter !== "all") {
setLoading(true)
fetchSystemLogs()
.then((newLogs) => {
console.log(
"[v0] Loaded logs for filters - Level:",
levelFilter,
"Service:",
serviceFilter,
"Count:",
newLogs.length,
)
setLogs(newLogs)
setLoading(false)
})
.catch((err) => {
console.error("[v0] Error loading logs:", err)
setLoading(false)
})
} else {
// Only reload all data if we're on "now" and all filters are cleared
// This else block is now theoretically unreachable given the change above, but kept for safety
fetchAllData()
}
}, [levelFilter, serviceFilter])
const fetchAllData = async () => {
try {
let cancelled = false
const loadData = async () => {
setLoading(true)
setError(null)
const [logsRes, backupsRes, eventsRes, notificationsRes] = await Promise.all([
fetchSystemLogs(),
fetchApi("/api/backups"),
fetchApi("/api/events?limit=50"),
fetchApi("/api/notifications"),
])
setLogs(logsRes)
setBackups(backupsRes.backups || [])
setEvents(eventsRes.events || [])
setNotifications(notificationsRes.notifications || [])
} catch (err) {
console.error("[v0] Error fetching system logs data:", err)
setError("Failed to connect to server")
} finally {
setLoading(false)
try {
const [logsRes, backupsRes, eventsRes, notificationsRes] = await Promise.all([
fetchSystemLogs(dateFilter, customDays),
fetchApi("/api/backups"),
fetchApi("/api/events?limit=50"),
fetchApi("/api/notifications"),
])
if (cancelled) return
setLogs(logsRes)
setBackups(backupsRes.backups || [])
setEvents(eventsRes.events || [])
setNotifications(notificationsRes.notifications || [])
} catch (err) {
if (cancelled) return
setError("Failed to connect to server")
} finally {
if (!cancelled) setLoading(false)
}
}
loadData()
return () => { cancelled = true }
}, [dateFilter, customDays, refreshCounter])
// Reset pagination when filters change
useEffect(() => {
setDisplayedLogsCount(100)
}, [searchTerm, levelFilter, serviceFilter, dateFilter, customDays])
const refreshData = () => {
setRefreshCounter((prev) => prev + 1)
}
const fetchSystemLogs = async (): Promise<SystemLog[]> => {
const fetchSystemLogs = async (filterDays: string, filterCustom: string): Promise<SystemLog[]> => {
try {
let apiUrl = "/api/logs"
const params = new URLSearchParams()
const daysAgo = filterDays === "custom" ? Number.parseInt(filterCustom) : Number.parseInt(filterDays)
const clampedDays = Math.max(1, Math.min(daysAgo || 1, 90))
const apiUrl = `/api/logs?since_days=${clampedDays}`
// CHANGE: Always add since_days parameter (no more "now" option)
const daysAgo = dateFilter === "custom" ? Number.parseInt(customDays) : Number.parseInt(dateFilter)
params.append("since_days", daysAgo.toString())
console.log("[v0] Fetching logs since_days:", daysAgo)
if (levelFilter !== "all") {
const priorityMap: Record<string, string> = {
error: "3", // 0-3: emerg, alert, crit, err
warning: "4", // 4: warning
info: "6", // 5-7: notice, info, debug
}
const priority = priorityMap[levelFilter]
if (priority) {
params.append("priority", priority)
console.log("[v0] Fetching logs with priority:", priority, "for level:", levelFilter)
}
}
if (serviceFilter !== "all") {
params.append("service", serviceFilter)
console.log("[v0] Fetching logs for service:", serviceFilter)
}
params.append("limit", "5000")
if (params.toString()) {
apiUrl += `?${params.toString()}`
}
console.log("[v0] Making fetch request to:", apiUrl)
const data = await fetchApi(apiUrl)
console.log("[v0] Received logs data, count:", data.logs?.length || 0)
const logsArray = Array.isArray(data) ? data : data.logs || []
console.log("[v0] Returning logs array with length:", logsArray.length)
return logsArray
} catch (error) {
console.error("[v0] Failed to fetch system logs:", error)
if (error instanceof Error && error.name === "TimeoutError") {
setError("Request timed out. Try selecting a more specific filter.")
} else {
setError("Failed to load logs. Please try again.")
}
} catch {
setError("Failed to load logs. Please try again.")
return []
}
}
@@ -271,7 +175,6 @@ export function SystemLogs() {
try {
// Generate filename based on active filters
const filters = []
// CHANGE: Always include days in filename (no more "now" option)
const days = dateFilter === "custom" ? customDays : dateFilter
filters.push(`${days}days`)
@@ -294,7 +197,7 @@ export function SystemLogs() {
`Total Entries: ${filteredCombinedLogs.length.toLocaleString()}`,
``,
`Filters Applied:`,
`- Date Range: ${dateFilter === "now" ? "Current logs" : dateFilter === "custom" ? `${customDays} days ago` : `${dateFilter} days ago`}`,
`- Date Range: ${dateFilter === "custom" ? `${customDays} days ago` : `${dateFilter} day(s) ago`}`,
`- Level: ${levelFilter === "all" ? "All Levels" : levelFilter}`,
`- Service: ${serviceFilter === "all" ? "All Services" : serviceFilter}`,
`- Search: ${searchTerm || "None"}`,
@@ -368,8 +271,7 @@ export function SystemLogs() {
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
return
} catch (error) {
console.error("[v0] Failed to fetch task log from Proxmox:", error)
} catch {
// Fall through to download notification message
}
}
@@ -397,8 +299,8 @@ export function SystemLogs() {
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (err) {
console.error("[v0] Error downloading notification:", err)
} catch {
// Download failed silently
}
}
@@ -407,70 +309,11 @@ export function SystemLogs() {
return String(value).toLowerCase()
}
const memoizedLogs = useMemo(() => logs, [logs])
const memoizedEvents = useMemo(() => events, [events])
const memoizedBackups = useMemo(() => backups, [backups])
const memoizedNotifications = useMemo(() => notifications, [notifications])
const logsOnly: CombinedLogEntry[] = useMemo(
() =>
memoizedLogs
.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() }))
.sort((a, b) => b.sortTimestamp - a.sortTimestamp),
[memoizedLogs],
)
const eventsOnly: CombinedLogEntry[] = useMemo(
() =>
memoizedEvents
.map((event) => ({
timestamp: event.starttime,
level: event.level,
service: event.type,
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`,
source: `Node: ${event.node} • User: ${event.user}`,
isEvent: true,
eventData: event,
sortTimestamp: new Date(event.starttime).getTime(),
}))
.sort((a, b) => b.sortTimestamp - a.sortTimestamp),
[memoizedEvents],
)
const filteredLogsOnly = logsOnly.filter((log) => {
const message = log.message || ""
const service = log.service || ""
const searchTermLower = safeToLowerCase(searchTerm)
const matchesSearch =
safeToLowerCase(message).includes(searchTermLower) || safeToLowerCase(service).includes(searchTermLower)
const matchesLevel = levelFilter === "all" || log.level === levelFilter
const matchesService = serviceFilter === "all" || log.service === serviceFilter
return matchesSearch && matchesLevel && matchesService
})
const filteredEventsOnly = eventsOnly.filter((event) => {
const message = event.message || ""
const service = event.service || ""
const searchTermLower = safeToLowerCase(searchTerm)
const matchesSearch =
safeToLowerCase(message).includes(searchTermLower) || safeToLowerCase(service).includes(searchTermLower)
const matchesLevel = levelFilter === "all" || event.level === levelFilter
const matchesService = serviceFilter === "all" || event.service === serviceFilter
return matchesSearch && matchesLevel && matchesService
})
const displayedLogsOnly = filteredLogsOnly.slice(0, displayedLogsCount)
const displayedEventsOnly = filteredEventsOnly.slice(0, displayedLogsCount)
const combinedLogs: CombinedLogEntry[] = useMemo(
() =>
[
...memoizedLogs.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() })),
...memoizedEvents.map((event) => ({
...logs.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() })),
...events.map((event) => ({
timestamp: event.starttime,
level: event.level,
service: event.type,
@@ -481,18 +324,20 @@ export function SystemLogs() {
sortTimestamp: new Date(event.starttime).getTime(),
})),
].sort((a, b) => b.sortTimestamp - a.sortTimestamp),
[memoizedLogs, memoizedEvents],
[logs, events],
)
const filteredCombinedLogs = useMemo(
() =>
combinedLogs.filter((log) => {
const message = log.message || ""
const service = log.service || ""
const searchTermLower = safeToLowerCase(searchTerm)
const matchesSearch =
safeToLowerCase(message).includes(searchTermLower) || safeToLowerCase(service).includes(searchTermLower)
const matchesSearch = !searchTermLower ||
safeToLowerCase(log.message).includes(searchTermLower) ||
safeToLowerCase(log.service).includes(searchTermLower) ||
safeToLowerCase(log.pid).includes(searchTermLower) ||
safeToLowerCase(log.hostname).includes(searchTermLower) ||
safeToLowerCase(log.unit).includes(searchTermLower)
const matchesLevel = levelFilter === "all" || log.level === levelFilter
const matchesService = serviceFilter === "all" || log.service === serviceFilter
@@ -501,7 +346,6 @@ export function SystemLogs() {
[combinedLogs, searchTerm, levelFilter, serviceFilter],
)
// CHANGE: Re-assigning displayedLogs to use the filteredCombinedLogs
const displayedLogs = filteredCombinedLogs.slice(0, displayedLogsCount)
const hasMoreLogs = displayedLogsCount < filteredCombinedLogs.length
@@ -577,7 +421,6 @@ export function SystemLogs() {
}
}
// ADDED: New function for notification source colors
const getNotificationSourceColor = (source: string) => {
if (!source) return "bg-gray-500/10 text-gray-500 border-gray-500/20"
@@ -600,7 +443,10 @@ export function SystemLogs() {
info: logs.filter((log) => ["info", "notice", "debug"].includes(log.level)).length,
}
const uniqueServices = useMemo(() => [...new Set(memoizedLogs.map((log) => log.service))], [memoizedLogs])
const uniqueServices = useMemo(
() => [...new Set(logs.map((log) => log.service).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
[logs],
)
const getBackupType = (volid: string): "vm" | "lxc" => {
if (volid.includes("/vm/") || volid.includes("vzdump-qemu")) {
@@ -695,20 +541,27 @@ export function SystemLogs() {
if (loading && logs.length === 0 && events.length === 0) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
<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 logs...</div>
<p className="text-xs text-muted-foreground">Fetching system logs and events</p>
</div>
)
}
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/80 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="flex flex-col items-center gap-4 p-8 rounded-lg bg-card border border-border shadow-lg">
<RefreshCw className="h-12 w-12 animate-spin text-primary" />
<div className="text-lg font-medium text-foreground">Loading logs selected...</div>
<div className="text-sm text-muted-foreground">Please wait while we fetch the logs</div>
<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">
<div className="relative">
<div className="h-10 w-10 rounded-full border-2 border-muted"></div>
<div className="absolute inset-0 h-10 w-10 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
</div>
<div className="text-sm font-medium text-foreground">Loading logs...</div>
</div>
</div>
)}
@@ -763,21 +616,21 @@ 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">
<Activity className="h-5 w-5 mr-2" />
System Logs & Events
</CardTitle>
<Button variant="outline" size="sm" onClick={fetchAllData} disabled={loading}>
<Button variant="outline" size="sm" onClick={refreshData} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
</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" />
@@ -875,7 +728,6 @@ export function SystemLogs() {
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search logs & events..."
// CHANGE: Renamed searchTerm to searchQuery
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 bg-background border-border"
@@ -928,8 +780,8 @@ export function SystemLogs() {
<SelectItem key="service-all" value="all">
All Services
</SelectItem>
{uniqueServices.slice(0, 20).map((service, idx) => (
<SelectItem key={`service-${service}-${idx}`} value={service}>
{uniqueServices.map((service) => (
<SelectItem key={`service-${service}`} value={service}>
{service}
</SelectItem>
))}
@@ -942,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()
@@ -954,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)
@@ -978,18 +830,19 @@ 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}`}
{log.hostname && ` • Host: ${log.hostname}`}
</div>
@@ -1006,10 +859,10 @@ 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 + 500)}
onClick={() => setDisplayedLogsCount((prev) => prev + 200)}
className="border-border"
>
<RefreshCw className="h-4 w-4 mr-2" />
@@ -1057,7 +910,7 @@ export function SystemLogs() {
<ScrollArea className="h-[500px] w-full rounded-md border border-border">
<div className="space-y-2 p-4">
{memoizedBackups.map((backup, index) => {
{backups.map((backup, index) => {
const uniqueKey = `backup-${backup.volid.replace(/[/:]/g, "-")}-${backup.timestamp || index}`
return (
@@ -1114,7 +967,7 @@ export function SystemLogs() {
<TabsContent value="notifications" className="space-y-4">
<ScrollArea className="h-[600px] w-full rounded-md border border-border">
<div className="space-y-2 p-4">
{memoizedNotifications.map((notification, index) => {
{notifications.map((notification, index) => {
const timestampMs = new Date(notification.timestamp).getTime()
const uniqueKey = `notification-${timestampMs}-${notification.service?.substring(0, 10) || "unknown"}-${notification.source?.substring(0, 10) || "unknown"}-${index}`
@@ -1202,6 +1055,12 @@ export function SystemLogs() {
<div className="text-sm font-medium text-muted-foreground mb-1">Source</div>
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.source}</div>
</div>
{selectedLog.unit && (
<div>
<div className="text-sm font-medium text-muted-foreground mb-1">Systemd Unit</div>
<div className="text-sm text-foreground font-mono break-all overflow-hidden">{selectedLog.unit}</div>
</div>
)}
{selectedLog.pid && (
<div>
<div className="text-sm font-medium text-muted-foreground mb-1">Process ID</div>
+146 -77
View File
@@ -7,8 +7,17 @@ import { Badge } from "./ui/badge"
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network } from "lucide-react"
import { NodeMetricsCharts } from "./node-metrics-charts"
import { NetworkTrafficChart } from "./network-traffic-chart"
import { TemperatureDetailModal } from "./temperature-detail-modal"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { fetchApi } from "../lib/api-config"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { formatStorage } from "../lib/utils"
import { Area, AreaChart, ResponsiveContainer } from "recharts"
interface TempDataPoint {
timestamp: number
value: number
}
interface SystemData {
cpu_usage: number
@@ -16,6 +25,7 @@ interface SystemData {
memory_total: number
memory_used: number
temperature: number
temperature_sparkline?: TempDataPoint[]
uptime: string
load_average: number[]
hostname: string
@@ -96,22 +106,29 @@ interface ProxmoxStorageData {
}>
}
const fetchSystemData = async (): Promise<SystemData | null> => {
try {
const data = await fetchApi<SystemData>("/api/system")
return data
} catch (error) {
console.error("[v0] Failed to fetch system data:", error)
return null
const fetchSystemData = async (retries = 3, delayMs = 500): Promise<SystemData | null> => {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const data = await fetchApi<SystemData>("/api/system")
return data
} catch {
if (attempt === retries - 1) {
// Silent fail - API not available (expected in preview environment)
return null
}
// Wait before retry
await new Promise((resolve) => setTimeout(resolve, delayMs))
}
}
return null
}
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 []
}
}
@@ -120,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
}
}
@@ -130,22 +146,26 @@ 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
}
}
const getUnitsSettings = (): "Bytes" | "Bits" => {
if (typeof window === "undefined") return "Bytes"
const raw = window.localStorage.getItem("proxmenux-network-unit")
return raw && raw.toLowerCase() === "bits" ? "Bits" : "Bytes"
}
export function SystemOverview() {
const [systemData, setSystemData] = useState<SystemData | null>(null)
const [vmData, setVmData] = useState<VMData[]>([])
@@ -159,8 +179,11 @@ export function SystemOverview() {
network: true,
})
const [error, setError] = useState<string | null>(null)
const [hasAttemptedLoad, setHasAttemptedLoad] = useState(false) // Added hasAttemptedLoad state
const [networkTimeframe, setNetworkTimeframe] = useState("day")
const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">("Bytes") // Added networkUnit state
const [tempModalOpen, setTempModalOpen] = useState(false)
useEffect(() => {
const fetchAllData = async () => {
@@ -173,6 +196,8 @@ export function SystemOverview() {
fetchNetworkData().finally(() => setLoadingStates((prev) => ({ ...prev, network: false }))),
])
setHasAttemptedLoad(true)
if (!systemResult) {
setError("Flask server not available. Please ensure the server is running.")
return
@@ -197,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()
@@ -215,35 +240,32 @@ export function SystemOverview() {
if (data) setNetworkData(data)
}, 59000)
setNetworkUnit(getNetworkUnit()) // Load initial setting
const handleUnitChange = (e: CustomEvent) => {
setNetworkUnit(e.detail === "Bits" ? "Bits" : "Bytes")
}
window.addEventListener("networkUnitChanged" as any, handleUnitChange)
return () => {
clearInterval(systemInterval)
clearInterval(vmInterval)
clearInterval(storageInterval)
clearInterval(networkInterval)
window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
}
}, [])
const isInitialLoading = loadingStates.system && !systemData
if (isInitialLoading) {
if (!hasAttemptedLoad || loadingStates.system) {
return (
<div className="space-y-6">
<div className="text-center py-8">
<div className="text-lg font-medium text-foreground mb-2">Connecting to ProxMenux Monitor...</div>
<div className="text-sm text-muted-foreground">Fetching real-time system data</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg: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>
)
}
@@ -298,16 +320,6 @@ export function SystemOverview() {
return (bytes / 1024 ** 3).toFixed(2)
}
const formatStorage = (sizeInGB: number): string => {
if (sizeInGB < 1) {
return `${(sizeInGB * 1024).toFixed(1)} MB`
} else if (sizeInGB > 999) {
return `${(sizeInGB / 1024).toFixed(2)} TB`
} else {
return `${sizeInGB.toFixed(2)} GB`
}
}
const tempStatus = getTemperatureStatus(systemData.temperature)
const localStorage = proxmoxStorageData?.storage.find((s) => s.name === "local")
@@ -411,26 +423,6 @@ export function SystemOverview() {
</CardContent>
</Card>
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Temperature</CardTitle>
<Thermometer className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-xl lg:text-2xl font-bold text-foreground">
{systemData.temperature === 0 ? "N/A" : `${systemData.temperature}°C`}
</div>
<div className="flex items-center mt-2">
<Badge variant="outline" className={tempStatus.color}>
{tempStatus.status}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-2">
{systemData.temperature === 0 ? "No sensor available" : "Live temperature reading"}
</p>
</CardContent>
</Card>
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-foreground flex items-center">
@@ -465,8 +457,61 @@ export function SystemOverview() {
)}
</CardContent>
</Card>
<Card
className={`bg-card border-border ${systemData.temperature > 0 ? "cursor-pointer hover:bg-white/5 transition-colors" : ""}`}
onClick={() => systemData.temperature > 0 && setTempModalOpen(true)}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Temperature</CardTitle>
<Thermometer className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<span className="text-xl lg:text-2xl font-bold text-foreground">
{systemData.temperature === 0 ? "N/A" : `${Math.round(systemData.temperature * 10) / 10}°C`}
</span>
<Badge variant="outline" className={tempStatus.color}>
{tempStatus.status}
</Badge>
</div>
{systemData.temperature > 0 && systemData.temperature_sparkline && systemData.temperature_sparkline.length > 1 ? (
<div className="mt-2 h-10">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={systemData.temperature_sparkline} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="tempSparkGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={systemData.temperature >= 75 ? "#ef4444" : systemData.temperature >= 60 ? "#f59e0b" : "#22c55e"} stopOpacity={0.3} />
<stop offset="100%" stopColor={systemData.temperature >= 75 ? "#ef4444" : systemData.temperature >= 60 ? "#f59e0b" : "#22c55e"} stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="value"
stroke={systemData.temperature >= 75 ? "#ef4444" : systemData.temperature >= 60 ? "#f59e0b" : "#22c55e"}
strokeWidth={1.5}
fill="url(#tempSparkGradient)"
dot={false}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
) : (
<p className="text-xs text-muted-foreground mt-2">
{systemData.temperature === 0 ? "No sensor available" : "Collecting data..."}
</p>
)}
</CardContent>
</Card>
</div>
<TemperatureDetailModal
open={tempModalOpen}
onOpenChange={setTempModalOpen}
liveTemperature={systemData.temperature}
/>
<NodeMetricsCharts />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
@@ -496,7 +541,9 @@ export function SystemOverview() {
<div className="space-y-2 pb-4 border-b-2 border-border">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground">Total Node Capacity:</span>
<span className="text-lg font-bold text-foreground">{formatStorage(totalCapacity)}</span>
<span className="text-lg font-bold text-foreground">
{formatStorage(totalCapacity)}
</span>
</div>
<Progress
value={totalPercent}
@@ -505,10 +552,16 @@ export function SystemOverview() {
<div className="flex justify-between items-center mt-1">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">
Used: <span className="font-semibold text-foreground">{formatStorage(totalUsed)}</span>
Used:{" "}
<span className="font-semibold text-foreground">
{formatStorage(totalUsed)}
</span>
</span>
<span className="text-xs text-muted-foreground">
Free: <span className="font-semibold text-green-500">{formatStorage(totalAvailable)}</span>
Free:{" "}
<span className="font-semibold text-green-500">
{formatStorage(totalAvailable)}
</span>
</span>
</div>
<span className="text-xs font-semibold text-muted-foreground">{totalPercent.toFixed(1)}%</span>
@@ -535,7 +588,9 @@ export function SystemOverview() {
<div className="text-xs font-medium text-muted-foreground mb-2">VM/LXC Storage</div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Used:</span>
<span className="text-sm font-semibold text-foreground">{formatStorage(vmLxcStorageUsed)}</span>
<span className="text-sm font-semibold text-foreground">
{formatStorage(vmLxcStorageUsed)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Available:</span>
@@ -546,7 +601,8 @@ export function SystemOverview() {
<Progress value={vmLxcStoragePercent} className="mt-2 [&>div]:bg-blue-500" />
<div className="flex justify-between items-center mt-1">
<span className="text-xs text-muted-foreground">
{formatStorage(vmLxcStorageUsed)} / {formatStorage(vmLxcStorageTotal)}
{formatStorage(vmLxcStorageUsed)} /{" "}
{formatStorage(vmLxcStorageTotal)}
</span>
<span className="text-xs text-muted-foreground">{vmLxcStoragePercent.toFixed(1)}%</span>
</div>
@@ -568,7 +624,9 @@ export function SystemOverview() {
<div className="text-xs font-medium text-muted-foreground mb-2">Local Storage (System)</div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Used:</span>
<span className="text-sm font-semibold text-foreground">{formatStorage(localStorage.used)}</span>
<span className="text-sm font-semibold text-foreground">
{formatStorage(localStorage.used)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Available:</span>
@@ -579,7 +637,8 @@ export function SystemOverview() {
<Progress value={localStorage.percent} className="mt-2 [&>div]:bg-purple-500" />
<div className="flex justify-between items-center mt-1">
<span className="text-xs text-muted-foreground">
{formatStorage(localStorage.used)} / {formatStorage(localStorage.total)}
{formatStorage(localStorage.used)} /{" "}
{formatStorage(localStorage.total)}
</span>
<span className="text-xs text-muted-foreground">{localStorage.percent.toFixed(1)}%</span>
</div>
@@ -667,21 +726,31 @@ export function SystemOverview() {
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Received:</span>
<span className="text-lg font-semibold text-green-500 flex items-center gap-1">
{formatStorage(networkTotals.received)}
{" "}
{networkUnit === "Bytes"
? `${networkTotals.received.toFixed(2)} GB`
: formatNetworkTraffic(networkTotals.received * 1024 * 1024 * 1024, "Bits")}
<span className="text-xs text-muted-foreground">({getTimeframeLabel(networkTimeframe)})</span>
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Sent:</span>
<span className="text-lg font-semibold text-blue-500 flex items-center gap-1">
{formatStorage(networkTotals.sent)}
{" "}
{networkUnit === "Bytes"
? `${networkTotals.sent.toFixed(2)} GB`
: formatNetworkTraffic(networkTotals.sent * 1024 * 1024 * 1024, "Bits")}
<span className="text-xs text-muted-foreground">({getTimeframeLabel(networkTimeframe)})</span>
</span>
</div>
</div>
<div className="pt-3 border-t border-border">
<NetworkTrafficChart timeframe={networkTimeframe} onTotalsCalculated={setNetworkTotals} />
<NetworkTrafficChart
timeframe={networkTimeframe}
onTotalsCalculated={setNetworkTotals}
networkUnit={networkUnit}
/>
</div>
</div>
) : (
@@ -0,0 +1,242 @@
"use client"
import { useState, useEffect } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Badge } from "./ui/badge"
import { Thermometer, TrendingDown, TrendingUp, Minus } from "lucide-react"
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
import { useIsMobile } from "../hooks/use-mobile"
import { fetchApi } from "@/lib/api-config"
const TIMEFRAME_OPTIONS = [
{ value: "hour", label: "1 Hour" },
{ value: "day", label: "24 Hours" },
{ value: "week", label: "7 Days" },
{ value: "month", label: "30 Days" },
]
interface TempHistoryPoint {
timestamp: number
value: number
min?: number
max?: number
}
interface TempStats {
min: number
max: number
avg: number
current: number
}
interface TemperatureDetailModalProps {
open: boolean
onOpenChange: (open: boolean) => void
liveTemperature?: number
}
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
<p className="text-sm font-semibold text-white mb-2">{label}</p>
<div className="space-y-1.5">
{payload.map((entry: any, index: number) => (
<div key={index} className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
<span className="text-sm font-semibold text-white">{entry.value}°C</span>
</div>
))}
</div>
</div>
)
}
return null
}
const getStatusColor = (temp: number) => {
if (temp >= 75) return "#ef4444"
if (temp >= 60) return "#f59e0b"
return "#22c55e"
}
const getStatusInfo = (temp: number) => {
if (temp === 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
if (temp < 60) return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (temp < 75) return { status: "Warm", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-500/20" }
}
export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }: TemperatureDetailModalProps) {
const [timeframe, setTimeframe] = useState("hour")
const [data, setData] = useState<TempHistoryPoint[]>([])
const [stats, setStats] = useState<TempStats>({ min: 0, max: 0, avg: 0, current: 0 })
const [loading, setLoading] = useState(true)
const isMobile = useIsMobile()
useEffect(() => {
if (open) {
fetchHistory()
}
}, [open, timeframe])
const fetchHistory = async () => {
setLoading(true)
try {
const result = await fetchApi<{ data: TempHistoryPoint[]; stats: TempStats }>(
`/api/temperature/history?timeframe=${timeframe}`
)
if (result && result.data) {
setData(result.data)
setStats(result.stats)
}
} catch (err) {
console.error("[v0] Failed to fetch temperature history:", err)
} finally {
setLoading(false)
}
}
const formatTime = (timestamp: number) => {
const date = new Date(timestamp * 1000)
if (timeframe === "hour") {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
} else if (timeframe === "day") {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
} else {
return date.toLocaleDateString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })
}
}
const chartData = data.map((d) => ({
...d,
time: formatTime(d.timestamp),
}))
// Use live temperature from the overview card (real-time) instead of last DB record
const currentTemp = liveTemperature && liveTemperature > 0 ? Math.round(liveTemperature * 10) / 10 : stats.current
const currentStatus = getStatusInfo(currentTemp)
const chartColor = getStatusColor(currentTemp)
// Calculate Y axis domain based on plotted data values only.
// Stats cards already show the real historical min/max separately.
// Using only graphed values keeps the chart readable and avoids
// large empty gaps caused by momentary spikes that get averaged out.
const values = data.map((d) => d.value)
const yMin = values.length > 0 ? Math.max(0, Math.floor(Math.min(...values) - 3)) : 0
const yMax = values.length > 0 ? Math.ceil(Math.max(...values) + 3) : 100
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl bg-card border-border px-3 sm:px-6">
<DialogHeader>
<div className="flex items-center justify-between pr-6">
<DialogTitle className="text-foreground flex items-center gap-2">
<Thermometer className="h-5 w-5" />
CPU Temperature
</DialogTitle>
<Select value={timeframe} onValueChange={setTimeframe}>
<SelectTrigger className="w-[130px] bg-card border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIMEFRAME_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</DialogHeader>
{/* Stats bar */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
<div className={`rounded-lg p-3 text-center ${currentStatus.color}`}>
<div className="text-xs opacity-80 mb-1">Current</div>
<div className="text-lg font-bold">{currentTemp}°C</div>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<TrendingDown className="h-3 w-3" /> Min
</div>
<div className="text-lg font-bold text-green-500">{stats.min}°C</div>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<Minus className="h-3 w-3" /> Avg
</div>
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<TrendingUp className="h-3 w-3" /> Max
</div>
<div className="text-lg font-bold text-red-500">{stats.max}°C</div>
</div>
</div>
{/* Chart */}
<div className="h-[300px] lg:h-[350px]">
{loading ? (
<div className="h-full flex items-center justify-center">
<div className="space-y-3 w-full animate-pulse">
<div className="h-4 bg-muted rounded w-1/4 mx-auto" />
<div className="h-[250px] bg-muted/50 rounded" />
</div>
</div>
) : chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No temperature data available for this period</p>
<p className="text-sm mt-1">Data is collected every 60 seconds</p>
</div>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="tempGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={chartColor} stopOpacity={0.3} />
<stop offset="100%" stopColor={chartColor} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
<XAxis
dataKey="time"
stroke="currentColor"
className="text-foreground"
tick={{ fill: "currentColor", fontSize: isMobile ? 10 : 12 }}
interval="preserveStartEnd"
minTickGap={isMobile ? 40 : 60}
/>
<YAxis
domain={[yMin, yMax]}
stroke="currentColor"
className="text-foreground"
tick={{ fill: "currentColor", fontSize: isMobile ? 10 : 12 }}
tickFormatter={(v) => `${v}°`}
width={isMobile ? 40 : 45}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="value"
name="Temperature"
stroke={chartColor}
strokeWidth={2}
fill="url(#tempGradient)"
dot={false}
activeDot={{ r: 4, fill: chartColor, stroke: "#fff", strokeWidth: 2 }}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</DialogContent>
</Dialog>
)
}
File diff suppressed because it is too large Load Diff
+38 -2
View File
@@ -89,8 +89,44 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
}
}
const copyToClipboard = (text: string, type: "secret" | "codes") => {
navigator.clipboard.writeText(text)
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 && window.isSecureContext) {
await navigator.clipboard.writeText(text)
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()
ok = document.execCommand("copy")
document.body.removeChild(textarea)
} catch {
ok = false
}
}
if (!ok) {
console.error("Failed to copy to clipboard")
return
}
if (type === "secret") {
setCopiedSecret(true)
setTimeout(() => setCopiedSecret(false), 2000)
+10 -6
View File
@@ -31,8 +31,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
hideClose?: boolean
}
>(({ className, children, hideClose, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
@@ -45,10 +47,12 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{!hideClose && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
))
+257
View File
@@ -0,0 +1,257 @@
'use client'
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className,
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8',
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+29
View File
@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"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>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
{
"_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",
"groq": {
"models": [
"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"
],
"recommended": "llama-3.3-70b-versatile"
},
"gemini": {
"models": [
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-2.5-pro"
],
"recommended": "gemini-2.5-flash",
"_note": "gemini-2.5-flash-lite is cheaper but may struggle with complex prompts. Use with simple/custom prompts.",
"_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-mini",
"gpt-4o-mini"
],
"recommended": "gpt-4o-mini"
},
"anthropic": {
"models": [
"claude-3-5-haiku-latest",
"claude-3-5-sonnet-latest",
"claude-3-opus-latest"
],
"recommended": "claude-3-5-haiku-latest"
},
"openrouter": {
"models": [
"meta-llama/llama-3.3-70b-instruct",
"meta-llama/llama-3.1-70b-instruct",
"meta-llama/llama-3.1-8b-instruct",
"anthropic/claude-3.5-haiku",
"anthropic/claude-3.5-sonnet",
"google/gemini-flash-2.5-flash-lite",
"openai/gpt-4o-mini",
"mistralai/mistral-7b-instruct",
"mistralai/mixtral-8x7b-instruct"
],
"recommended": "meta-llama/llama-3.3-70b-instruct"
},
"ollama": {
"_note": "Ollama models are local, we don't filter them. User manages their own models.",
"models": [],
"recommended": ""
}
}
+21 -33
View File
@@ -19,29 +19,19 @@ export const API_PORT = process.env.NEXT_PUBLIC_API_PORT || "8008"
*/
export function getApiBaseUrl(): string {
if (typeof window === "undefined") {
console.log("[v0] getApiBaseUrl: Running on server (SSR)")
return ""
}
const { protocol, hostname, port } = window.location
console.log("[v0] getApiBaseUrl - protocol:", protocol, "hostname:", hostname, "port:", port)
// If accessing via standard ports (80/443) or no port, assume we're behind a proxy
// In this case, use relative URLs so the proxy handles routing
const isStandardPort = port === "" || port === "80" || port === "443"
console.log("[v0] getApiBaseUrl - isStandardPort:", isStandardPort)
if (isStandardPort) {
// Behind a proxy - use relative URL
console.log("[v0] getApiBaseUrl: Detected proxy access, using relative URLs")
return ""
} else {
// Direct access - use explicit API port
const baseUrl = `${protocol}//${hostname}:${API_PORT}`
console.log("[v0] getApiBaseUrl: Direct access detected, using:", baseUrl)
return baseUrl
return `${protocol}//${hostname}:${API_PORT}`
}
}
@@ -69,12 +59,7 @@ export function getAuthToken(): string | null {
if (typeof window === "undefined") {
return null
}
const token = localStorage.getItem("proxmenux-auth-token")
console.log(
"[v0] getAuthToken called:",
token ? `Token found (length: ${token.length})` : "No token found in localStorage",
)
return token
return localStorage.getItem("proxmenux-auth-token")
}
/**
@@ -96,19 +81,13 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
if (token) {
headers["Authorization"] = `Bearer ${token}`
console.log("[v0] fetchApi:", endpoint, "- Authorization header ADDED")
} else {
console.log("[v0] fetchApi:", endpoint, "- NO TOKEN - Request will fail if endpoint is protected")
}
try {
const response = await fetch(url, {
...options,
headers,
cache: "no-store",
})
console.log("[v0] fetchApi:", endpoint, "- Response status:", response.status)
const response = await fetch(url, {
...options,
headers,
cache: "no-store",
})
if (!response.ok) {
if (response.status === 401) {
@@ -118,9 +97,18 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
return response.json()
} catch (error) {
console.error("[v0] fetchApi error for", endpoint, ":", error)
throw error
}
// Check content type to ensure we're getting JSON
const contentType = response.headers.get("content-type")
if (!contentType || !contentType.includes("application/json")) {
const text = await response.text()
console.error("[v0] fetchApi: Expected JSON but got:", contentType, "- Body preview:", text.substring(0, 200))
throw new Error(`Expected JSON response but got ${contentType || "unknown content type"}`)
}
try {
return await response.json()
} catch (jsonError) {
console.error("[v0] fetchApi: JSON parse error for", endpoint, "-", jsonError)
throw new Error(`Invalid JSON response from ${endpoint}`)
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Utility functions for formatting network traffic data
* Supports conversion between Bytes and Bits based on user preferences
*/
export type NetworkUnit = 'Bytes' | 'Bits';
/**
* Format network traffic value with appropriate unit
* @param bytes - Value in bytes
* @param unit - Target unit ('Bytes' or 'Bits')
* @param decimals - Number of decimal places (default: 2)
* @returns Formatted string with value and unit
*/
export function formatNetworkTraffic(
bytes: number,
unit: NetworkUnit = 'Bytes',
decimals: number = 2
): string {
if (bytes === 0) return unit === 'Bits' ? '0 b' : '0 B';
const k = unit === 'Bits' ? 1000 : 1024;
const dm = decimals < 0 ? 0 : Math.min(decimals, 2);
// For Bits: convert bytes to bits first (multiply by 8)
const value = unit === 'Bits' ? bytes * 8 : bytes;
const sizes = unit === 'Bits'
? ['b', 'Kb', 'Mb', 'Gb', 'Tb', 'Pb']
: ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(value) / Math.log(k));
const finalDecimals = 2; // Always use 2 decimals for consistency
const formattedValue = parseFloat((value / Math.pow(k, i)).toFixed(finalDecimals));
return `${formattedValue} ${sizes[i]}`;
}
/**
* Get the current network unit preference from localStorage
* @returns 'Bytes' or 'Bits'
*/
export function getNetworkUnit(): NetworkUnit {
if (typeof window === 'undefined') return 'Bytes';
const stored = localStorage.getItem('proxmenux-network-unit');
return stored === 'Bits' ? 'Bits' : 'Bytes';
}
/**
* Get the label for network traffic based on current unit
* @param direction - 'received' or 'sent'
* @returns Label string
*/
export function getNetworkLabel(direction: 'received' | 'sent'): string {
const unit = getNetworkUnit();
const prefix = direction === 'received' ? 'Received' : 'Sent';
return unit === 'Bits' ? `${prefix}` : `${prefix}`;
}
/**
* Get the unit suffix for displaying in charts
* @returns Unit suffix string (e.g., 'GB' or 'Gb')
*/
export function getNetworkUnitSuffix(): string {
const unit = getNetworkUnit();
return unit === 'Bits' ? 'b' : 'B';
}
+39
View File
@@ -0,0 +1,39 @@
import { exec } from "child_process"
import { promisify } from "util"
const execAsync = promisify(exec)
interface ScriptExecutorOptions {
env?: Record<string, string>
timeout?: number
}
interface ScriptResult {
stdout: string
stderr: string
exitCode: number
}
export async function executeScript(scriptPath: string, options: ScriptExecutorOptions = {}): Promise<ScriptResult> {
const { env = {}, timeout = 300000 } = options // 5 minutes default timeout
try {
const { stdout, stderr } = await execAsync(`bash ${scriptPath}`, {
env: { ...process.env, ...env },
timeout,
maxBuffer: 1024 * 1024 * 10, // 10MB buffer
})
return {
stdout,
stderr,
exitCode: 0,
}
} catch (error: any) {
return {
stdout: error.stdout || "",
stderr: error.stderr || error.message || "Unknown error",
exitCode: error.code || 1,
}
}
}
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ProxMenux-Monitor",
"version": "1.0.1",
"version": "1.2.0",
"description": "Proxmox System Monitoring Dashboard",
"private": true,
"scripts": {
@@ -55,11 +55,14 @@
"react-hook-form": "^7.60.0",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.4",
"socket.io-client": "^4.8.1",
"sonner": "^1.7.4",
"swr": "^2.2.5",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"zod": "3.25.67"
},
"devDependencies": {
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200.18 69.76">
<g id="Layer_1-2" data-name="Layer 1">
<path d="M114.26.13c-13.19,0-23.88,10.68-23.88,23.88s10.68,23.9,23.88,23.9,23.88-10.68,23.88-23.88h0c-.02-13.19-10.71-23.88-23.88-23.9ZM114.26,38.94c-8.24,0-14.93-6.69-14.93-14.93s6.69-14.93,14.93-14.93,14.93,6.69,14.93,14.93c-.02,8.24-6.71,14.93-14.93,14.93h0Z"/>
<path d="M24.11,0C10.92-.11.13,10.47,0,23.66c-.13,13.19,10.47,23.98,23.66,24.11h8.31v-8.94h-7.86c-8.24.11-15-6.5-15.1-14.74-.11-8.24,6.5-15,14.74-15.1h.34c8.22,0,14.95,6.69,14.95,14.93h0v21.98h0c0,8.18-6.65,14.83-14.81,14.93-3.91-.04-7.63-1.59-10.39-4.38l-6.33,6.31c4.4,4.42,10.34,6.92,16.57,6.99h.32c13.02-.19,23.49-10.75,23.56-23.77v-22.69C47.65,10.35,37.05.02,24.11,0Z"/>
<path d="M191.28,68.74V23.43c-.32-12.96-10.92-23.28-23.88-23.3-13.19-.13-23.98,10.47-24.11,23.66-.13,13.19,10.49,23.98,23.68,24.11h8.31v-8.94h-7.86c-8.24.11-15-6.5-15.1-14.74s6.5-15,14.74-15.1h.34c8.22,0,14.95,6.69,14.95,14.93h0v44.63h0l8.92.06Z"/>
<path d="M54.8,47.9h8.92v-23.88c0-8.24,6.69-14.93,14.93-14.93,2.72,0,5.25.72,7.46,2l4.48-7.75c-3.5-2.02-7.58-3.19-11.92-3.19-13.19,0-23.88,10.68-23.88,23.88v23.88Z"/>
<path d="M198.01.74c.68.38,1.21.91,1.59,1.59.38.68.57,1.42.57,2.25s-.19,1.57-.59,2.27c-.4.68-.93,1.23-1.61,1.61-.68.4-1.44.59-2.25.59s-1.57-.19-2.25-.59c-.68-.4-1.21-.93-1.59-1.61-.38-.68-.59-1.42-.59-2.25s.19-1.57.59-2.25c.38-.68.93-1.21,1.61-1.61s1.44-.59,2.27-.59c.83,0,1.57.19,2.25.59ZM197.57,7.75c.55-.32.98-.76,1.3-1.32.32-.55.47-1.17.47-1.85s-.15-1.3-.47-1.85-.74-.98-1.27-1.3c-.55-.32-1.17-.47-1.85-.47s-1.3.17-1.85.49c-.55.32-.98.76-1.3,1.32s-.47,1.17-.47,1.85.15,1.3.47,1.85c.32.55.74,1,1.27,1.32.55.32,1.15.49,1.83.49.7-.04,1.32-.21,1.87-.53ZM197.84,4.82c-.15.25-.38.45-.68.59l1.06,1.64h-1.32l-.91-1.42h-.87v1.42h-1.32V2.17h2.12c.66,0,1.19.15,1.57.47.38.32.57.74.57,1.27,0,.34-.08.66-.23.91ZM195.85,4.65c.3,0,.53-.06.68-.19.17-.13.25-.32.25-.55s-.08-.42-.25-.57-.4-.19-.68-.19h-.74v1.53h.74v-.02Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200.18 69.76">
<defs>
<style>
.cls-1 {
fill: #fff;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M114.26.13c-13.19,0-23.88,10.68-23.88,23.88s10.68,23.9,23.88,23.9,23.88-10.68,23.88-23.88h0c-.02-13.19-10.71-23.88-23.88-23.9ZM114.26,38.94c-8.24,0-14.93-6.69-14.93-14.93s6.69-14.93,14.93-14.93,14.93,6.69,14.93,14.93c-.02,8.24-6.71,14.93-14.93,14.93h0Z"/>
<path class="cls-1" d="M24.11,0C10.92-.11.13,10.47,0,23.66c-.13,13.19,10.47,23.98,23.66,24.11h8.31v-8.94h-7.86c-8.24.11-15-6.5-15.1-14.74-.11-8.24,6.5-15,14.74-15.1h.34c8.22,0,14.95,6.69,14.95,14.93h0v21.98h0c0,8.18-6.65,14.83-14.81,14.93-3.91-.04-7.63-1.59-10.39-4.38l-6.33,6.31c4.4,4.42,10.34,6.92,16.57,6.99h.32c13.02-.19,23.49-10.75,23.56-23.77v-22.69C47.65,10.35,37.05.02,24.11,0Z"/>
<path class="cls-1" d="M191.28,68.74V23.43c-.32-12.96-10.92-23.28-23.88-23.3-13.19-.13-23.98,10.47-24.11,23.66-.13,13.19,10.49,23.98,23.68,24.11h8.31v-8.94h-7.86c-8.24.11-15-6.5-15.1-14.74s6.5-15,14.74-15.1h.34c8.22,0,14.95,6.69,14.95,14.93h0v44.63h0l8.92.06Z"/>
<path class="cls-1" d="M54.8,47.9h8.92v-23.88c0-8.24,6.69-14.93,14.93-14.93,2.72,0,5.25.72,7.46,2l4.48-7.75c-3.5-2.02-7.58-3.19-11.92-3.19-13.19,0-23.88,10.68-23.88,23.88v23.88Z"/>
<path class="cls-1" d="M198.01.74c.68.38,1.21.91,1.59,1.59.38.68.57,1.42.57,2.25s-.19,1.57-.59,2.27c-.4.68-.93,1.23-1.61,1.61-.68.4-1.44.59-2.25.59s-1.57-.19-2.25-.59c-.68-.4-1.21-.93-1.59-1.61-.38-.68-.59-1.42-.59-2.25s.19-1.57.59-2.25c.38-.68.93-1.21,1.61-1.61s1.44-.59,2.27-.59c.83,0,1.57.19,2.25.59ZM197.57,7.75c.55-.32.98-.76,1.3-1.32.32-.55.47-1.17.47-1.85s-.15-1.3-.47-1.85-.74-.98-1.27-1.3c-.55-.32-1.17-.47-1.85-.47s-1.3.17-1.85.49c-.55.32-.98.76-1.3,1.32s-.47,1.17-.47,1.85.15,1.3.47,1.85c.32.55.74,1,1.27,1.32.55.32,1.15.49,1.83.49.7-.04,1.32-.21,1.87-.53ZM197.84,4.82c-.15.25-.38.45-.68.59l1.06,1.64h-1.32l-.91-1.42h-.87v1.42h-1.32V2.17h2.12c.66,0,1.19.15,1.57.47.38.32.57.74.57,1.27,0,.34-.08.66-.23.91ZM195.85,4.65c.3,0,.53-.06.68-.19.17-.13.25-.32.25-.55s-.08-.42-.25-.57-.4-.19-.68-.19h-.74v1.53h.74v-.02Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

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
)
+106
View File
@@ -0,0 +1,106 @@
"""AI Providers for ProxMenux notification enhancement.
This module provides a pluggable architecture for different AI providers
to enhance and translate notification messages.
Supported providers:
- Groq: Fast inference, generous free tier (30 req/min)
- OpenAI: Industry standard, widely used
- Anthropic: Excellent for text generation, Claude Haiku is fast and affordable
- Gemini: Google's model, free tier available, good quality/price ratio
- Ollama: 100% local execution, no costs, complete privacy
- OpenRouter: Aggregator with access to 100+ models using a single API key
"""
from .base import AIProvider, AIProviderError
from .groq_provider import GroqProvider
from .openai_provider import OpenAIProvider
from .anthropic_provider import AnthropicProvider
from .gemini_provider import GeminiProvider
from .ollama_provider import OllamaProvider
from .openrouter_provider import OpenRouterProvider
PROVIDERS = {
'groq': GroqProvider,
'openai': OpenAIProvider,
'anthropic': AnthropicProvider,
'gemini': GeminiProvider,
'ollama': OllamaProvider,
'openrouter': OpenRouterProvider,
}
# Provider metadata for UI display
# Note: No hardcoded models - users load models dynamically from each provider
PROVIDER_INFO = {
'groq': {
'name': 'Groq',
'description': 'Fast inference, generous free tier (30 req/min). Ideal to get started.',
'requires_api_key': True,
},
'openai': {
'name': 'OpenAI',
'description': 'Industry standard. Very accurate and widely used.',
'requires_api_key': True,
},
'anthropic': {
'name': 'Anthropic (Claude)',
'description': 'Excellent for writing and translation. Fast and affordable.',
'requires_api_key': True,
},
'gemini': {
'name': 'Google Gemini',
'description': 'Free tier available, very good quality/price ratio.',
'requires_api_key': True,
},
'ollama': {
'name': 'Ollama (Local)',
'description': '100% local execution. No costs, complete privacy, no internet required.',
'requires_api_key': False,
},
'openrouter': {
'name': 'OpenRouter',
'description': 'Aggregator with access to 100+ models using a single API key. Maximum flexibility.',
'requires_api_key': True,
},
}
def get_provider(name: str, **kwargs) -> AIProvider:
"""Factory function to get provider instance.
Args:
name: Provider name (groq, openai, anthropic, gemini, ollama, openrouter)
**kwargs: Provider-specific arguments (api_key, model, base_url)
Returns:
AIProvider instance
Raises:
AIProviderError: If provider name is unknown
"""
if name not in PROVIDERS:
raise AIProviderError(f"Unknown provider: {name}. Available: {list(PROVIDERS.keys())}")
return PROVIDERS[name](**kwargs)
def get_provider_info(name: str = None) -> dict:
"""Get provider metadata for UI display.
Args:
name: Optional provider name. If None, returns all providers info.
Returns:
Provider info dict or dict of all providers
"""
if name:
return PROVIDER_INFO.get(name, {})
return PROVIDER_INFO
__all__ = [
'AIProvider',
'AIProviderError',
'PROVIDERS',
'PROVIDER_INFO',
'get_provider',
'get_provider_info',
]
@@ -0,0 +1,80 @@
"""Anthropic (Claude) provider implementation.
Anthropic's Claude models are excellent for text generation and translation.
Models use "-latest" aliases that auto-update to newest versions.
"""
from typing import Optional, List
from .base import AIProvider, AIProviderError
class AnthropicProvider(AIProvider):
"""Anthropic provider using their Messages API."""
NAME = "anthropic"
REQUIRES_API_KEY = True
API_URL = "https://api.anthropic.com/v1/messages"
API_VERSION = "2023-06-01"
# Known stable model aliases (Anthropic doesn't have a public models list API)
# These use "-latest" which auto-updates to the newest version
KNOWN_MODELS = [
"claude-3-5-haiku-latest",
"claude-3-5-sonnet-latest",
"claude-3-opus-latest",
]
def list_models(self) -> List[str]:
"""Return known Anthropic model aliases.
Anthropic doesn't have a public models list API, but their "-latest"
aliases auto-update to the newest versions, making them reliable choices.
"""
return self.KNOWN_MODELS
def generate(self, system_prompt: str, user_message: str,
max_tokens: int = 200) -> Optional[str]:
"""Generate a response using Anthropic's API.
Note: Anthropic uses a different API format than OpenAI.
The system prompt goes in a separate field, not in messages.
Args:
system_prompt: System instructions
user_message: User message to process
max_tokens: Maximum response length
Returns:
Generated text or None if failed
Raises:
AIProviderError: If API key is missing or request fails
"""
if not self.api_key:
raise AIProviderError("API key required for Anthropic")
# Anthropic uses a different format - system is a top-level field
payload = {
'model': self.model,
'system': system_prompt,
'messages': [
{'role': 'user', 'content': user_message},
],
'max_tokens': max_tokens,
}
headers = {
'Content-Type': 'application/json',
'x-api-key': self.api_key,
'anthropic-version': self.API_VERSION,
}
result = self._make_request(self.API_URL, payload, headers)
try:
# Anthropic returns content as array of content blocks
content = result['content']
if isinstance(content, list) and len(content) > 0:
return content[0].get('text', '').strip()
return str(content).strip()
except (KeyError, IndexError) as e:
raise AIProviderError(f"Unexpected response format: {e}")
+177
View File
@@ -0,0 +1,177 @@
"""Base class for AI providers."""
from abc import ABC, abstractmethod
from typing import Optional, Dict, Any, List
class AIProviderError(Exception):
"""Exception for AI provider errors."""
pass
class AIProvider(ABC):
"""Abstract base class for AI providers.
All provider implementations must inherit from this class and implement
the generate() method.
"""
# Provider metadata (override in subclasses)
NAME = "base"
REQUIRES_API_KEY = True
def __init__(self, api_key: str = "", model: str = "", base_url: str = ""):
"""Initialize the AI provider.
Args:
api_key: API key for authentication (not required for local providers)
model: Model name to use (required - user selects from loaded models)
base_url: Base URL for API calls (used by Ollama and custom endpoints)
"""
self.api_key = api_key
self.model = model # Model must be provided by user after loading from provider
self.base_url = base_url
@abstractmethod
def generate(self, system_prompt: str, user_message: str,
max_tokens: int = 200) -> Optional[str]:
"""Generate a response from the AI model.
Args:
system_prompt: System instructions for the model
user_message: User message/query to process
max_tokens: Maximum tokens in the response
Returns:
Generated text or None if failed
Raises:
AIProviderError: If there's an error communicating with the provider
"""
pass
def test_connection(self) -> Dict[str, Any]:
"""Test the connection to the AI provider.
Sends a simple test message to verify the provider is accessible
and the API key is valid.
Returns:
Dictionary with:
- success: bool indicating if connection succeeded
- message: Human-readable status message
- model: Model name being used
"""
try:
response = self.generate(
system_prompt="You are a test assistant. Respond with exactly: CONNECTION_OK",
user_message="Test connection",
max_tokens=50 # Some providers (Gemini) need more tokens to return any content
)
if response:
# Check if response contains our expected text
if "CONNECTION_OK" in response.upper() or "CONNECTION" in response.upper():
return {
'success': True,
'message': 'Connection successful',
'model': self.model
}
# Even if different response, connection worked
return {
'success': True,
'message': f'Connected (response received)',
'model': self.model
}
return {
'success': False,
'message': 'No response received from provider',
'model': self.model
}
except AIProviderError as e:
return {
'success': False,
'message': str(e),
'model': self.model
}
except Exception as e:
return {
'success': False,
'message': f'Unexpected error: {str(e)}',
'model': self.model
}
def list_models(self) -> List[str]:
"""List available models from the provider.
Returns:
List of model IDs available for use.
Returns empty list if the provider doesn't support listing.
"""
# Default implementation - subclasses should override
return []
def get_recommended_model(self) -> str:
"""Get the recommended model for this provider.
Checks if the current model is available. If not, returns
the first available model from the provider's model list.
This is fully dynamic - no hardcoded fallback models.
Returns:
Recommended model ID, or empty string if no models available
"""
available = self.list_models()
if not available:
# Can't get model list - keep current model and hope it works
return self.model
# Check if current model is available
if self.model and self.model in available:
return self.model
# Current model not available - return first available model
# Models are typically sorted, so first one is usually a good default
return available[0]
def _make_request(self, url: str, payload: dict, headers: dict,
timeout: int = 15) -> dict:
"""Make HTTP request to AI provider API.
Args:
url: API endpoint URL
payload: JSON payload to send
headers: HTTP headers
timeout: Request timeout in seconds
Returns:
Parsed JSON response
Raises:
AIProviderError: If request fails
"""
import json
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')
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode('utf-8'))
except urllib.error.HTTPError as e:
error_body = ""
try:
error_body = e.read().decode('utf-8')
except Exception:
pass
raise AIProviderError(f"HTTP {e.code}: {error_body or e.reason}")
except urllib.error.URLError as e:
raise AIProviderError(f"Connection error: {e.reason}")
except json.JSONDecodeError as e:
raise AIProviderError(f"Invalid JSON response: {e}")
except Exception as e:
raise AIProviderError(f"Request failed: {str(e)}")
@@ -0,0 +1,181 @@
"""Google Gemini provider implementation.
Google's Gemini models offer a free tier and excellent quality/price ratio.
Models are loaded dynamically from the API - no hardcoded model names.
"""
from typing import Optional, List
import json
import urllib.request
import urllib.error
from .base import AIProvider, AIProviderError
class GeminiProvider(AIProvider):
"""Google Gemini provider using the Generative Language API."""
NAME = "gemini"
REQUIRES_API_KEY = True
API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
# Patterns to exclude from model list (experimental, preview, specialized)
EXCLUDED_PATTERNS = [
'preview', 'exp', 'experimental', 'computer-use',
'deep-research', 'image', 'embedding', 'aqa', 'tts',
'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',
]
def list_models(self) -> List[str]:
"""List available Gemini models that support generateContent.
Filters to only stable text generation models, excluding:
- Preview/experimental models
- Image generation models
- Embedding models
- Specialized models (computer-use, deep-research, etc.)
Returns:
List of model IDs available for text generation.
"""
if not self.api_key:
return []
try:
url = f"{self.API_BASE}?key={self.api_key}"
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'))
models = []
for model in data.get('models', []):
model_name = model.get('name', '')
# Extract just the model ID (e.g., "models/gemini-pro" -> "gemini-pro")
if model_name.startswith('models/'):
model_id = model_name[7:]
else:
model_id = model_name
# Only include models that support generateContent
supported_methods = model.get('supportedGenerationMethods', [])
if 'generateContent' not in supported_methods:
continue
# Exclude experimental, preview, and specialized models
model_lower = model_id.lower()
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)
def sort_key(m):
m_lower = m.lower()
if 'flash-lite' in m_lower:
return (0, m) # Best for notifications (fast, cheap)
if 'flash' in m_lower:
return (1, m)
if 'pro' in m_lower:
return (2, m)
return (3, m)
return sorted(models, key=sort_key)
except Exception as e:
print(f"[GeminiProvider] Failed to list models: {e}")
return []
def generate(self, system_prompt: str, user_message: str,
max_tokens: int = 200) -> Optional[str]:
"""Generate a response using Google's Gemini API.
Note: Gemini uses a different API format. System instructions
go in a separate systemInstruction field.
Args:
system_prompt: System instructions
user_message: User message to process
max_tokens: Maximum response length
Returns:
Generated text or None if failed
Raises:
AIProviderError: If API key is missing or request fails
"""
if not self.api_key:
raise AIProviderError("API key required for Gemini")
url = f"{self.API_BASE}/{self.model}:generateContent?key={self.api_key}"
# Gemini uses a specific format with contents array
payload = {
'systemInstruction': {
'parts': [{'text': system_prompt}]
},
'contents': [
{
'role': 'user',
'parts': [{'text': user_message}]
}
],
'generationConfig': {
'maxOutputTokens': max_tokens,
'temperature': 0.3,
}
}
headers = {
'Content-Type': 'application/json',
}
result = self._make_request(url, payload, headers)
try:
# Gemini returns candidates array with content parts
candidates = result.get('candidates', [])
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}")
@@ -0,0 +1,116 @@
"""Groq AI provider implementation.
Groq provides fast inference with a generous free tier (30 requests/minute).
Uses the OpenAI-compatible API format.
"""
from typing import Optional, List
import json
import urllib.request
import urllib.error
from .base import AIProvider, AIProviderError
class GroqProvider(AIProvider):
"""Groq AI provider using their OpenAI-compatible API."""
NAME = "groq"
REQUIRES_API_KEY = True
API_URL = "https://api.groq.com/openai/v1/chat/completions"
MODELS_URL = "https://api.groq.com/openai/v1/models"
# Exclude non-chat models
EXCLUDED_PATTERNS = ['whisper', 'tts', 'guard', 'tool-use']
# Recommended models (in priority order - versatile/large models first)
RECOMMENDED_PREFIXES = ['llama-3.3', 'llama-3.1-70b', 'llama-3.1-8b', 'mixtral', 'gemma']
def list_models(self) -> List[str]:
"""List available Groq models for chat completions.
Filters out non-chat models (whisper, guard, etc.)
Returns:
List of model IDs suitable for chat completions.
"""
if not self.api_key:
return []
try:
req = urllib.request.Request(
self.MODELS_URL,
headers={
'Authorization': f'Bearer {self.api_key}',
'User-Agent': 'ProxMenux/1.0' # Cloudflare blocks requests without User-Agent
},
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()
# Exclude non-chat models
if any(pattern in model_lower for pattern in self.EXCLUDED_PATTERNS):
continue
models.append(model_id)
# Sort with recommended models first
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"[GroqProvider] Failed to list models: {e}")
return []
def generate(self, system_prompt: str, user_message: str,
max_tokens: int = 200) -> Optional[str]:
"""Generate a response using Groq's API.
Args:
system_prompt: System instructions
user_message: User message to process
max_tokens: Maximum response length
Returns:
Generated text or None if failed
Raises:
AIProviderError: If API key is missing or request fails
"""
if not self.api_key:
raise AIProviderError("API key required for Groq")
payload = {
'model': self.model,
'messages': [
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': user_message},
],
'max_tokens': max_tokens,
'temperature': 0.3,
}
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {self.api_key}',
}
result = self._make_request(self.API_URL, payload, headers)
try:
return result['choices'][0]['message']['content'].strip()
except (KeyError, IndexError) as e:
raise AIProviderError(f"Unexpected response format: {e}")
@@ -0,0 +1,149 @@
"""Ollama provider implementation.
Ollama enables 100% local AI execution with no costs and complete privacy.
No internet connection required - perfect for sensitive enterprise environments.
"""
from typing import Optional
from .base import AIProvider, AIProviderError
class OllamaProvider(AIProvider):
"""Ollama provider for local AI execution."""
NAME = "ollama"
REQUIRES_API_KEY = False
DEFAULT_URL = "http://localhost:11434"
def __init__(self, api_key: str = "", model: str = "", base_url: str = ""):
"""Initialize Ollama provider.
Args:
api_key: Not used for Ollama (local execution)
model: Model name (user must select from loaded models)
base_url: Ollama server URL (default: http://localhost:11434)
"""
super().__init__(api_key, model, base_url)
# Use default URL if not provided
if not self.base_url:
self.base_url = self.DEFAULT_URL
def generate(self, system_prompt: str, user_message: str,
max_tokens: int = 200) -> Optional[str]:
"""Generate a response using local Ollama server.
Args:
system_prompt: System instructions
user_message: User message to process
max_tokens: Maximum response length (maps to num_predict)
Returns:
Generated text or None if failed
Raises:
AIProviderError: If Ollama server is unreachable
"""
url = f"{self.base_url.rstrip('/')}/api/chat"
payload = {
'model': self.model,
'messages': [
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': user_message},
],
'stream': False,
'options': {
'num_predict': max_tokens,
'temperature': 0.3,
}
}
headers = {
'Content-Type': 'application/json',
}
# 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 90 # 2 minutes for cloud, 90s for local
try:
result = self._make_request(url, payload, headers, timeout=timeout)
except AIProviderError as e:
if "Connection" in str(e) or "refused" in str(e).lower():
raise AIProviderError(
f"Cannot connect to Ollama at {self.base_url}. "
"Make sure Ollama is running (ollama serve)"
)
raise
try:
message = result.get('message', {})
return message.get('content', '').strip()
except (KeyError, AttributeError) as e:
raise AIProviderError(f"Unexpected response format: {e}")
def test_connection(self):
"""Test connection to Ollama server.
Also checks if the specified model is available.
"""
import json
import urllib.request
import urllib.error
# First check if server is running
try:
url = f"{self.base_url.rstrip('/')}/api/tags"
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'))
# Get full model names (with tags) for comparison
full_model_names = [m.get('name', '') for m in data.get('models', [])]
# Also get base names (without tags) for fallback matching
base_model_names = [name.split(':')[0] for name in full_model_names]
# Check if the requested model matches any available model
# Match by: exact name, base name, or requested model without tag
requested_base = self.model.split(':')[0] if ':' in self.model else self.model
model_found = (
self.model in full_model_names or # Exact match (e.g., "llama3.2:latest")
self.model in base_model_names or # Base name match (e.g., "llama3.2")
requested_base in base_model_names # Requested base matches available base
)
if not model_found:
display_models = full_model_names[:5] if full_model_names else ['none']
return {
'success': False,
'message': f"Model '{self.model}' not found. Available: {', '.join(display_models)}{'...' if len(full_model_names) > 5 else ''}",
'model': self.model
}
except urllib.error.URLError:
return {
'success': False,
'message': f"Cannot connect to Ollama at {self.base_url}. Make sure Ollama is running.",
'model': self.model
}
except Exception as e:
return {
'success': False,
'message': f"Error checking Ollama: {str(e)}",
'model': self.model
}
# If server is up and model exists, do the actual test
# For cloud models, we skip the full test (which sends a message)
# because it would take too long. The model availability check above is sufficient.
is_cloud_model = ':cloud' in self.model.lower()
if is_cloud_model:
return {
'success': True,
'message': f"Cloud model '{self.model}' is available via Ollama",
'model': self.model
}
return super().test_connection()
@@ -0,0 +1,158 @@
"""OpenAI provider implementation.
OpenAI is the industry standard for AI APIs.
Models are loaded dynamically from the API.
"""
from typing import Optional, List
import json
import urllib.request
import urllib.error
from .base import AIProvider, AIProviderError
class OpenAIProvider(AIProvider):
"""OpenAI provider using their Chat Completions API.
Also compatible with OpenAI-compatible APIs like:
- BytePlus/ByteDance (Kimi K2.5)
- LocalAI
- LM Studio
- vLLM
- Together AI
- Any OpenAI-compatible endpoint
"""
NAME = "openai"
REQUIRES_API_KEY = True
DEFAULT_API_URL = "https://api.openai.com/v1/chat/completions"
DEFAULT_MODELS_URL = "https://api.openai.com/v1/models"
# Models to exclude (not suitable for chat/text generation)
EXCLUDED_PATTERNS = [
'embedding', 'whisper', 'tts', 'dall-e', 'image',
'instruct', 'realtime', 'audio', 'moderation',
'search', 'code-search', 'text-similarity', 'babbage', 'davinci',
'curie', 'ada', 'transcribe'
]
# Recommended models for chat (in priority order)
RECOMMENDED_PREFIXES = ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo']
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.)
Returns:
List of model IDs suitable for chat completions.
"""
if not self.api_key:
return []
try:
# Determine models URL from base_url if set
if self.base_url:
base = self.base_url.rstrip('/')
if not base.endswith('/v1'):
base = f"{base}/v1"
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:
continue
# Exclude non-chat models
if any(pattern in model_lower for pattern in self.EXCLUDED_PATTERNS):
continue
models.append(model_id)
# Sort with recommended models first
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}")
return []
def _get_api_url(self) -> str:
"""Get the API URL, using custom base_url if provided."""
if self.base_url:
# Ensure the URL ends with the correct path
base = self.base_url.rstrip('/')
if not base.endswith('/chat/completions'):
if not base.endswith('/v1'):
base = f"{base}/v1"
base = f"{base}/chat/completions"
return base
return self.DEFAULT_API_URL
def generate(self, system_prompt: str, user_message: str,
max_tokens: int = 200) -> Optional[str]:
"""Generate a response using OpenAI's API or compatible endpoint.
Args:
system_prompt: System instructions
user_message: User message to process
max_tokens: Maximum response length
Returns:
Generated text or None if failed
Raises:
AIProviderError: If API key is missing or request fails
"""
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,
}
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {self.api_key}',
}
api_url = self._get_api_url()
result = self._make_request(api_url, payload, headers)
try:
return result['choices'][0]['message']['content'].strip()
except (KeyError, IndexError) as e:
raise AIProviderError(f"Unexpected response format: {e}")
@@ -0,0 +1,123 @@
"""OpenRouter provider implementation.
OpenRouter is an aggregator that provides access to 100+ AI models
using a single API key. Maximum flexibility for choosing models.
Uses OpenAI-compatible API format.
"""
from typing import Optional, List
import json
import urllib.request
import urllib.error
from .base import AIProvider, AIProviderError
class OpenRouterProvider(AIProvider):
"""OpenRouter provider for multi-model access."""
NAME = "openrouter"
REQUIRES_API_KEY = True
API_URL = "https://openrouter.ai/api/v1/chat/completions"
MODELS_URL = "https://openrouter.ai/api/v1/models"
# Exclude non-text models
EXCLUDED_PATTERNS = ['image', 'vision', 'audio', 'video', 'embedding', 'moderation']
# Recommended model prefixes (popular, reliable, good for notifications)
RECOMMENDED_PREFIXES = [
'meta-llama/llama-3', 'anthropic/claude', 'google/gemini',
'openai/gpt', 'mistralai/mistral', 'mistralai/mixtral'
]
def list_models(self) -> List[str]:
"""List available OpenRouter models for chat completions.
OpenRouter has 300+ models. This filters to text generation models
and prioritizes popular, reliable options.
Returns:
List of model IDs suitable for text generation.
"""
if not self.api_key:
return []
try:
req = urllib.request.Request(
self.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()
# Exclude non-text models
if any(pattern in model_lower for pattern in self.EXCLUDED_PATTERNS):
continue
models.append(model_id)
# Sort with recommended models first
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"[OpenRouterProvider] Failed to list models: {e}")
return []
def generate(self, system_prompt: str, user_message: str,
max_tokens: int = 200) -> Optional[str]:
"""Generate a response using OpenRouter's API.
OpenRouter uses OpenAI-compatible format with additional
headers for app identification.
Args:
system_prompt: System instructions
user_message: User message to process
max_tokens: Maximum response length
Returns:
Generated text or None if failed
Raises:
AIProviderError: If API key is missing or request fails
"""
if not self.api_key:
raise AIProviderError("API key required for OpenRouter")
payload = {
'model': self.model,
'messages': [
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': user_message},
],
'max_tokens': max_tokens,
'temperature': 0.3,
}
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {self.api_key}',
'HTTP-Referer': 'https://github.com/MacRimi/ProxMenux',
'X-Title': 'ProxMenux Monitor',
}
result = self._make_request(self.API_URL, payload, headers)
try:
return result['choices'][0]['message']['content'].strip()
except (KeyError, IndexError) as e:
raise AIProviderError(f"Unexpected response format: {e}")
+303 -4
View File
@@ -57,7 +57,9 @@ def load_auth_config():
"configured": bool,
"totp_enabled": bool, # 2FA enabled flag
"totp_secret": str, # TOTP secret key
"backup_codes": list # List of backup codes
"backup_codes": list, # List of backup codes
"api_tokens": list, # List of stored API token metadata
"revoked_tokens": list # List of revoked token hashes
}
"""
if not AUTH_CONFIG_FILE.exists():
@@ -69,7 +71,9 @@ def load_auth_config():
"configured": False,
"totp_enabled": False,
"totp_secret": None,
"backup_codes": []
"backup_codes": [],
"api_tokens": [],
"revoked_tokens": []
}
try:
@@ -81,6 +85,8 @@ def load_auth_config():
config.setdefault("totp_enabled", False)
config.setdefault("totp_secret", None)
config.setdefault("backup_codes", [])
config.setdefault("api_tokens", [])
config.setdefault("revoked_tokens", [])
return config
except Exception as e:
print(f"Error loading auth config: {e}")
@@ -92,7 +98,9 @@ def load_auth_config():
"configured": False,
"totp_enabled": False,
"totp_secret": None,
"backup_codes": []
"backup_codes": [],
"api_tokens": [],
"revoked_tokens": []
}
@@ -141,11 +149,18 @@ def verify_token(token):
"""
Verify a JWT token
Returns username if valid, None otherwise
Also checks if the token has been revoked
"""
if not JWT_AVAILABLE or not token:
return None
try:
# Check if the token has been revoked
token_hash = hashlib.sha256(token.encode()).hexdigest()
config = load_auth_config()
if token_hash in config.get("revoked_tokens", []):
return None
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return payload.get('username')
except jwt.ExpiredSignatureError:
@@ -156,6 +171,88 @@ def verify_token(token):
return None
def store_api_token_metadata(token, token_name="API Token"):
"""
Store API token metadata (hash, name, creation date) for listing and revocation.
The actual token is never stored - only a hash for identification.
"""
config = load_auth_config()
token_hash = hashlib.sha256(token.encode()).hexdigest()
token_id = token_hash[:16]
token_entry = {
"id": token_id,
"name": token_name,
"token_hash": token_hash,
"token_prefix": token[:12] + "...",
"created_at": datetime.utcnow().isoformat() + "Z",
"expires_at": (datetime.utcnow() + timedelta(days=365)).isoformat() + "Z"
}
config.setdefault("api_tokens", [])
config["api_tokens"].append(token_entry)
save_auth_config(config)
return token_entry
def list_api_tokens():
"""
List all stored API token metadata (no actual tokens are returned).
Returns list of token entries with id, name, prefix, creation and expiration dates.
"""
config = load_auth_config()
tokens = config.get("api_tokens", [])
revoked = set(config.get("revoked_tokens", []))
result = []
for t in tokens:
entry = {
"id": t.get("id"),
"name": t.get("name", "API Token"),
"token_prefix": t.get("token_prefix", "***"),
"created_at": t.get("created_at"),
"expires_at": t.get("expires_at"),
"revoked": t.get("token_hash") in revoked
}
result.append(entry)
return result
def revoke_api_token(token_id):
"""
Revoke an API token by its ID.
Adds the token hash to the revoked list so it fails verification.
Returns (success: bool, message: str)
"""
config = load_auth_config()
tokens = config.get("api_tokens", [])
target = None
for t in tokens:
if t.get("id") == token_id:
target = t
break
if not target:
return False, "Token not found"
token_hash = target.get("token_hash")
config.setdefault("revoked_tokens", [])
if token_hash in config["revoked_tokens"]:
return False, "Token is already revoked"
config["revoked_tokens"].append(token_hash)
# Remove from the active tokens list
config["api_tokens"] = [t for t in tokens if t.get("id") != token_id]
if save_auth_config(config):
return True, "Token revoked successfully"
else:
return False, "Failed to save configuration"
def get_auth_status():
"""
Get current authentication status
@@ -243,6 +340,8 @@ def disable_auth():
config["totp_enabled"] = False
config["totp_secret"] = None
config["backup_codes"] = []
config["api_tokens"] = []
config["revoked_tokens"] = []
if save_auth_config(config):
return True, "Authentication disabled"
@@ -472,6 +571,203 @@ def disable_totp(username, password):
return False, "Failed to disable 2FA"
# -------------------------------------------------------------------
# SSL/HTTPS Certificate Management
# -------------------------------------------------------------------
SSL_CONFIG_FILE = Path(os.environ.get("PROXMENUX_SSL_CONFIG", "/etc/proxmenux/ssl_config.json"))
# Default Proxmox certificate paths
PROXMOX_CERT_PATH = "/etc/pve/local/pve-ssl.pem"
PROXMOX_KEY_PATH = "/etc/pve/local/pve-ssl.key"
def load_ssl_config():
"""Load SSL configuration from file"""
if not SSL_CONFIG_FILE.exists():
return {
"enabled": False,
"cert_path": "",
"key_path": "",
"source": "none" # "none", "proxmox", "custom"
}
try:
with open(SSL_CONFIG_FILE, 'r') as f:
config = json.load(f)
config.setdefault("enabled", False)
config.setdefault("cert_path", "")
config.setdefault("key_path", "")
config.setdefault("source", "none")
return config
except Exception:
return {
"enabled": False,
"cert_path": "",
"key_path": "",
"source": "none"
}
def save_ssl_config(config):
"""Save SSL configuration to file"""
try:
SSL_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(SSL_CONFIG_FILE, 'w') as f:
json.dump(config, f, indent=2)
return True
except Exception as e:
print(f"Error saving SSL config: {e}")
return False
def detect_proxmox_certificates():
"""
Detect available Proxmox certificates.
Returns dict with detection results.
"""
result = {
"proxmox_available": False,
"proxmox_cert": PROXMOX_CERT_PATH,
"proxmox_key": PROXMOX_KEY_PATH,
"cert_info": None
}
if os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH):
result["proxmox_available"] = True
# Try to get certificate info
try:
import subprocess
cert_output = subprocess.run(
["openssl", "x509", "-in", PROXMOX_CERT_PATH, "-noout", "-subject", "-enddate", "-issuer"],
capture_output=True, text=True, timeout=5
)
if cert_output.returncode == 0:
lines = cert_output.stdout.strip().split('\n')
info = {}
for line in lines:
if line.startswith("subject="):
info["subject"] = line.replace("subject=", "").strip()
elif line.startswith("notAfter="):
info["expires"] = line.replace("notAfter=", "").strip()
elif line.startswith("issuer="):
issuer = line.replace("issuer=", "").strip()
info["issuer"] = issuer
info["is_self_signed"] = info.get("subject", "") == issuer
result["cert_info"] = info
except Exception:
pass
return result
def validate_certificate_files(cert_path, key_path):
"""
Validate that cert and key files exist and are readable.
Returns (valid: bool, message: str)
"""
if not cert_path or not key_path:
return False, "Certificate and key paths are required"
if not os.path.isfile(cert_path):
return False, f"Certificate file not found: {cert_path}"
if not os.path.isfile(key_path):
return False, f"Key file not found: {key_path}"
# Verify files are readable
try:
with open(cert_path, 'r') as f:
content = f.read(100)
if "BEGIN CERTIFICATE" not in content and "BEGIN TRUSTED CERTIFICATE" not in content:
return False, "Certificate file does not appear to be a valid PEM certificate"
with open(key_path, 'r') as f:
content = f.read(100)
if "BEGIN" not in content or "KEY" not in content:
return False, "Key file does not appear to be a valid PEM key"
except PermissionError:
return False, "Cannot read certificate files. Check file permissions."
except Exception as e:
return False, f"Error reading certificate files: {str(e)}"
# Verify cert and key match
try:
import subprocess
cert_mod = subprocess.run(
["openssl", "x509", "-noout", "-modulus", "-in", cert_path],
capture_output=True, text=True, timeout=5
)
key_mod = subprocess.run(
["openssl", "rsa", "-noout", "-modulus", "-in", key_path],
capture_output=True, text=True, timeout=5
)
if cert_mod.returncode == 0 and key_mod.returncode == 0:
if cert_mod.stdout.strip() != key_mod.stdout.strip():
return False, "Certificate and key do not match"
except Exception:
pass # Non-critical, proceed anyway
return True, "Certificate files are valid"
def configure_ssl(cert_path, key_path, source="custom"):
"""
Configure SSL with given certificate and key paths.
Returns (success: bool, message: str)
"""
valid, message = validate_certificate_files(cert_path, key_path)
if not valid:
return False, message
config = {
"enabled": True,
"cert_path": cert_path,
"key_path": key_path,
"source": source
}
if save_ssl_config(config):
return True, "SSL configured successfully. Restart the monitor service to apply changes."
else:
return False, "Failed to save SSL configuration"
def disable_ssl():
"""Disable SSL and return to HTTP"""
config = {
"enabled": False,
"cert_path": "",
"key_path": "",
"source": "none"
}
if save_ssl_config(config):
return True, "SSL disabled. Restart the monitor service to apply changes."
else:
return False, "Failed to save SSL configuration"
def get_ssl_context():
"""
Get SSL context for Flask if SSL is configured and enabled.
Returns tuple (cert_path, key_path) or None
"""
config = load_ssl_config()
if not config.get("enabled"):
return None
cert_path = config.get("cert_path", "")
key_path = config.get("key_path", "")
if cert_path and key_path and os.path.isfile(cert_path) and os.path.isfile(key_path):
return (cert_path, key_path)
return None
def authenticate(username, password, totp_token=None):
"""
Authenticate a user with username, password, and optional TOTP
@@ -490,12 +786,15 @@ def authenticate(username, password, totp_token=None):
if config.get("totp_enabled"):
if not totp_token:
# First step: password OK, now request TOTP code (not a failure)
return False, None, True, "2FA code required"
# Verify TOTP token or backup code
success, message = verify_totp(username, totp_token, use_backup=len(totp_token) == 9) # Backup codes are formatted XXXX-XXXX
if not success:
return False, None, True, message
# TOTP code is wrong: return requires_totp=False so the caller
# logs it as a real authentication failure for Fail2Ban
return False, None, False, "Invalid 2FA code"
token = generate_token(username)
if token:
+61 -3
View File
@@ -85,6 +85,45 @@ cp "$SCRIPT_DIR/health_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠
cp "$SCRIPT_DIR/health_persistence.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_persistence.py not found"
cp "$SCRIPT_DIR/flask_health_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_health_routes.py not found"
cp "$SCRIPT_DIR/flask_proxmenux_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_proxmenux_routes.py not found"
cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_terminal_routes.py not found"
cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found"
cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found"
cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found"
cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found"
cp "$SCRIPT_DIR/flask_security_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_security_routes.py not found"
cp "$SCRIPT_DIR/notification_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ notification_manager.py not found"
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"
cp "$SCRIPT_DIR/oci/description_templates.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ description_templates.py not found"
# Copy AI providers module for notification enhancement
echo "📋 Copying AI providers module..."
if [ -d "$SCRIPT_DIR/ai_providers" ]; then
mkdir -p "$APP_DIR/usr/bin/ai_providers"
cp "$SCRIPT_DIR/ai_providers/"*.py "$APP_DIR/usr/bin/ai_providers/"
echo "✅ AI providers module copied"
else
echo "⚠️ ai_providers directory not found"
fi
# 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"
fi
echo "📋 Adding translation support..."
cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF'
@@ -281,7 +320,16 @@ if [ -f "$APP_DIR/proxmenux-monitor.png" ]; then
fi
echo "📦 Installing Python dependencies..."
# Phase 1: Install googletrans with its old dependencies
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" \
googletrans==4.0.0-rc1 \
httpx==0.13.3 \
httpcore==0.9.1 \
h11==0.9.0 || true
# Phase 2: Install modern Flask/WebSocket dependencies (will upgrade h11 and related packages)
# Note: cryptography removed due to Python version compatibility issues (PyO3 modules)
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade --no-deps \
flask \
flask-cors \
psutil \
@@ -289,11 +337,21 @@ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" \
PyJWT \
pyotp \
segno \
googletrans==4.0.0-rc1 \
httpx==0.13.3 \
httpcore==0.9.1 \
beautifulsoup4
# Phase 3: Install WebSocket with newer h11
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \
h11>=0.14.0 \
wsproto>=1.2.0 \
simple-websocket>=0.10.0 \
flask-sock>=0.6.0
# Phase 3b: Install gevent for SSL+WebSocket support (WSS)
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \
gevent>=24.2.1 \
gevent-websocket>=0.10.1 \
greenlet>=3.0.0
cat > "$APP_DIR/usr/lib/python3/dist-packages/cgi.py" << 'PYEOF'
from typing import Tuple, Dict
try:
+222 -11
View File
@@ -3,11 +3,48 @@ Flask Authentication Routes
Provides REST API endpoints for authentication management
"""
import logging
import logging.handlers
import os
import subprocess
import threading
import time
from flask import Blueprint, jsonify, request
import auth_manager
import jwt
import datetime
# Dedicated logger for auth failures (Fail2Ban reads this file)
auth_logger = logging.getLogger("proxmenux-auth")
auth_logger.setLevel(logging.WARNING)
# Handler 1: File for Fail2Ban
_auth_file_handler = logging.FileHandler("/var/log/proxmenux-auth.log")
_auth_file_handler.setFormatter(logging.Formatter("%(asctime)s proxmenux-auth: %(message)s"))
auth_logger.addHandler(_auth_file_handler)
# Handler 2: Syslog for JournalWatcher notifications
# This sends to the systemd journal so notification_events.py can detect auth failures
try:
_auth_syslog_handler = logging.handlers.SysLogHandler(address='/dev/log', facility=logging.handlers.SysLogHandler.LOG_AUTH)
_auth_syslog_handler.setFormatter(logging.Formatter("proxmenux-auth: %(message)s"))
_auth_syslog_handler.ident = "proxmenux-auth"
auth_logger.addHandler(_auth_syslog_handler)
except Exception:
pass # Syslog may not be available in all environments
def _get_client_ip():
"""Get the real client IP, supporting reverse proxies (X-Forwarded-For, X-Real-IP)"""
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
# First IP in the chain is the real client
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("X-Real-IP", "")
if real_ip:
return real_ip.strip()
return request.remote_addr or "unknown"
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/api/auth/status', methods=['GET'])
@@ -24,27 +61,132 @@ def auth_status():
return jsonify(status)
except Exception as e:
return jsonify({"error": str(e)}), 500
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/auth/setup', methods=['POST'])
def auth_setup():
"""Set up authentication with username and password"""
# -------------------------------------------------------------------
# SSL/HTTPS Certificate Management
# -------------------------------------------------------------------
@auth_bp.route('/api/ssl/status', methods=['GET'])
def ssl_status():
"""Get current SSL configuration status and detect available certificates"""
try:
data = request.json
username = data.get('username')
password = data.get('password')
config = auth_manager.load_ssl_config()
detection = auth_manager.detect_proxmox_certificates()
success, message = auth_manager.setup_auth(username, password)
return jsonify({
"success": True,
"ssl_enabled": config.get("enabled", False),
"source": config.get("source", "none"),
"cert_path": config.get("cert_path", ""),
"key_path": config.get("key_path", ""),
"proxmox_available": detection.get("proxmox_available", False),
"proxmox_cert": detection.get("proxmox_cert", ""),
"proxmox_key": detection.get("proxmox_key", ""),
"cert_info": detection.get("cert_info")
})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
def _schedule_service_restart(delay=1.5):
"""Schedule a restart of the monitor service via systemctl after a short delay.
This gives time for the HTTP response to reach the client before the process restarts."""
def _do_restart():
time.sleep(delay)
print("[ProxMenux] Restarting monitor service to apply SSL changes...")
# Use systemctl restart which properly stops and starts the service.
# This works because systemd manages proxmenux-monitor.service.
try:
subprocess.Popen(
["systemctl", "restart", "proxmenux-monitor"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
except Exception as e:
print(f"[ProxMenux] Failed to restart via systemctl: {e}")
# Fallback: try to restart the process directly
os.kill(os.getpid(), 15) # SIGTERM
t = threading.Thread(target=_do_restart, daemon=True)
t.start()
@auth_bp.route('/api/ssl/configure', methods=['POST'])
def ssl_configure():
"""Configure SSL with Proxmox or custom certificates"""
try:
data = request.json or {}
source = data.get("source", "proxmox")
auto_restart = data.get("auto_restart", True)
if source == "proxmox":
cert_path = auth_manager.PROXMOX_CERT_PATH
key_path = auth_manager.PROXMOX_KEY_PATH
elif source == "custom":
cert_path = data.get("cert_path", "")
key_path = data.get("key_path", "")
else:
return jsonify({"success": False, "message": "Invalid source. Use 'proxmox' or 'custom'."}), 400
success, message = auth_manager.configure_ssl(cert_path, key_path, source)
if success:
return jsonify({"success": True, "message": message})
if auto_restart:
_schedule_service_restart()
return jsonify({
"success": True,
"message": "SSL enabled. The service is restarting...",
"restarting": auto_restart,
"new_protocol": "https"
})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/ssl/disable', methods=['POST'])
def ssl_disable():
"""Disable SSL and return to HTTP"""
try:
data = request.json or {}
auto_restart = data.get("auto_restart", True)
success, message = auth_manager.disable_ssl()
if success:
if auto_restart:
_schedule_service_restart()
return jsonify({
"success": True,
"message": "SSL disabled. The service is restarting...",
"restarting": auto_restart,
"new_protocol": "http"
})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/ssl/validate', methods=['POST'])
def ssl_validate():
"""Validate custom certificate and key file paths"""
try:
data = request.json or {}
cert_path = data.get("cert_path", "")
key_path = data.get("key_path", "")
valid, message = auth_manager.validate_certificate_files(cert_path, key_path)
return jsonify({"success": valid, "message": message})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/auth/decline', methods=['POST'])
def auth_decline():
"""Decline authentication setup"""
@@ -73,16 +215,50 @@ def auth_login():
if success:
return jsonify({"success": True, "token": token, "message": message})
elif requires_totp:
# First step: password OK, requesting TOTP code (not a failure)
return jsonify({"success": False, "requires_totp": True, "message": message}), 200
else:
return jsonify({"success": False, "message": message}), 401
# Authentication failure (wrong password or wrong TOTP code)
client_ip = _get_client_ip()
auth_logger.warning(
"authentication failure; rhost=%s user=%s",
client_ip, username or "unknown"
)
# If user submitted a TOTP token that was wrong, tell frontend
# to keep showing the TOTP field (not go back to password step)
is_totp_failure = totp_token and "2FA" in message
return jsonify({
"success": False,
"message": message,
"requires_totp": is_totp_failure
}), 401
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/auth/setup', methods=['POST'])
def auth_setup():
"""Set up authentication with username and password (create user + enable auth)"""
try:
data = request.json
username = data.get('username')
password = data.get('password')
success, message = auth_manager.setup_auth(username, password)
if success:
# Generate a token so the user is logged in immediately
token = auth_manager.generate_token(username)
return jsonify({"success": True, "token": token, "message": message})
else:
return jsonify({"success": False, "error": message}), 400
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@auth_bp.route('/api/auth/enable', methods=['POST'])
def auth_enable():
"""Enable authentication"""
"""Enable authentication (must already be configured)"""
try:
success, message = auth_manager.enable_auth()
@@ -262,6 +438,9 @@ def generate_api_token():
'iat': datetime.datetime.utcnow()
}, auth_manager.JWT_SECRET, algorithm='HS256')
# Store token metadata for listing and revocation
auth_manager.store_api_token_metadata(api_token, token_name)
return jsonify({
"success": True,
"token": api_token,
@@ -276,3 +455,35 @@ def generate_api_token():
except Exception as e:
print(f"[ERROR] generate_api_token: {str(e)}") # Log error for debugging
return jsonify({"success": False, "message": f"Internal error: {str(e)}"}), 500
@auth_bp.route('/api/auth/api-tokens', methods=['GET'])
def list_api_tokens():
"""List all generated API tokens (metadata only, no actual token values)"""
try:
token = request.headers.get('Authorization', '').replace('Bearer ', '')
if not token or not auth_manager.verify_token(token):
return jsonify({"success": False, "message": "Unauthorized"}), 401
tokens = auth_manager.list_api_tokens()
return jsonify({"success": True, "tokens": tokens})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/auth/api-tokens/<token_id>', methods=['DELETE'])
def revoke_api_token_route(token_id):
"""Revoke an API token by its ID"""
try:
token = request.headers.get('Authorization', '').replace('Bearer ', '')
if not token or not auth_manager.verify_token(token):
return jsonify({"success": False, "message": "Unauthorized"}), 401
success, message = auth_manager.revoke_api_token(token_id)
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
+529 -3
View File
@@ -51,15 +51,74 @@ def get_system_info():
@health_bp.route('/api/health/acknowledge', methods=['POST'])
def acknowledge_error():
"""Acknowledge an error manually (user dismissed it)"""
"""
Acknowledge/dismiss an error manually.
Returns details about the acknowledged error including original severity
and suppression period info.
"""
try:
data = request.get_json()
if not data or 'error_key' not in data:
return jsonify({'error': 'error_key is required'}), 400
error_key = data['error_key']
health_persistence.acknowledge_error(error_key)
return jsonify({'success': True, 'message': 'Error acknowledged'})
result = health_persistence.acknowledge_error(error_key)
if result.get('success'):
# Invalidate cached health results so next fetch reflects the dismiss
# Use the error's category to clear the correct cache
category = result.get('category', '')
cache_key_map = {
'logs': 'logs_analysis',
'pve_services': 'pve_services',
'updates': 'updates_check',
'security': 'security_check',
'temperature': 'cpu_check',
'network': 'network_check',
'disks': 'storage_check',
'vms': 'vms_check',
}
cache_key = cache_key_map.get(category)
if cache_key:
health_monitor.last_check_times.pop(cache_key, None)
health_monitor.cached_results.pop(cache_key, None)
# Also invalidate ALL background/overall caches so next fetch reflects dismiss
for ck in ['_bg_overall', '_bg_detailed', 'overall_health']:
health_monitor.last_check_times.pop(ck, None)
health_monitor.cached_results.pop(ck, None)
# Use the per-record suppression hours from acknowledge_error()
sup_hours = result.get('suppression_hours', 24)
if sup_hours == -1:
suppression_label = 'permanently'
elif sup_hours >= 8760:
suppression_label = f'{sup_hours // 8760} year(s)'
elif sup_hours >= 720:
suppression_label = f'{sup_hours // 720} month(s)'
elif sup_hours >= 168:
suppression_label = f'{sup_hours // 168} week(s)'
elif sup_hours >= 72:
suppression_label = f'{sup_hours // 24} day(s)'
else:
suppression_label = f'{sup_hours} hours'
return jsonify({
'success': True,
'message': f'Error dismissed for {suppression_label}',
'error_key': error_key,
'original_severity': result.get('original_severity', 'WARNING'),
'category': category,
'suppression_hours': sup_hours,
'suppression_label': suppression_label,
'acknowledged_at': result.get('acknowledged_at')
})
else:
return jsonify({
'success': False,
'message': 'Error not found or already dismissed',
'error_key': error_key
}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500
@@ -72,3 +131,470 @@ def get_active_errors():
return jsonify({'errors': errors})
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/dismissed', methods=['GET'])
def get_dismissed_errors():
"""
Get dismissed errors that are still within their suppression period.
These are shown as INFO items with a 'Dismissed' badge in the frontend.
"""
try:
dismissed = health_persistence.get_dismissed_errors()
return jsonify({'dismissed': dismissed})
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/full', methods=['GET'])
def get_full_health():
"""
Get complete health data in a single request: detailed status + active errors + dismissed.
Uses background-cached results if fresh (< 6 min) for instant response,
otherwise runs a fresh check.
"""
import time as _time
try:
# Try to use the background-cached detailed result for instant response
bg_key = '_bg_detailed'
bg_last = health_monitor.last_check_times.get(bg_key, 0)
bg_age = _time.time() - bg_last
if bg_age < 360 and bg_key in health_monitor.cached_results:
# Use cached result (at most ~5 min old)
details = health_monitor.cached_results[bg_key]
else:
# No fresh cache, run live (first load or cache expired)
details = health_monitor.get_detailed_status()
active_errors = health_persistence.get_active_errors()
dismissed = health_persistence.get_dismissed_errors()
custom_suppressions = health_persistence.get_custom_suppressions()
return jsonify({
'health': details,
'active_errors': active_errors,
'dismissed': dismissed,
'custom_suppressions': custom_suppressions,
'timestamp': details.get('timestamp')
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/cleanup-orphans', methods=['POST'])
def cleanup_orphan_errors():
"""
Clean up errors for devices that no longer exist in the system.
Useful when USB drives or temporary devices are disconnected.
"""
import os
import re
try:
cleaned = []
# Get all active disk errors
disk_errors = health_persistence.get_active_errors(category='disks')
for err in disk_errors:
err_key = err.get('error_key', '')
details = err.get('details', {})
if isinstance(details, str):
try:
import json as _json
details = _json.loads(details)
except Exception:
details = {}
device = details.get('device', '')
base_disk = details.get('disk', '')
# Try to determine the device path
dev_path = None
if base_disk:
dev_path = f'/dev/{base_disk}'
elif device:
dev_path = device if device.startswith('/dev/') else f'/dev/{device}'
elif err_key.startswith('disk_'):
# Extract device from error_key
dev_name = err_key.replace('disk_fs_', '').replace('disk_', '')
dev_name = re.sub(r'_.*$', '', dev_name) # Remove suffix
if dev_name:
dev_path = f'/dev/{dev_name}'
if dev_path:
# Also check base disk (remove partition number)
base_path = re.sub(r'\d+$', '', dev_path)
if not os.path.exists(dev_path) and not os.path.exists(base_path):
health_persistence.resolve_error(err_key, 'Device no longer present (manual cleanup)')
cleaned.append({'error_key': err_key, 'device': dev_path})
# Also cleanup disk_observations for non-existent devices
try:
health_persistence.cleanup_orphan_observations()
except Exception:
pass
return jsonify({
'success': True,
'cleaned_count': len(cleaned),
'cleaned_errors': cleaned
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/pending-notifications', methods=['GET'])
def get_pending_notifications():
"""
Get events pending notification (for future Telegram/Gotify/Discord integration).
This endpoint will be consumed by the Notification Service (Bloque A).
"""
try:
pending = health_persistence.get_pending_notifications()
return jsonify({'pending': pending, 'count': len(pending)})
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/mark-notified', methods=['POST'])
def mark_events_notified():
"""
Mark events as notified after notification was sent successfully.
Used by the Notification Service (Bloque A) after sending alerts.
"""
try:
data = request.get_json()
if not data or 'event_ids' not in data:
return jsonify({'error': 'event_ids array is required'}), 400
event_ids = data['event_ids']
if not isinstance(event_ids, list):
return jsonify({'error': 'event_ids must be an array'}), 400
health_persistence.mark_events_notified(event_ids)
return jsonify({'success': True, 'marked_count': len(event_ids)})
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/settings', methods=['GET'])
def get_health_settings():
"""
Get per-category suppression duration settings.
Returns all health categories with their current configured hours.
"""
try:
categories = health_persistence.get_suppression_categories()
return jsonify({'categories': categories})
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/settings', methods=['POST'])
def save_health_settings():
"""
Save per-category suppression duration settings.
Expects JSON body with key-value pairs like: {"suppress_cpu": "168", "suppress_memory": "-1"}
Valid values: 24, 72, 168, 720, 8760, -1 (permanent), or any positive integer for custom.
"""
try:
data = request.get_json()
if not data:
return jsonify({'error': 'No settings provided'}), 400
valid_keys = set(health_persistence.CATEGORY_SETTING_MAP.values())
updated = []
for key, value in data.items():
if key not in valid_keys:
continue
try:
hours = int(value)
# Validate: must be -1 (permanent) or positive
if hours != -1 and hours < 1:
continue
health_persistence.set_setting(key, str(hours))
updated.append(key)
except (ValueError, TypeError):
continue
# Retroactively sync all existing dismissed errors
# so changes are effective immediately, not just on next dismiss
synced_count = health_persistence.sync_dismissed_suppression()
return jsonify({
'success': True,
'updated': updated,
'count': len(updated),
'synced_dismissed': synced_count
})
except Exception as e:
return jsonify({'error': str(e)}), 500
# ── Remote Storage Exclusions Endpoints ──
@health_bp.route('/api/health/remote-storages', methods=['GET'])
def get_remote_storages():
"""
Get list of all remote storages with their exclusion status.
Remote storages are those that can be offline (PBS, NFS, CIFS, etc.)
"""
try:
from proxmox_storage_monitor import proxmox_storage_monitor
# Get current storage status
storage_status = proxmox_storage_monitor.get_storage_status()
all_storages = storage_status.get('available', []) + storage_status.get('unavailable', [])
# Filter to only remote types
remote_types = health_persistence.REMOTE_STORAGE_TYPES
remote_storages = [s for s in all_storages if s.get('type', '').lower() in remote_types]
# Get current exclusions
exclusions = {e['storage_name']: e for e in health_persistence.get_excluded_storages()}
# Combine info
result = []
for storage in remote_storages:
name = storage.get('name', '')
exclusion = exclusions.get(name, {})
result.append({
'name': name,
'type': storage.get('type', 'unknown'),
'status': storage.get('status', 'unknown'),
'total': storage.get('total', 0),
'used': storage.get('used', 0),
'available': storage.get('available', 0),
'percent': storage.get('percent', 0),
'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')
})
return jsonify({
'storages': result,
'remote_types': list(remote_types)
})
except ImportError:
return jsonify({'error': 'Storage monitor not available', 'storages': []}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/storage-exclusions', methods=['GET'])
def get_storage_exclusions():
"""Get all storage exclusions."""
try:
exclusions = health_persistence.get_excluded_storages()
return jsonify({'exclusions': exclusions})
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/storage-exclusions', methods=['POST'])
def save_storage_exclusion():
"""
Add or update a storage exclusion.
Request body:
{
"storage_name": "pbs-backup",
"storage_type": "pbs",
"exclude_health": true,
"exclude_notifications": true,
"reason": "PBS server is offline daily"
}
"""
try:
data = request.get_json()
if not data or 'storage_name' not in data:
return jsonify({'error': 'storage_name is required'}), 400
storage_name = data['storage_name']
storage_type = data.get('storage_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_storages()
exists = any(e['storage_name'] == storage_name for e in existing)
if exists:
# Update existing
success = health_persistence.update_storage_exclusion(
storage_name, exclude_health, exclude_notifications
)
else:
# Add new
success = health_persistence.exclude_storage(
storage_name, storage_type, exclude_health, exclude_notifications, reason
)
if success:
return jsonify({
'success': True,
'message': f'Storage {storage_name} exclusion saved',
'storage_name': storage_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/storage-exclusions/<storage_name>', methods=['DELETE'])
def delete_storage_exclusion(storage_name):
"""Remove a storage from the exclusion list."""
try:
success = health_persistence.remove_storage_exclusion(storage_name)
if success:
return jsonify({
'success': True,
'message': f'Storage {storage_name} removed from exclusions'
})
else:
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
@@ -0,0 +1,996 @@
"""
Flask routes for notification service configuration and management.
Blueprint pattern matching flask_health_routes.py / flask_security_routes.py.
"""
import hmac
import time
import json
import hashlib
from pathlib import Path
from collections import deque
from flask import Blueprint, jsonify, request
from notification_manager import notification_manager
# ─── Webhook Hardening Helpers ───────────────────────────────────
class WebhookRateLimiter:
"""Simple sliding-window rate limiter for the webhook endpoint."""
def __init__(self, max_requests: int = 60, window_seconds: int = 60):
self._max = max_requests
self._window = window_seconds
self._timestamps: deque = deque()
def allow(self) -> bool:
now = time.time()
# Prune entries outside the window
while self._timestamps and now - self._timestamps[0] > self._window:
self._timestamps.popleft()
if len(self._timestamps) >= self._max:
return False
self._timestamps.append(now)
return True
class ReplayCache:
"""Bounded in-memory cache of recently seen request signatures (60s TTL)."""
_MAX_SIZE = 2000 # Hard cap to prevent memory growth
def __init__(self, ttl: int = 60):
self._ttl = ttl
self._seen: dict = {} # signature -> timestamp
def check_and_record(self, signature: str) -> bool:
"""Return True if this signature was already seen (replay). Records it otherwise."""
now = time.time()
# Periodic cleanup
if len(self._seen) > self._MAX_SIZE // 2:
cutoff = now - self._ttl
self._seen = {k: v for k, v in self._seen.items() if v > cutoff}
if signature in self._seen and now - self._seen[signature] < self._ttl:
return True # Replay detected
self._seen[signature] = now
return False
# Module-level singletons (one per process)
_webhook_limiter = WebhookRateLimiter(max_requests=60, window_seconds=60)
_replay_cache = ReplayCache(ttl=60)
# Timestamp validation window (seconds)
_TIMESTAMP_MAX_DRIFT = 60
notification_bp = Blueprint('notifications', __name__)
@notification_bp.route('/api/notifications/settings', methods=['GET'])
def get_notification_settings():
"""Get all notification settings for the UI."""
try:
settings = notification_manager.get_settings()
return jsonify(settings)
except Exception as e:
return jsonify({'error': str(e)}), 500
@notification_bp.route('/api/notifications/settings', methods=['POST'])
def save_notification_settings():
"""Save notification settings from the UI."""
try:
payload = request.get_json()
if not payload:
return jsonify({'error': 'No data provided'}), 400
result = notification_manager.save_settings(payload)
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@notification_bp.route('/api/notifications/test', methods=['POST'])
def test_notification():
"""Send a test notification to one or all channels."""
try:
data = request.get_json() or {}
channel = data.get('channel', 'all')
result = notification_manager.test_channel(channel)
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
def load_verified_models():
"""Load verified models from config file.
Checks multiple paths:
1. Same directory as script (AppImage: /usr/bin/config/)
2. Parent directory config folder (dev: AppImage/config/)
"""
try:
# Try AppImage path first (scripts and config both in /usr/bin/)
script_dir = Path(__file__).parent
config_path = script_dir / 'config' / 'verified_ai_models.json'
if not config_path.exists():
# Try development path (AppImage/scripts/ -> AppImage/config/)
config_path = script_dir.parent / 'config' / 'verified_ai_models.json'
if config_path.exists():
with open(config_path, 'r') as f:
return json.load(f)
else:
print(f"[flask_notification_routes] Config not found at {config_path}")
except Exception as e:
print(f"[flask_notification_routes] Failed to load verified models: {e}")
return {}
@notification_bp.route('/api/notifications/provider-models', methods=['POST'])
def get_provider_models():
"""Fetch available models from AI provider, filtered by verified models list.
Only returns models that:
1. Are available from the provider's API
2. Are in our verified_ai_models.json list (tested to work)
Request body:
{
"provider": "gemini|groq|openai|openrouter|ollama|anthropic",
"api_key": "your-api-key", // Not needed for ollama
"ollama_url": "http://localhost:11434", // Only for ollama
"openai_base_url": "https://custom.endpoint/v1" // Optional for openai
}
Returns:
{
"success": true/false,
"models": ["model1", "model2", ...],
"recommended": "recommended-model",
"message": "status message"
}
"""
try:
data = request.get_json() or {}
provider = data.get('provider', '')
api_key = data.get('api_key', '')
ollama_url = data.get('ollama_url', 'http://localhost:11434')
openai_base_url = data.get('openai_base_url', '')
if not provider:
return jsonify({'success': False, 'models': [], 'message': 'Provider not specified'})
# Load verified models config
verified_config = load_verified_models()
provider_config = verified_config.get(provider, {})
verified_models = set(provider_config.get('models', []))
recommended = provider_config.get('recommended', '')
# Handle Ollama separately (local, no filtering)
if provider == 'ollama':
import urllib.request
import urllib.error
url = f"{ollama_url.rstrip('/')}/api/tags"
req = urllib.request.Request(url, method='GET')
req.add_header('User-Agent', 'ProxMenux-Monitor/1.1')
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read().decode('utf-8'))
models = [m.get('name', '') for m in result.get('models', []) if m.get('name')]
models = sorted(models)
return jsonify({
'success': True,
'models': models,
'recommended': models[0] if models else '',
'message': f'Found {len(models)} local models'
})
# Handle Anthropic - no models list API, return verified models directly
if provider == 'anthropic':
models = list(verified_models) if verified_models else [
'claude-3-5-haiku-latest',
'claude-3-5-sonnet-latest',
'claude-3-opus-latest',
]
return jsonify({
'success': True,
'models': sorted(models),
'recommended': recommended or models[0],
'message': f'{len(models)} verified models'
})
# For other providers, fetch from API and filter by verified list
if not api_key:
return jsonify({'success': False, 'models': [], 'message': 'API key required'})
from ai_providers import get_provider
ai_provider = get_provider(
provider,
api_key=api_key,
model='',
base_url=openai_base_url if provider == 'openai' else None
)
if not ai_provider:
return jsonify({'success': False, 'models': [], 'message': f'Unknown provider: {provider}'})
# Get all models from provider API
api_models = ai_provider.list_models()
if not api_models:
# API failed, fall back to verified list only
if verified_models:
models = sorted(verified_models)
return jsonify({
'success': True,
'models': models,
'recommended': recommended or models[0],
'message': f'{len(models)} verified models (API unavailable)'
})
return jsonify({
'success': False,
'models': [],
'message': 'Could not retrieve models. Check your API key.'
})
# 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
models = sorted(api_models)
return jsonify({
'success': True,
'models': models,
'recommended': recommended if recommended in models else (models[0] if models else ''),
'message': f'{len(models)} verified models available'
})
except Exception as e:
return jsonify({
'success': False,
'models': [],
'message': f'Error: {str(e)}'
})
@notification_bp.route('/api/notifications/test-ai', methods=['POST'])
def test_ai_connection():
"""Test AI provider connection and configuration.
Request body:
{
"provider": "groq" | "openai" | "anthropic" | "gemini" | "ollama" | "openrouter",
"api_key": "...",
"model": "..." (optional),
"ollama_url": "http://localhost:11434" (optional, for ollama)
}
Returns:
{
"success": true/false,
"message": "Connection successful" or error message,
"model": "model used for test"
}
"""
try:
data = request.get_json() or {}
provider = data.get('provider', 'groq')
api_key = data.get('api_key', '')
model = data.get('model', '')
ollama_url = data.get('ollama_url', 'http://localhost:11434')
openai_base_url = data.get('openai_base_url', '')
# Validate required fields
if provider != 'ollama' and not api_key:
return jsonify({
'success': False,
'message': 'API key is required',
'model': ''
}), 400
if provider == 'ollama' and not ollama_url:
return jsonify({
'success': False,
'message': 'Ollama URL is required',
'model': ''
}), 400
# Import and use the AI providers module
import sys
import os
script_dir = os.path.dirname(os.path.abspath(__file__))
if script_dir not in sys.path:
sys.path.insert(0, script_dir)
from ai_providers import get_provider, AIProviderError
# Determine base_url based on provider
if provider == 'ollama':
base_url = ollama_url
elif provider == 'openai':
base_url = openai_base_url # Empty string means use default OpenAI API
else:
base_url = ''
try:
ai_provider = get_provider(
provider,
api_key=api_key,
model=model,
base_url=base_url
)
result = ai_provider.test_connection()
return jsonify(result)
except AIProviderError as e:
return jsonify({
'success': False,
'message': str(e),
'model': model
}), 400
except Exception as e:
return jsonify({
'success': False,
'message': f'Unexpected error: {str(e)}',
'model': ''
}), 500
@notification_bp.route('/api/notifications/status', methods=['GET'])
def get_notification_status():
"""Get notification service status."""
try:
status = notification_manager.get_status()
return jsonify(status)
except Exception as e:
return jsonify({'error': str(e)}), 500
@notification_bp.route('/api/notifications/history', methods=['GET'])
def get_notification_history():
"""Get notification history with optional filters."""
try:
limit = request.args.get('limit', 100, type=int)
offset = request.args.get('offset', 0, type=int)
severity = request.args.get('severity', '')
channel = request.args.get('channel', '')
result = notification_manager.get_history(limit, offset, severity, channel)
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@notification_bp.route('/api/notifications/history', methods=['DELETE'])
def clear_notification_history():
"""Clear all notification history."""
try:
result = notification_manager.clear_history()
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@notification_bp.route('/api/notifications/send', methods=['POST'])
def send_notification():
"""Send a notification via API (for testing or external triggers)."""
try:
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
result = notification_manager.send_notification(
event_type=data.get('event_type', 'custom'),
severity=data.get('severity', 'INFO'),
title=data.get('title', ''),
message=data.get('message', ''),
data=data.get('data', {}),
source='api'
)
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
# ── PVE config constants ──
_PVE_ENDPOINT_ID = 'proxmenux-webhook'
_PVE_MATCHER_ID = 'proxmenux-default'
_PVE_WEBHOOK_URL = 'http://127.0.0.1:8008/api/notifications/webhook'
_PVE_NOTIFICATIONS_CFG = '/etc/pve/notifications.cfg'
_PVE_PRIV_CFG = '/etc/pve/priv/notifications.cfg'
_PVE_OUR_HEADERS = {
f'webhook: {_PVE_ENDPOINT_ID}',
f'matcher: {_PVE_MATCHER_ID}',
}
def _pve_read_file(path):
"""Read file, return (content, error). Content is '' if missing."""
try:
with open(path, 'r') as f:
return f.read(), None
except FileNotFoundError:
return '', None
except PermissionError:
return None, f'Permission denied reading {path}'
except Exception as e:
return None, str(e)
def _pve_backup_file(path):
"""Create timestamped backup if file exists. Never fails fatally."""
import os, shutil
from datetime import datetime
try:
if os.path.exists(path):
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
backup = f"{path}.proxmenux_backup_{ts}"
shutil.copy2(path, backup)
except Exception:
pass
def _pve_remove_our_blocks(text, headers_to_remove):
"""Remove only blocks whose header line matches one of ours.
Preserves ALL other content byte-for-byte.
A block = header line + indented continuation lines + trailing blank line.
"""
lines = text.splitlines(keepends=True)
cleaned = []
skip_block = False
for line in lines:
stripped = line.strip()
if stripped and not line[0:1].isspace() and ':' in stripped:
if stripped in headers_to_remove:
skip_block = True
continue
else:
skip_block = False
if skip_block:
if not stripped:
skip_block = False
continue
elif line[0:1].isspace():
continue
else:
skip_block = False
cleaned.append(line)
return ''.join(cleaned)
def _build_webhook_fallback():
"""Build fallback manual commands for webhook setup."""
import base64
body_tpl = '{"title":"{{ escape title }}","message":"{{ escape message }}","severity":"{{ severity }}","timestamp":"{{ timestamp }}","fields":{{ json fields }}}'
body_b64 = base64.b64encode(body_tpl.encode()).decode()
return [
"# 1. Append to END of /etc/pve/notifications.cfg",
"# (do NOT delete existing content):",
"",
f"webhook: {_PVE_ENDPOINT_ID}",
f"\tbody {body_b64}",
f"\tmethod post",
f"\turl {_PVE_WEBHOOK_URL}",
"",
f"matcher: {_PVE_MATCHER_ID}",
f"\ttarget {_PVE_ENDPOINT_ID}",
"\tmode all",
"",
"# 2. Append to /etc/pve/priv/notifications.cfg :",
f"webhook: {_PVE_ENDPOINT_ID}",
]
def setup_pve_webhook_core() -> dict:
"""Core logic to configure PVE webhook. Callable from anywhere.
Returns dict with 'configured', 'error', 'fallback_commands' keys.
Idempotent: safe to call multiple times.
"""
import secrets as secrets_mod
result = {
'configured': False,
'endpoint_id': _PVE_ENDPOINT_ID,
'matcher_id': _PVE_MATCHER_ID,
'url': _PVE_WEBHOOK_URL,
'fallback_commands': [],
'error': None,
}
try:
# ── Step 1: Ensure webhook secret exists (for our own internal use) ──
secret = notification_manager.get_webhook_secret()
if not secret:
secret = secrets_mod.token_urlsafe(32)
notification_manager._save_setting('webhook_secret', secret)
# ── Step 2: Read main config ──
cfg_text, err = _pve_read_file(_PVE_NOTIFICATIONS_CFG)
if err:
result['error'] = err
result['fallback_commands'] = _build_webhook_fallback()
return result
# ── Step 3: Read priv config (to clean up any broken blocks we wrote before) ──
priv_text, err = _pve_read_file(_PVE_PRIV_CFG)
if err:
priv_text = None
# ── Step 4: Create backups before ANY modification ──
_pve_backup_file(_PVE_NOTIFICATIONS_CFG)
if priv_text is not None:
_pve_backup_file(_PVE_PRIV_CFG)
# ── Step 5: Remove any previous proxmenux blocks from BOTH files ──
cleaned_cfg = _pve_remove_our_blocks(cfg_text, _PVE_OUR_HEADERS)
if priv_text is not None:
cleaned_priv = _pve_remove_our_blocks(priv_text, _PVE_OUR_HEADERS)
# ── Step 6: Build new blocks ──
# Exact format from a real working PVE server:
# webhook: name
# \tmethod post
# \turl http://...
#
# NO header lines -- localhost webhook doesn't need them.
# PVE header format is: header name=X-Key,value=<base64>
# PVE secret format is: secret name=key,value=<base64>
# Neither is needed for localhost calls.
# PVE stores body as base64 in the config file.
# {{ escape title/message }} -- JSON-safe escaping of quotes/newlines.
# {{ json fields }} -- renders ALL PVE metadata as a JSON object
# (type, hostname, job-id). This is a single Handlebars helper
# that always works, even if fields is empty (renders {}).
import base64
body_template = '{"title":"{{ escape title }}","message":"{{ escape message }}","severity":"{{ severity }}","timestamp":"{{ timestamp }}","fields":{{ json fields }}}'
body_b64 = base64.b64encode(body_template.encode()).decode()
endpoint_block = (
f"webhook: {_PVE_ENDPOINT_ID}\n"
f"\tbody {body_b64}\n"
f"\tmethod post\n"
f"\turl {_PVE_WEBHOOK_URL}\n"
)
matcher_block = (
f"matcher: {_PVE_MATCHER_ID}\n"
f"\ttarget {_PVE_ENDPOINT_ID}\n"
f"\tmode all\n"
)
# ── Step 7: Append our blocks to cleaned main config ──
if cleaned_cfg and not cleaned_cfg.endswith('\n'):
cleaned_cfg += '\n'
if cleaned_cfg and not cleaned_cfg.endswith('\n\n'):
cleaned_cfg += '\n'
new_cfg = cleaned_cfg + endpoint_block + '\n' + matcher_block
# ── Step 8: Write main config ──
try:
with open(_PVE_NOTIFICATIONS_CFG, 'w') as f:
f.write(new_cfg)
except PermissionError:
result['error'] = f'Permission denied writing {_PVE_NOTIFICATIONS_CFG}'
result['fallback_commands'] = _build_webhook_fallback()
return result
except Exception as e:
try:
with open(_PVE_NOTIFICATIONS_CFG, 'w') as f:
f.write(cfg_text)
except Exception:
pass
result['error'] = str(e)
result['fallback_commands'] = _build_webhook_fallback()
return result
# ── Step 9: Write priv config with our webhook entry ──
# PVE REQUIRES a matching block in priv/notifications.cfg for every
# webhook endpoint, even if it has no secrets. Without it PVE throws:
# "Could not instantiate endpoint: private config does not exist"
priv_block = (
f"webhook: {_PVE_ENDPOINT_ID}\n"
)
if priv_text is not None:
# Start from cleaned priv (our old blocks removed)
if cleaned_priv and not cleaned_priv.endswith('\n'):
cleaned_priv += '\n'
if cleaned_priv and not cleaned_priv.endswith('\n\n'):
cleaned_priv += '\n'
new_priv = cleaned_priv + priv_block
else:
new_priv = priv_block
try:
with open(_PVE_PRIV_CFG, 'w') as f:
f.write(new_priv)
except PermissionError:
result['error'] = f'Permission denied writing {_PVE_PRIV_CFG}'
result['fallback_commands'] = _build_webhook_fallback()
return result
except Exception:
pass
result['configured'] = True
result['secret'] = secret
return result
except Exception as e:
result['error'] = str(e)
result['fallback_commands'] = _build_webhook_fallback()
return result
@notification_bp.route('/api/notifications/proxmox/setup-webhook', methods=['POST'])
def setup_proxmox_webhook():
"""HTTP endpoint wrapper for webhook setup."""
return jsonify(setup_pve_webhook_core()), 200
def cleanup_pve_webhook_core() -> dict:
"""Core logic to remove PVE webhook blocks. Callable from anywhere.
Returns dict with 'cleaned', 'error' keys.
Only removes blocks named 'proxmenux-webhook' / 'proxmenux-default'.
"""
result = {'cleaned': False, 'error': None}
try:
# Read both files
cfg_text, err = _pve_read_file(_PVE_NOTIFICATIONS_CFG)
if err:
result['error'] = err
return result
priv_text, err = _pve_read_file(_PVE_PRIV_CFG)
if err:
priv_text = None
# Check if our blocks actually exist before doing anything
has_our_blocks = any(
h in cfg_text for h in [f'webhook: {_PVE_ENDPOINT_ID}', f'matcher: {_PVE_MATCHER_ID}']
)
has_priv_blocks = priv_text and f'webhook: {_PVE_ENDPOINT_ID}' in priv_text
if not has_our_blocks and not has_priv_blocks:
result['cleaned'] = True
return result
# Backup before modification
_pve_backup_file(_PVE_NOTIFICATIONS_CFG)
if priv_text is not None:
_pve_backup_file(_PVE_PRIV_CFG)
# Remove our blocks
if has_our_blocks:
cleaned_cfg = _pve_remove_our_blocks(cfg_text, _PVE_OUR_HEADERS)
try:
with open(_PVE_NOTIFICATIONS_CFG, 'w') as f:
f.write(cleaned_cfg)
except PermissionError:
result['error'] = f'Permission denied writing {_PVE_NOTIFICATIONS_CFG}'
return result
except Exception as e:
# Rollback
try:
with open(_PVE_NOTIFICATIONS_CFG, 'w') as f:
f.write(cfg_text)
except Exception:
pass
result['error'] = str(e)
return result
if has_priv_blocks and priv_text is not None:
cleaned_priv = _pve_remove_our_blocks(priv_text, _PVE_OUR_HEADERS)
try:
with open(_PVE_PRIV_CFG, 'w') as f:
f.write(cleaned_priv)
except Exception:
pass # Best-effort
result['cleaned'] = True
return result
except Exception as e:
result['error'] = str(e)
return result
@notification_bp.route('/api/notifications/proxmox/cleanup-webhook', methods=['POST'])
def cleanup_proxmox_webhook():
"""HTTP endpoint wrapper for webhook cleanup."""
return jsonify(cleanup_pve_webhook_core()), 200
@notification_bp.route('/api/notifications/proxmox/read-cfg', methods=['GET'])
def read_pve_notification_cfg():
"""Diagnostic: return raw content of PVE notification config files.
GET /api/notifications/proxmox/read-cfg
Returns both notifications.cfg and priv/notifications.cfg content.
"""
import os
files = {
'notifications_cfg': '/etc/pve/notifications.cfg',
'priv_cfg': '/etc/pve/priv/notifications.cfg',
}
# Also look for any backups we created
backup_dir = '/etc/pve'
priv_backup_dir = '/etc/pve/priv'
result = {}
for key, path in files.items():
try:
with open(path, 'r') as f:
result[key] = {
'path': path,
'content': f.read(),
'size': os.path.getsize(path),
'error': None,
}
except FileNotFoundError:
result[key] = {'path': path, 'content': None, 'size': 0, 'error': 'file_not_found'}
except PermissionError:
result[key] = {'path': path, 'content': None, 'size': 0, 'error': 'permission_denied'}
except Exception as e:
result[key] = {'path': path, 'content': None, 'size': 0, 'error': str(e)}
# Find backups
backups = []
for d in [backup_dir, priv_backup_dir]:
try:
for fname in sorted(os.listdir(d)):
if 'proxmenux_backup' in fname:
fpath = os.path.join(d, fname)
try:
with open(fpath, 'r') as f:
backups.append({
'path': fpath,
'content': f.read(),
'size': os.path.getsize(fpath),
})
except Exception:
backups.append({'path': fpath, 'content': None, 'error': 'read_failed'})
except Exception:
pass
result['backups'] = backups
return jsonify(result), 200
@notification_bp.route('/api/notifications/proxmox/restore-cfg', methods=['POST'])
def restore_pve_notification_cfg():
"""Restore PVE notification config from our backup.
POST /api/notifications/proxmox/restore-cfg
Finds the most recent proxmenux_backup and restores it.
"""
import os
import shutil
files_to_restore = {
'/etc/pve': '/etc/pve/notifications.cfg',
'/etc/pve/priv': '/etc/pve/priv/notifications.cfg',
}
restored = []
errors = []
for search_dir, target_path in files_to_restore.items():
try:
candidates = sorted([
f for f in os.listdir(search_dir)
if 'proxmenux_backup' in f and f.startswith('notifications.cfg')
], reverse=True)
if candidates:
backup_path = os.path.join(search_dir, candidates[0])
shutil.copy2(backup_path, target_path)
restored.append({'target': target_path, 'from_backup': backup_path})
else:
errors.append({'target': target_path, 'error': 'no_backup_found'})
except Exception as e:
errors.append({'target': target_path, 'error': str(e)})
return jsonify({
'restored': restored,
'errors': errors,
'success': len(errors) == 0 and len(restored) > 0,
}), 200
@notification_bp.route('/api/notifications/webhook', methods=['POST'])
def proxmox_webhook():
"""Receive native Proxmox VE notification webhooks (hardened).
Security layers:
Localhost (127.0.0.1 / ::1): rate limiting only.
PVE calls us on localhost and cannot send custom auth headers,
so we trust the loopback interface (only local processes can reach it).
Remote: rate limiting + shared secret + timestamp + replay + IP allowlist.
"""
_reject = lambda code, error, status: (jsonify({'accepted': False, 'error': error}), status)
client_ip = request.remote_addr or ''
is_localhost = client_ip in ('127.0.0.1', '::1')
# ── Layer 1: Rate limiting (always) ──
if not _webhook_limiter.allow():
resp = jsonify({'accepted': False, 'error': 'rate_limited'})
resp.headers['Retry-After'] = '60'
return resp, 429
# ── Layers 2-5: Remote-only checks ──
if not is_localhost:
# Layer 2: Shared secret
try:
configured_secret = notification_manager.get_webhook_secret()
except Exception:
configured_secret = ''
if configured_secret:
request_secret = request.headers.get('X-Webhook-Secret', '')
if not request_secret:
return _reject(401, 'missing_secret', 401)
if not hmac.compare_digest(configured_secret, request_secret):
return _reject(401, 'invalid_secret', 401)
# Layer 3: Anti-replay timestamp
ts_header = request.headers.get('X-ProxMenux-Timestamp', '')
if not ts_header:
return _reject(401, 'missing_timestamp', 401)
try:
ts_value = int(ts_header)
except (ValueError, TypeError):
return _reject(401, 'invalid_timestamp', 401)
if abs(time.time() - ts_value) > _TIMESTAMP_MAX_DRIFT:
return _reject(401, 'timestamp_expired', 401)
# Layer 4: Replay cache
raw_body = request.get_data(as_text=True) or ''
signature = hashlib.sha256(f"{ts_value}:{raw_body}".encode(errors='replace')).hexdigest()
if _replay_cache.check_and_record(signature):
return _reject(409, 'replay_detected', 409)
# Layer 5: IP allowlist
try:
allowed_ips = notification_manager.get_webhook_allowed_ips()
if allowed_ips and client_ip not in allowed_ips:
return _reject(403, 'forbidden_ip', 403)
except Exception:
pass
# ── Parse and process payload ──
try:
content_type = request.content_type or ''
raw_data = request.get_data(as_text=True) or ''
# Try JSON first
payload = request.get_json(silent=True) or {}
# If not JSON, try form data
if not payload:
payload = dict(request.form)
# If still empty, try parsing raw data as JSON (PVE may not set Content-Type)
if not payload and raw_data:
import json
try:
payload = json.loads(raw_data)
except (json.JSONDecodeError, ValueError):
# PVE's {{ message }} may contain unescaped newlines/quotes
# that break JSON. Try to repair common issues.
try:
repaired = raw_data.replace('\n', '\\n').replace('\r', '\\r')
payload = json.loads(repaired)
except (json.JSONDecodeError, ValueError):
# Try to extract fields with regex from broken JSON
import re
title_m = re.search(r'"title"\s*:\s*"([^"]*)"', raw_data)
sev_m = re.search(r'"severity"\s*:\s*"([^"]*)"', raw_data)
if title_m:
payload = {
'title': title_m.group(1),
'body': raw_data[:1000],
'severity': sev_m.group(1) if sev_m else 'info',
'source': 'proxmox_hook',
}
# If still empty, try to salvage data from raw body
if not payload:
if raw_data:
# Last resort: treat raw text as the message body
payload = {
'title': 'PVE Notification',
'body': raw_data[:1000],
'severity': 'info',
'source': 'proxmox_hook',
}
else:
return _reject(400, 'empty_payload', 400)
result = notification_manager.process_webhook(payload)
# Always return 200 to PVE -- a non-200 makes PVE report the webhook as broken.
# The 'accepted' field in the JSON body indicates actual processing status.
return jsonify(result), 200
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
+545
View File
@@ -0,0 +1,545 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ProxMenux OCI Routes
REST API endpoints for OCI container app management.
"""
import logging
from flask import Blueprint, jsonify, request
import oci_manager
from jwt_middleware import require_auth
# Logging
logger = logging.getLogger("proxmenux.oci.routes")
# Blueprint
oci_bp = Blueprint("oci", __name__, url_prefix="/api/oci")
# =================================================================
# Catalog Endpoints
# =================================================================
@oci_bp.route("/catalog", methods=["GET"])
@require_auth
def get_catalog():
"""
List all available apps from the catalog.
Returns:
List of apps with basic info and installation status.
"""
try:
apps = oci_manager.list_available_apps()
return jsonify({
"success": True,
"apps": apps
})
except Exception as e:
logger.error(f"Failed to get catalog: {e}")
return jsonify({
"success": False,
"message": str(e)
}), 500
@oci_bp.route("/catalog/<app_id>", methods=["GET"])
@require_auth
def get_app_definition(app_id: str):
"""
Get the full definition for a specific app.
Args:
app_id: The app identifier
Returns:
Full app definition including config schema.
"""
try:
app_def = oci_manager.get_app_definition(app_id)
if not app_def:
return jsonify({
"success": False,
"message": f"App '{app_id}' not found in catalog"
}), 404
return jsonify({
"success": True,
"app": app_def,
"installed": oci_manager.is_installed(app_id)
})
except Exception as e:
logger.error(f"Failed to get app definition: {e}")
return jsonify({
"success": False,
"message": str(e)
}), 500
@oci_bp.route("/storages", methods=["GET"])
@require_auth
def get_storages():
"""
Get list of available storages for LXC rootfs.
Returns:
List of storages with capacity info and recommendations.
"""
try:
storages = oci_manager.get_available_storages()
return jsonify({
"success": True,
"storages": storages
})
except Exception as e:
logger.error(f"Failed to get storages: {e}")
return jsonify({
"success": False,
"message": str(e)
}), 500
@oci_bp.route("/catalog/<app_id>/schema", methods=["GET"])
@require_auth
def get_app_schema(app_id: str):
"""
Get only the config schema for an app.
Args:
app_id: The app identifier
Returns:
Config schema for building dynamic forms.
"""
try:
app_def = oci_manager.get_app_definition(app_id)
if not app_def:
return jsonify({
"success": False,
"message": f"App '{app_id}' not found in catalog"
}), 404
return jsonify({
"success": True,
"app_id": app_id,
"name": app_def.get("name", app_id),
"schema": app_def.get("config_schema", {})
})
except Exception as e:
logger.error(f"Failed to get app schema: {e}")
return jsonify({
"success": False,
"message": str(e)
}), 500
# =================================================================
# Installed Apps Endpoints
# =================================================================
@oci_bp.route("/installed", methods=["GET"])
@require_auth
def list_installed():
"""
List all installed apps with their current status.
Returns:
List of installed apps with status info.
"""
try:
apps = oci_manager.list_installed_apps()
return jsonify({
"success": True,
"instances": apps
})
except Exception as e:
logger.error(f"Failed to list installed apps: {e}")
return jsonify({
"success": False,
"message": str(e)
}), 500
@oci_bp.route("/installed/<app_id>", methods=["GET"])
@require_auth
def get_installed_app(app_id: str):
"""
Get details of an installed app including current status.
Args:
app_id: The app identifier
Returns:
Installed app details with container info and status.
"""
try:
app = oci_manager.get_installed_app(app_id)
if not app:
return jsonify({
"success": False,
"message": f"App '{app_id}' is not installed"
}), 404
return jsonify({
"success": True,
"instance": app
})
except Exception as e:
logger.error(f"Failed to get installed app: {e}")
return jsonify({
"success": False,
"message": str(e)
}), 500
@oci_bp.route("/installed/<app_id>/logs", methods=["GET"])
@require_auth
def get_app_logs(app_id: str):
"""
Get recent logs from an app's container.
Args:
app_id: The app identifier
Query params:
lines: Number of lines to return (default 100)
Returns:
Container logs.
"""
try:
lines = request.args.get("lines", 100, type=int)
result = oci_manager.get_app_logs(app_id, lines=lines)
if not result.get("success"):
return jsonify(result), 404 if "not installed" in result.get("message", "") else 500
return jsonify(result)
except Exception as e:
logger.error(f"Failed to get app logs: {e}")
return jsonify({
"success": False,
"message": str(e)
}), 500
# =================================================================
# Deployment Endpoint
# =================================================================
@oci_bp.route("/deploy", methods=["POST"])
@require_auth
def deploy_app():
"""
Deploy an OCI app with the given configuration.
Body:
{
"app_id": "secure-gateway",
"config": {
"auth_key": "tskey-auth-xxx",
"hostname": "proxmox-gateway",
...
}
}
Returns:
Deployment result with container ID if successful.
"""
try:
data = request.get_json()
if not data:
return jsonify({
"success": False,
"message": "Request body is required"
}), 400
app_id = data.get("app_id")
config = data.get("config", {})
if not app_id:
return jsonify({
"success": False,
"message": "app_id is required"
}), 400
logger.info(f"Deploy request: app_id={app_id}, config_keys={list(config.keys())}")
result = oci_manager.deploy_app(app_id, config, installed_by="web")
logger.info(f"Deploy result: {result}")
status_code = 200 if result.get("success") else 400
return jsonify(result), status_code
except Exception as e:
logger.error(f"Failed to deploy app: {e}")
return jsonify({
"success": False,
"message": str(e)
}), 500
# =================================================================
# Lifecycle Action Endpoints
# =================================================================
@oci_bp.route("/installed/<app_id>/start", methods=["POST"])
@require_auth
def start_app(app_id: str):
"""Start an installed app's container."""
try:
result = oci_manager.start_app(app_id)
status_code = 200 if result.get("success") else 400
return jsonify(result), status_code
except Exception as e:
logger.error(f"Failed to start app: {e}")
return jsonify({
"success": False,
"message": str(e)
}), 500
@oci_bp.route("/installed/<app_id>/stop", methods=["POST"])
@require_auth
def stop_app(app_id: str):
"""Stop an installed app's container."""
try:
result = oci_manager.stop_app(app_id)
status_code = 200 if result.get("success") else 400
return jsonify(result), status_code
except Exception as e:
logger.error(f"Failed to stop app: {e}")
return jsonify({
"success": False,
"message": str(e)
}), 500
@oci_bp.route("/installed/<app_id>/restart", methods=["POST"])
@require_auth
def restart_app(app_id: str):
"""Restart an installed app's container."""
try:
result = oci_manager.restart_app(app_id)
status_code = 200 if result.get("success") else 400
return jsonify(result), status_code
except Exception as e:
logger.error(f"Failed to restart app: {e}")
return jsonify({
"success": False,
"message": str(e)
}), 500
@oci_bp.route("/installed/<app_id>", methods=["DELETE"])
@require_auth
def remove_app(app_id: str):
"""
Remove an installed app.
Query params:
remove_data: If true, also remove persistent data (default false)
"""
try:
remove_data = request.args.get("remove_data", "false").lower() == "true"
result = oci_manager.remove_app(app_id, remove_data=remove_data)
status_code = 200 if result.get("success") else 400
return jsonify(result), status_code
except Exception as e:
logger.error(f"Failed to remove app: {e}")
return jsonify({
"success": False,
"message": str(e)
}), 500
# =================================================================
# Configuration Update Endpoint
# =================================================================
@oci_bp.route("/installed/<app_id>/config", methods=["PUT"])
@require_auth
def update_app_config(app_id: str):
"""
Update an app's configuration and recreate the container.
Body:
{
"config": { ... new config values ... }
}
"""
try:
data = request.get_json()
if not data or "config" not in data:
return jsonify({
"success": False,
"message": "config is required in request body"
}), 400
result = oci_manager.update_app_config(app_id, data["config"])
status_code = 200 if result.get("success") else 400
return jsonify(result), status_code
except Exception as e:
logger.error(f"Failed to update app config: {e}")
return jsonify({
"success": False,
"message": str(e)
}), 500
# =================================================================
# Utility Endpoints
# =================================================================
@oci_bp.route("/networks", methods=["GET"])
@require_auth
def get_networks():
"""
Get available networks for VPN routing.
Returns:
List of detected network interfaces with their subnets.
"""
try:
networks = oci_manager.detect_networks()
return jsonify({
"success": True,
"networks": networks
})
except Exception as e:
logger.error(f"Failed to detect networks: {e}")
return jsonify({
"success": False,
"message": str(e)
}), 500
@oci_bp.route("/runtime", methods=["GET"])
@require_auth
def get_runtime():
"""
Get container runtime information.
Returns:
Runtime type (podman/docker), version, and availability.
"""
try:
runtime_info = oci_manager.detect_runtime()
return jsonify({
"success": True,
**runtime_info
})
except Exception as e:
logger.error(f"Failed to detect runtime: {e}")
return jsonify({
"success": False,
"message": str(e)
}), 500
@oci_bp.route("/runtime/install-script", methods=["GET"])
@require_auth
def get_runtime_install_script():
"""
Get the path to the runtime installation script.
Returns:
Script path for installing Podman.
"""
import os
# Check possible paths for the install script
possible_paths = [
"/usr/local/share/proxmenux/scripts/oci/install_runtime.sh",
os.path.join(os.path.dirname(__file__), "..", "..", "Scripts", "oci", "install_runtime.sh"),
]
for script_path in possible_paths:
if os.path.exists(script_path):
return jsonify({
"success": True,
"script_path": os.path.abspath(script_path)
})
return jsonify({
"success": False,
"message": "Runtime installation script not found"
}), 404
@oci_bp.route("/status/<app_id>", methods=["GET"])
@require_auth
def get_app_status(app_id: str):
"""
Get the current status of an app's container.
Returns:
Container state, health, and uptime.
"""
try:
status = oci_manager.get_app_status(app_id)
return jsonify({
"success": True,
"app_id": app_id,
"status": status
})
except Exception as e:
logger.error(f"Failed to get app status: {e}")
return jsonify({
"success": False,
"message": str(e)
}), 500
@oci_bp.route("/installed/<app_id>/update-auth-key", methods=["POST"])
@require_auth
def update_auth_key(app_id: str):
"""
Update the Tailscale auth key for an installed gateway.
This is useful when the auth key expires and the gateway needs to re-authenticate.
Body:
{
"auth_key": "tskey-auth-xxx"
}
Returns:
Success status and message.
"""
try:
data = request.get_json()
if not data or "auth_key" not in data:
return jsonify({
"success": False,
"message": "auth_key is required in request body"
}), 400
auth_key = data["auth_key"]
if not auth_key.startswith("tskey-"):
return jsonify({
"success": False,
"message": "Invalid auth key format. Should start with 'tskey-'"
}), 400
result = oci_manager.update_auth_key(app_id, auth_key)
status_code = 200 if result.get("success") else 400
return jsonify(result), status_code
except Exception as e:
logger.error(f"Failed to update auth key: {e}")
return jsonify({
"success": False,
"message": 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
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""
Script Runner System for ProxMenux
Executes bash scripts and provides real-time log streaming with interactive menu support
"""
import os
import sys
import json
import subprocess
import threading
import time
from datetime import datetime
from pathlib import Path
import uuid
class ScriptRunner:
"""Manages script execution with real-time log streaming and menu interactions"""
def __init__(self):
self.active_sessions = {}
self.log_dir = Path("/var/log/proxmenux/scripts")
self.log_dir.mkdir(parents=True, exist_ok=True)
self.interaction_handlers = {}
def create_session(self, script_name):
"""Create a new script execution session"""
session_id = str(uuid.uuid4())[:8]
log_file = self.log_dir / f"{script_name}_{session_id}_{int(time.time())}.log"
self.active_sessions[session_id] = {
'script_name': script_name,
'log_file': str(log_file),
'start_time': datetime.now().isoformat(),
'status': 'initializing',
'process': None,
'exit_code': None,
'pending_interaction': None
}
return session_id
def execute_script(self, script_path, session_id, env_vars=None):
"""Execute a script in web mode with logging"""
if session_id not in self.active_sessions:
return {'success': False, 'error': 'Invalid session ID'}
session = self.active_sessions[session_id]
log_file = session['log_file']
print(f"[DEBUG] execute_script called for session {session_id}", file=sys.stderr, flush=True)
print(f"[DEBUG] Script path: {script_path}", file=sys.stderr, flush=True)
print(f"[DEBUG] Log file: {log_file}", file=sys.stderr, flush=True)
# Prepare environment
env = os.environ.copy()
env['EXECUTION_MODE'] = 'web'
env['LOG_FILE'] = log_file
if env_vars:
env.update(env_vars)
print(f"[DEBUG] Environment variables set: EXECUTION_MODE=web, LOG_FILE={log_file}", file=sys.stderr, flush=True)
# Initialize log file
with open(log_file, 'w') as f:
init_line = json.dumps({
'type': 'init',
'session_id': session_id,
'script': script_path,
'timestamp': int(time.time())
}) + '\n'
f.write(init_line)
print(f"[DEBUG] Wrote init line to log: {init_line.strip()}", file=sys.stderr, flush=True)
try:
# Execute script
session['status'] = 'running'
print(f"[DEBUG] Starting subprocess with /bin/bash {script_path}", file=sys.stderr, flush=True)
process = subprocess.Popen(
['/bin/bash', script_path],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=0 # Unbuffered
)
print(f"[DEBUG] Process started with PID: {process.pid}", file=sys.stderr, flush=True)
session['process'] = process
lines_read = [0] # Lista para compartir entre threads
def monitor_output():
print(f"[DEBUG] monitor_output thread started for session {session_id}", file=sys.stderr, flush=True)
print(f"[DEBUG] Will monitor log file: {log_file}", file=sys.stderr, flush=True)
try:
# Read log file in real-time (similar to tail -f)
last_position = 0
# Wait a moment for script to start writing
time.sleep(0.5)
while process.poll() is None or last_position < os.path.getsize(log_file):
try:
if os.path.exists(log_file):
with open(log_file, 'r') as log_f:
log_f.seek(last_position)
new_lines = log_f.readlines()
for line in new_lines:
decoded_line = line.rstrip()
if decoded_line: # Skip empty lines
lines_read[0] += 1
print(f"[DEBUG] Read line {lines_read[0]} from log: {decoded_line[:100]}...", file=sys.stderr, flush=True)
# Check for interaction requests in the line
if 'WEB_INTERACTION:' in decoded_line:
print(f"[DEBUG] Detected WEB_INTERACTION line: {decoded_line}", file=sys.stderr, flush=True)
session['pending_interaction'] = decoded_line
last_position = log_f.tell()
except Exception as e:
print(f"[DEBUG ERROR] Error reading log file: {e}", file=sys.stderr, flush=True)
time.sleep(0.1) # Poll every 100ms
print(f"[DEBUG] monitor_output thread finished. Total lines read: {lines_read[0]}", file=sys.stderr, flush=True)
except Exception as e:
print(f"[DEBUG ERROR] Exception in monitor_output: {e}", file=sys.stderr, flush=True)
monitor_thread = threading.Thread(target=monitor_output, daemon=False)
monitor_thread.start()
print(f"[DEBUG] Waiting for process to complete...", file=sys.stderr, flush=True)
# Wait for completion
process.wait()
print(f"[DEBUG] Process exited with code: {process.returncode}", file=sys.stderr, flush=True)
monitor_thread.join(timeout=30)
if monitor_thread.is_alive():
print(f"[DEBUG WARNING] monitor_thread still alive after 30s timeout", file=sys.stderr, flush=True)
else:
print(f"[DEBUG] monitor_thread joined successfully", file=sys.stderr, flush=True)
session['exit_code'] = process.returncode
session['status'] = 'completed' if process.returncode == 0 else 'failed'
session['end_time'] = datetime.now().isoformat()
print(f"[DEBUG] Script execution completed. Lines captured: {lines_read[0]}", file=sys.stderr, flush=True)
return {
'success': True,
'session_id': session_id,
'exit_code': process.returncode,
'log_file': log_file
}
except Exception as e:
print(f"[DEBUG ERROR] Exception in execute_script: {e}", file=sys.stderr, flush=True)
session['status'] = 'error'
session['error'] = str(e)
return {
'success': False,
'error': str(e)
}
def get_session_status(self, session_id):
"""Get current status of a script execution session"""
if session_id not in self.active_sessions:
return {'success': False, 'error': 'Session not found'}
session = self.active_sessions[session_id]
return {
'success': True,
'session_id': session_id,
'status': session['status'],
'start_time': session['start_time'],
'script_name': session['script_name'],
'exit_code': session['exit_code'],
'pending_interaction': session.get('pending_interaction')
}
def respond_to_interaction(self, session_id, interaction_id, value):
"""Respond to a script interaction request"""
if session_id not in self.active_sessions:
return {'success': False, 'error': 'Session not found'}
session = self.active_sessions[session_id]
# Write response to file that script is waiting for
response_file = f"/tmp/nvidia_response_{interaction_id}.json"
with open(response_file, 'w') as f:
json.dump({
'interaction_id': interaction_id,
'value': value,
'timestamp': int(time.time())
}, f)
# Clear pending interaction
session['pending_interaction'] = None
return {'success': True}
def stream_logs(self, session_id):
"""Generator that yields log entries as they are written"""
if session_id not in self.active_sessions:
yield json.dumps({'type': 'error', 'message': 'Invalid session ID'})
return
session = self.active_sessions[session_id]
log_file = session['log_file']
# Wait for log file to be created
timeout = 10
start = time.time()
while not os.path.exists(log_file) and (time.time() - start) < timeout:
time.sleep(0.1)
if not os.path.exists(log_file):
yield json.dumps({'type': 'error', 'message': 'Log file not created'})
return
# Stream log file
with open(log_file, 'r') as f:
# Start from beginning
f.seek(0)
while session['status'] in ['initializing', 'running']:
line = f.readline()
if line:
# Try to parse as JSON, yield as-is if not JSON
try:
log_entry = json.loads(line.strip())
yield json.dumps(log_entry)
except json.JSONDecodeError:
yield json.dumps({'type': 'raw', 'message': line.strip()})
else:
time.sleep(0.1)
# Read any remaining lines after completion
for line in f:
try:
log_entry = json.loads(line.strip())
yield json.dumps(log_entry)
except json.JSONDecodeError:
yield json.dumps({'type': 'raw', 'message': line.strip()})
def cleanup_session(self, session_id):
"""Clean up a completed session"""
if session_id in self.active_sessions:
del self.active_sessions[session_id]
return {'success': True}
return {'success': False, 'error': 'Session not found'}
# Global instance
script_runner = ScriptRunner()
+352
View File
@@ -0,0 +1,352 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ProxMenux Security Routes
Flask blueprint for firewall management and security tool detection.
"""
from flask import Blueprint, jsonify, request
security_bp = Blueprint('security', __name__)
try:
import security_manager
except ImportError:
security_manager = None
# -------------------------------------------------------------------
# Proxmox Firewall
# -------------------------------------------------------------------
@security_bp.route('/api/security/firewall/status', methods=['GET'])
def firewall_status():
"""Get Proxmox firewall status, rules, and port 8008 status"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
status = security_manager.get_firewall_status()
return jsonify({"success": True, **status})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/firewall/enable', methods=['POST'])
def firewall_enable():
"""Enable Proxmox firewall at host or cluster level"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
data = request.json or {}
level = data.get("level", "host")
success, message = security_manager.enable_firewall(level)
return jsonify({"success": success, "message": message})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/firewall/disable', methods=['POST'])
def firewall_disable():
"""Disable Proxmox firewall at host or cluster level"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
data = request.json or {}
level = data.get("level", "host")
success, message = security_manager.disable_firewall(level)
return jsonify({"success": success, "message": message})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/firewall/rules', methods=['POST'])
def firewall_add_rule():
"""Add a custom firewall rule"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
data = request.json or {}
success, message = security_manager.add_firewall_rule(
direction=data.get("direction", "IN"),
action=data.get("action", "ACCEPT"),
protocol=data.get("protocol", "tcp"),
dport=data.get("dport", ""),
sport=data.get("sport", ""),
source=data.get("source", ""),
dest=data.get("dest", ""),
iface=data.get("iface", ""),
comment=data.get("comment", ""),
level=data.get("level", "host"),
)
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/firewall/rules', methods=['DELETE'])
def firewall_delete_rule():
"""Delete a firewall rule by index"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
data = request.json or {}
rule_index = data.get("rule_index")
level = data.get("level", "host")
if rule_index is None:
return jsonify({"success": False, "message": "rule_index is required"}), 400
success, message = security_manager.delete_firewall_rule(int(rule_index), level)
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/firewall/rules/edit', methods=['PUT'])
def firewall_edit_rule():
"""Edit an existing firewall rule (delete old + insert new at same position)"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
data = request.json or {}
rule_index = data.get("rule_index")
level = data.get("level", "host")
new_rule = data.get("new_rule", {})
if rule_index is None:
return jsonify({"success": False, "message": "rule_index is required"}), 400
success, message = security_manager.edit_firewall_rule(
rule_index=int(rule_index),
level=level,
direction=new_rule.get("direction", "IN"),
action=new_rule.get("action", "ACCEPT"),
protocol=new_rule.get("protocol", "tcp"),
dport=new_rule.get("dport", ""),
sport=new_rule.get("sport", ""),
source=new_rule.get("source", ""),
iface=new_rule.get("iface", ""),
comment=new_rule.get("comment", ""),
)
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/firewall/monitor-port', methods=['POST'])
def firewall_add_monitor_port():
"""Add firewall rule to allow port 8008 for ProxMenux Monitor"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
success, message = security_manager.add_monitor_port_rule()
return jsonify({"success": success, "message": message})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/firewall/monitor-port', methods=['DELETE'])
def firewall_remove_monitor_port():
"""Remove the ProxMenux Monitor port 8008 rule"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
success, message = security_manager.remove_monitor_port_rule()
return jsonify({"success": success, "message": message})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
# -------------------------------------------------------------------
# Fail2Ban Detailed Management
# -------------------------------------------------------------------
@security_bp.route('/api/security/fail2ban/details', methods=['GET'])
def fail2ban_details():
"""Get detailed Fail2Ban info: per-jail banned IPs, stats, config"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
details = security_manager.get_fail2ban_details()
return jsonify({"success": True, **details})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/fail2ban/unban', methods=['POST'])
def fail2ban_unban():
"""Unban a specific IP from a Fail2Ban jail"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
data = request.json or {}
jail = data.get("jail", "")
ip = data.get("ip", "")
success, message = security_manager.unban_ip(jail, ip)
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/fail2ban/jail/config', methods=['PUT'])
def fail2ban_jail_config():
"""Update jail configuration (maxretry, bantime, findtime)"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
data = request.json or {}
jail = data.get("jail", "")
if not jail:
return jsonify({"success": False, "message": "Jail name is required"}), 400
success, message = security_manager.update_jail_config(
jail,
maxretry=data.get("maxretry"),
bantime=data.get("bantime"),
findtime=data.get("findtime"),
)
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/fail2ban/apply-jails', methods=['POST'])
def fail2ban_apply_jails():
"""Apply missing Fail2Ban jails (proxmox, proxmenux)"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
success, message, applied = security_manager.apply_missing_jails()
return jsonify({"success": success, "message": message, "applied": applied})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/fail2ban/activity', methods=['GET'])
def fail2ban_activity():
"""Get recent Fail2Ban log activity"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
events = security_manager.get_fail2ban_recent_activity()
return jsonify({"success": True, "events": events})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
# -------------------------------------------------------------------
# Lynis Audit
# -------------------------------------------------------------------
@security_bp.route('/api/security/lynis/run', methods=['POST'])
def lynis_run_audit():
"""Start a Lynis audit (runs in background)"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
success, message = security_manager.run_lynis_audit()
return jsonify({"success": success, "message": message})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/lynis/status', methods=['GET'])
def lynis_audit_status():
"""Get Lynis audit running status"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
status = security_manager.get_lynis_audit_status()
return jsonify({"success": True, **status})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/lynis/report', methods=['GET'])
def lynis_report():
"""Get parsed Lynis audit report"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
report = security_manager.parse_lynis_report()
if report:
return jsonify({"success": True, "report": report})
else:
return jsonify({"success": False, "message": "No report available. Run an audit first."})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/lynis/report', methods=['DELETE'])
def lynis_report_delete():
"""Delete Lynis audit report files"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
import os
deleted = []
for f in ["/var/log/lynis-report.dat", "/var/log/lynis.log", "/var/log/lynis-output.log"]:
if os.path.isfile(f):
os.remove(f)
deleted.append(f)
if deleted:
return jsonify({"success": True, "message": f"Deleted: {', '.join(deleted)}"})
else:
return jsonify({"success": False, "message": "No report files found to delete"})
except Exception as e:
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
# -------------------------------------------------------------------
@security_bp.route('/api/security/tools', methods=['GET'])
def security_tools():
"""Detect installed security tools (Fail2Ban, Lynis, etc.)"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
tools = security_manager.detect_security_tools()
return jsonify({"success": True, "tools": tools})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
File diff suppressed because it is too large Load Diff
+477
View File
@@ -0,0 +1,477 @@
#!/usr/bin/env python3
"""
ProxMenux Terminal WebSocket Routes
Provides a WebSocket endpoint for interactive terminal sessions
"""
from flask import Blueprint, jsonify, request
from flask_sock import Sock
import subprocess
import os
import pty
import select
import struct
import fcntl
import termios
import threading
import time
import requests
import json
import tempfile
import base64
terminal_bp = Blueprint('terminal', __name__)
sock = Sock()
# Active terminal sessions
active_sessions = {}
@terminal_bp.route('/api/terminal/health', methods=['GET'])
def terminal_health():
"""Health check for terminal service"""
return {'success': True, 'active_sessions': len(active_sessions)}
@terminal_bp.route('/api/terminal/search-command', methods=['GET'])
def search_command():
"""Proxy endpoint for cheat.sh API to avoid CORS issues"""
query = request.args.get('q', '')
if not query or len(query) < 2:
return jsonify({'error': 'Query too short'}), 400
try:
url = f'https://cht.sh/{query.replace(" ", "+")}?QT'
headers = {
'User-Agent': 'curl/7.68.0'
}
response = requests.get(url, headers=headers, timeout=10)
if response.status_code == 200:
content = response.text
examples = []
current_description = []
for line in content.split('\n'):
stripped = line.strip()
# Ignorar líneas vacías
if not stripped:
continue
# Si es un comentario
if stripped.startswith('#'):
# Acumular descripciones
current_description.append(stripped[1:].strip())
# Si no es comentario, es un comando
elif stripped and not stripped.startswith('http'):
# Unir las descripciones acumuladas
description = ' '.join(current_description) if current_description else ''
examples.append({
'description': description,
'command': stripped
})
# Resetear descripciones para el siguiente comando
current_description = []
return jsonify({
'success': True,
'examples': examples
})
else:
return jsonify({
'success': False,
'error': f'API returned status {response.status_code}'
}), response.status_code
except requests.Timeout:
return jsonify({
'success': False,
'error': 'Request timeout'
}), 504
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
def set_winsize(fd, rows, cols):
"""Set terminal window size"""
try:
winsize = struct.pack('HHHH', rows, cols, 0, 0)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
except Exception as e:
print(f"Error setting window size: {e}")
def read_and_forward_output(master_fd, ws):
"""Read from PTY and send to WebSocket"""
while True:
try:
# Use select with timeout to check if data is available
r, _, _ = select.select([master_fd], [], [], 0.01)
if master_fd in r:
try:
data = os.read(master_fd, 4096)
if data:
ws.send(data.decode('utf-8', errors='ignore'))
else:
break
except OSError:
break
except Exception as e:
print(f"Error reading from PTY: {e}")
break
@sock.route('/ws/terminal')
def terminal_websocket(ws):
"""WebSocket endpoint for terminal sessions"""
# Create pseudo-terminal
master_fd, slave_fd = pty.openpty()
# Start bash process
shell_process = subprocess.Popen(
['/bin/bash', '-i'],
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
preexec_fn=os.setsid,
cwd='/',
env=dict(os.environ, TERM='xterm-256color', PS1='\\u@\\h:\\w\\$ ')
)
session_id = id(ws)
active_sessions[session_id] = {
'process': shell_process,
'master_fd': master_fd
}
# Set non-blocking mode for master_fd
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
# Set initial terminal size
set_winsize(master_fd, 30, 120)
# Start thread to read PTY output and forward to WebSocket
output_thread = threading.Thread(
target=read_and_forward_output,
args=(master_fd, ws),
daemon=True
)
output_thread.start()
try:
while True:
# Receive data from WebSocket (blocking)
data = ws.receive(timeout=None)
if data is None:
# Client closed connection
break
handled = False
# Try to handle JSON control messages (e.g. resize)
if isinstance(data, str):
try:
msg = json.loads(data)
except Exception:
msg = None
if isinstance(msg, dict):
msg_type = msg.get('type')
# Handle ping messages (heartbeat to keep connection alive)
if msg_type == 'ping':
try:
ws.send(json.dumps({'type': 'pong'}))
except:
pass
handled = True
# Handle resize messages
elif msg_type == 'resize':
cols = int(msg.get('cols', 120))
rows = int(msg.get('rows', 30))
set_winsize(master_fd, rows, cols)
handled = True
if handled:
# Control message processed, do not send to bash
continue
# Optional: legacy resize escape sequence support
if isinstance(data, str) and data.startswith('\x1b[8;'):
try:
parts = data[4:-1].split(';')
rows, cols = int(parts[0]), int(parts[1])
set_winsize(master_fd, rows, cols)
continue
except Exception:
pass
# Send input to bash
try:
os.write(master_fd, data.encode('utf-8'))
except OSError as e:
print(f"Error writing to PTY: {e}")
break
# Check if process is still alive
if shell_process.poll() is not None:
break
except Exception as e:
print(f"Terminal session error: {e}")
finally:
# Cleanup
try:
shell_process.terminate()
shell_process.wait(timeout=1)
except:
try:
shell_process.kill()
except:
pass
try:
os.close(master_fd)
except:
pass
try:
os.close(slave_fd)
except:
pass
if session_id in active_sessions:
del active_sessions[session_id]
@sock.route('/ws/script/<session_id>')
def script_websocket(ws, session_id):
"""WebSocket endpoint for executing scripts with hybrid web mode"""
try:
init_data = ws.receive(timeout=10)
if not init_data:
error_msg = '{"type": "error", "message": "No script data received"}\r\n'
ws.send(error_msg)
return
script_data = json.loads(init_data)
script_path = script_data.get('script_path')
params = script_data.get('params', {})
if not script_path:
error_msg = '{"type": "error", "message": "No script_path provided"}\r\n'
ws.send(error_msg)
return
if not os.path.exists(script_path):
error_msg = f'{{"type": "error", "message": "Script not found: {script_path}"}}\r\n'
ws.send(error_msg)
return
except Exception as e:
error_msg = f'{{"type": "error", "message": "Invalid init data: {str(e)}"}}\r\n'
ws.send(error_msg)
return
web_log_fd, web_log_path = tempfile.mkstemp(suffix='.log', prefix='proxmenux_web_')
# Create pseudo-terminal for script execution
master_fd, slave_fd = pty.openpty()
env = os.environ.copy()
env['EXECUTION_MODE'] = 'web'
env['WEB_LOG'] = web_log_path
for key, value in params.items():
env[key] = str(value)
env['PYTHONUNBUFFERED'] = '1'
env['TERM'] = 'xterm-256color'
script_process = subprocess.Popen(
['/bin/bash', script_path],
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
preexec_fn=os.setsid,
env=env
)
# Set non-blocking mode for master_fd
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
# Set terminal size
set_winsize(master_fd, 30, 120)
def monitor_web_log():
last_position = 0
while script_process.poll() is None:
try:
if os.path.exists(web_log_path):
with open(web_log_path, 'r') as f:
f.seek(last_position)
new_lines = f.readlines()
last_position = f.tell()
for line in new_lines:
line = line.strip()
if line.startswith('WEB_INTERACTION:'):
try:
# Parse: WEB_INTERACTION:type:id:title_b64:message_b64[:options_json]
parts = line[16:].split(':', 4)
interaction_type = parts[0]
interaction_id = parts[1]
title_b64 = parts[2]
message_b64 = parts[3]
title = base64.b64decode(title_b64).decode('utf-8')
message = base64.b64decode(message_b64).decode('utf-8')
interaction_data = {
'type': 'web_interaction',
'interaction': {
'type': interaction_type,
'id': interaction_id,
'title': title,
'message': message
}
}
# Parse options for menu
if interaction_type == 'menu' and len(parts) > 4:
options_json = parts[4]
interaction_data['interaction']['options'] = json.loads(options_json)
# Parse default for inputbox
if interaction_type == 'inputbox' and len(parts) > 4:
default_b64 = parts[4]
interaction_data['interaction']['default'] = base64.b64decode(default_b64).decode('utf-8')
# Send interaction to WebSocket
ws.send(json.dumps(interaction_data))
except Exception as e:
pass
time.sleep(0.01)
except Exception as e:
break
web_log_thread = threading.Thread(target=monitor_web_log, daemon=True)
web_log_thread.start()
# Thread to read script output and forward to WebSocket
def read_script_output():
while True:
try:
r, _, _ = select.select([master_fd], [], [], 0.01)
if master_fd in r:
try:
data = os.read(master_fd, 4096)
if not data:
break
text = data.decode('utf-8', errors='ignore')
# Send raw text to terminal
try:
ws.send(text)
except Exception as e:
break
except OSError as e:
break
except Exception as e:
break
script_process.wait()
exit_code = script_process.returncode if script_process.returncode is not None else 0
try:
ws.send(f'\r\n[Script exited with code {exit_code}]\r\n')
except Exception as e:
pass
output_thread = threading.Thread(target=read_script_output, daemon=True)
output_thread.start()
try:
while True:
data = ws.receive(timeout=None)
if data is None:
break
try:
msg = json.loads(data)
if msg.get('type') == 'interaction_response':
interaction_id = msg.get('id')
value = msg.get('value')
# Write response to the file the script is waiting for
response_file = f"/tmp/proxmenux_response_{interaction_id}"
with open(response_file, 'w') as f:
f.write(value)
continue
# Handle resize
if msg.get('type') == 'resize':
cols = int(msg.get('cols', 120))
rows = int(msg.get('rows', 30))
set_winsize(master_fd, rows, cols)
continue
except json.JSONDecodeError:
# Raw text input, send to script
try:
os.write(master_fd, data.encode('utf-8'))
except OSError as e:
break
if script_process.poll() is not None:
break
except Exception as e:
pass
finally:
try:
script_process.terminate()
script_process.wait(timeout=1)
except:
try:
script_process.kill()
except:
pass
try:
os.close(master_fd)
except:
pass
try:
os.close(slave_fd)
except:
pass
try:
os.close(web_log_fd)
os.unlink(web_log_path)
except:
pass
def init_terminal_routes(app):
"""Initialize terminal routes with Flask app"""
sock.init_app(app)
app.register_blueprint(terminal_bp)
+390 -346
View File
@@ -1,369 +1,413 @@
#!/usr/bin/env python3
import json
"""
Hardware Monitor - RAPL Power Monitoring and GPU Identification
This module provides:
1. CPU power consumption monitoring using Intel RAPL (Running Average Power Limit)
2. PCI GPU identification for better fan labeling
3. HBA controller detection and temperature monitoring
Only contains these specialized functions - all other hardware monitoring
is handled by flask_server.py to avoid code duplication.
"""
import os
import time
import subprocess
import re
import os
from typing import Dict, List, Any, Optional
from typing import Dict, Any, Optional
def run_command(cmd: List[str]) -> str:
"""Run a command and return its output."""
# Global variable to store previous energy reading for power calculation
_last_energy_reading = {'energy_uj': None, 'timestamp': None}
def get_pci_gpu_map() -> Dict[str, Dict[str, str]]:
"""
Get a mapping of PCI addresses to GPU names from lspci.
This function parses lspci output to identify GPU models by their PCI addresses,
which allows us to provide meaningful names for GPU fans in sensors output.
Returns:
dict: Mapping of PCI addresses (e.g., '02:00.0') to GPU info
Example: {
'02:00.0': {
'vendor': 'NVIDIA',
'name': 'GeForce GTX 1080',
'full_name': 'NVIDIA Corporation GP104 [GeForce GTX 1080]'
}
}
"""
gpu_map = {}
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
return result.stdout
# Run lspci to get VGA/3D/Display controllers
result = subprocess.run(
['lspci', '-nn'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
for line in result.stdout.split('\n'):
if 'VGA compatible controller' in line or '3D controller' in line or 'Display controller' in line:
# Example line: "02:00.0 VGA compatible controller [0300]: NVIDIA Corporation GP104 [GeForce GTX 1080] [10de:1b80]"
match = re.match(r'^([0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f])\s+.*:\s+(.+?)\s+\[([0-9a-f]{4}):([0-9a-f]{4})\]', line)
if match:
pci_address = match.group(1)
device_name = match.group(2).strip()
# Extract vendor
vendor = None
if 'NVIDIA' in device_name.upper() or 'GEFORCE' in device_name.upper() or 'QUADRO' in device_name.upper():
vendor = 'NVIDIA'
elif 'AMD' in device_name.upper() or 'RADEON' in device_name.upper():
vendor = 'AMD'
elif 'INTEL' in device_name.upper() or 'ARC' in device_name.upper():
vendor = 'Intel'
# Extract model name (text between brackets is usually the commercial name)
bracket_match = re.search(r'\[([^\]]+)\]', device_name)
if bracket_match:
model_name = bracket_match.group(1)
else:
# Fallback: use everything after the vendor name
if vendor:
model_name = device_name.split(vendor)[-1].strip()
else:
model_name = device_name
gpu_map[pci_address] = {
'vendor': vendor if vendor else 'Unknown',
'name': model_name,
'full_name': device_name
}
except Exception:
return ""
pass
return gpu_map
def get_nvidia_gpu_info() -> List[Dict[str, Any]]:
"""Get detailed NVIDIA GPU information using nvidia-smi."""
gpus = []
# Check if nvidia-smi is available
if not os.path.exists('/usr/bin/nvidia-smi'):
return gpus
try:
# Query all GPU metrics at once
query_fields = [
'index',
'name',
'driver_version',
'memory.total',
'memory.used',
'memory.free',
'temperature.gpu',
'utilization.gpu',
'utilization.memory',
'power.draw',
'power.limit',
'clocks.current.graphics',
'clocks.current.memory',
'pcie.link.gen.current',
'pcie.link.width.current'
]
cmd = ['nvidia-smi', '--query-gpu=' + ','.join(query_fields), '--format=csv,noheader,nounits']
output = run_command(cmd)
if not output:
return gpus
for line in output.strip().split('\n'):
if not line:
continue
values = [v.strip() for v in line.split(',')]
if len(values) < len(query_fields):
continue
gpu_info = {
'index': values[0],
'name': values[1],
'driver_version': values[2],
'memory_total': f"{values[3]} MiB",
'memory_used': f"{values[4]} MiB",
'memory_free': f"{values[5]} MiB",
'temperature': values[6],
'utilization_gpu': values[7],
'utilization_memory': values[8],
'power_draw': f"{values[9]} W",
'power_limit': f"{values[10]} W",
'clock_graphics': f"{values[11]} MHz",
'clock_memory': f"{values[12]} MHz",
'pcie_gen': values[13],
'pcie_width': f"x{values[14]}"
}
# Get CUDA version if available
cuda_output = run_command(['nvidia-smi', '--query-gpu=compute_cap', '--format=csv,noheader', '-i', values[0]])
if cuda_output:
gpu_info['compute_capability'] = cuda_output.strip()
gpus.append(gpu_info)
except Exception as e:
print(f"Error getting NVIDIA GPU info: {e}", file=sys.stderr)
return gpus
def get_amd_gpu_info() -> List[Dict[str, Any]]:
"""Get AMD GPU information using rocm-smi."""
gpus = []
# Check if rocm-smi is available
if not os.path.exists('/opt/rocm/bin/rocm-smi'):
return gpus
try:
# Get basic GPU info
output = run_command(['/opt/rocm/bin/rocm-smi', '--showid', '--showtemp', '--showuse', '--showmeminfo', 'vram'])
if not output:
return gpus
# Parse rocm-smi output (format varies, this is a basic parser)
current_gpu = None
for line in output.split('\n'):
if 'GPU[' in line:
if current_gpu:
gpus.append(current_gpu)
current_gpu = {'index': line.split('[')[1].split(']')[0]}
elif current_gpu:
if 'Temperature' in line:
temp_match = re.search(r'(\d+\.?\d*)', line)
if temp_match:
current_gpu['temperature'] = temp_match.group(1)
elif 'GPU use' in line:
use_match = re.search(r'(\d+)%', line)
if use_match:
current_gpu['utilization_gpu'] = use_match.group(1)
elif 'VRAM' in line:
mem_match = re.search(r'(\d+)MB / (\d+)MB', line)
if mem_match:
current_gpu['memory_used'] = f"{mem_match.group(1)} MiB"
current_gpu['memory_total'] = f"{mem_match.group(2)} MiB"
if current_gpu:
gpus.append(current_gpu)
except Exception as e:
print(f"Error getting AMD GPU info: {e}", file=sys.stderr)
return gpus
def get_temperatures() -> List[Dict[str, Any]]:
"""Get temperature readings from sensors."""
temps = []
output = run_command(['sensors', '-A', '-u'])
current_adapter = None
current_sensor = None
for line in output.split('\n'):
line = line.strip()
if not line:
continue
if line.endswith(':') and not line.startswith(' '):
current_adapter = line[:-1]
elif '_input:' in line and current_adapter:
parts = line.split(':')
if len(parts) == 2:
sensor_name = parts[0].replace('_input', '').replace('_', ' ').title()
try:
temp_value = float(parts[1].strip())
temps.append({
'name': sensor_name,
'current': round(temp_value, 1),
'adapter': current_adapter
})
except ValueError:
pass
return temps
def get_fans() -> List[Dict[str, Any]]:
"""Get fan speed readings."""
fans = []
output = run_command(['sensors', '-A', '-u'])
current_adapter = None
for line in output.split('\n'):
line = line.strip()
if not line:
continue
if line.endswith(':') and not line.startswith(' '):
current_adapter = line[:-1]
elif 'fan' in line.lower() and '_input:' in line and current_adapter:
parts = line.split(':')
if len(parts) == 2:
fan_name = parts[0].replace('_input', '').replace('_', ' ').title()
try:
speed = float(parts[1].strip())
fans.append({
'name': fan_name,
'speed': int(speed),
'unit': 'RPM'
})
except ValueError:
pass
return fans
def get_network_cards() -> List[Dict[str, Any]]:
"""Get network interface information."""
cards = []
output = run_command(['ip', '-o', 'link', 'show'])
for line in output.split('\n'):
if not line or 'lo:' in line:
continue
parts = line.split()
if len(parts) >= 2:
name = parts[1].rstrip(':')
state = 'UP' if 'UP' in line else 'DOWN'
# Get interface type
iface_type = 'Unknown'
if 'ether' in line:
iface_type = 'Ethernet'
elif 'wlan' in name or 'wifi' in name:
iface_type = 'WiFi'
# Try to get speed
speed = None
speed_output = run_command(['ethtool', name])
speed_match = re.search(r'Speed: (\d+\w+)', speed_output)
if speed_match:
speed = speed_match.group(1)
cards.append({
'name': name,
'type': iface_type,
'status': state,
'speed': speed
})
return cards
def get_storage_devices() -> List[Dict[str, Any]]:
"""Get storage device information."""
devices = []
output = run_command(['lsblk', '-d', '-o', 'NAME,TYPE,SIZE,MODEL', '-n'])
for line in output.split('\n'):
if not line:
continue
parts = line.split(None, 3)
if len(parts) >= 3:
name = parts[0]
dev_type = parts[1]
size = parts[2]
model = parts[3] if len(parts) > 3 else 'Unknown'
if dev_type in ['disk', 'nvme']:
devices.append({
'name': name,
'type': dev_type,
'size': size,
'model': model.strip()
})
return devices
def get_pci_devices() -> List[Dict[str, Any]]:
"""Get PCI device information including GPUs."""
devices = []
output = run_command(['lspci', '-vmm'])
current_device = {}
for line in output.split('\n'):
line = line.strip()
if not line:
if current_device:
devices.append(current_device)
current_device = {}
continue
if ':' in line:
key, value = line.split(':', 1)
key = key.strip().lower().replace(' ', '_')
value = value.strip()
current_device[key] = value
if current_device:
devices.append(current_device)
# Enhance GPU devices with monitoring data
nvidia_gpus = get_nvidia_gpu_info()
amd_gpus = get_amd_gpu_info()
nvidia_idx = 0
amd_idx = 0
for device in devices:
# Check if it's a GPU
device_class = device.get('class', '').lower()
vendor = device.get('vendor', '').lower()
if 'vga' in device_class or 'display' in device_class or '3d' in device_class:
device['type'] = 'GPU'
# Add NVIDIA GPU monitoring data
if 'nvidia' in vendor and nvidia_idx < len(nvidia_gpus):
gpu_data = nvidia_gpus[nvidia_idx]
device['gpu_memory'] = gpu_data.get('memory_total')
device['gpu_driver_version'] = gpu_data.get('driver_version')
device['gpu_compute_capability'] = gpu_data.get('compute_capability')
device['gpu_power_draw'] = gpu_data.get('power_draw')
device['gpu_temperature'] = float(gpu_data.get('temperature', 0))
device['gpu_utilization'] = float(gpu_data.get('utilization_gpu', 0))
device['gpu_memory_used'] = gpu_data.get('memory_used')
device['gpu_memory_total'] = gpu_data.get('memory_total')
device['gpu_clock_speed'] = gpu_data.get('clock_graphics')
device['gpu_memory_clock'] = gpu_data.get('clock_memory')
nvidia_idx += 1
# Add AMD GPU monitoring data
elif 'amd' in vendor and amd_idx < len(amd_gpus):
gpu_data = amd_gpus[amd_idx]
device['gpu_temperature'] = float(gpu_data.get('temperature', 0))
device['gpu_utilization'] = float(gpu_data.get('utilization_gpu', 0))
device['gpu_memory_used'] = gpu_data.get('memory_used')
device['gpu_memory_total'] = gpu_data.get('memory_total')
amd_idx += 1
elif 'network' in device_class or 'ethernet' in device_class:
device['type'] = 'Network'
elif 'storage' in device_class or 'sata' in device_class or 'nvme' in device_class:
device['type'] = 'Storage'
else:
device['type'] = 'Other'
return devices
def get_power_info() -> Optional[Dict[str, Any]]:
"""Get power consumption information if available."""
# Try to get system power from RAPL (Running Average Power Limit)
"""
Get CPU power consumption using Intel RAPL interface.
This function measures power consumption by reading energy counters
from /sys/class/powercap/intel-rapl interfaces and calculating
the power draw based on the change in energy over time.
Used as fallback when IPMI power monitoring is not available.
Returns:
dict: Power meter information with 'name', 'watts', and 'adapter' keys
or None if RAPL interface is unavailable
Example:
{
'name': 'CPU Power',
'watts': 45.32,
'adapter': 'Intel RAPL (CPU only)'
}
"""
global _last_energy_reading
rapl_path = '/sys/class/powercap/intel-rapl/intel-rapl:0/energy_uj'
if os.path.exists(rapl_path):
try:
# Read current energy value in microjoules
with open(rapl_path, 'r') as f:
energy_uj = int(f.read().strip())
current_energy_uj = int(f.read().strip())
current_time = time.time()
watts = 0.0
# Calculate power if we have a previous reading
if _last_energy_reading['energy_uj'] is not None and _last_energy_reading['timestamp'] is not None:
time_diff = current_time - _last_energy_reading['timestamp']
if time_diff > 0:
energy_diff = current_energy_uj - _last_energy_reading['energy_uj']
# Handle counter overflow (wraps around at max value)
if energy_diff < 0:
energy_diff = current_energy_uj
# Power (W) = Energy (µJ) / time (s) / 1,000,000
watts = round((energy_diff / time_diff) / 1000000, 2)
# Store current reading for next calculation
_last_energy_reading['energy_uj'] = current_energy_uj
_last_energy_reading['timestamp'] = current_time
# Detect CPU vendor for display purposes
cpu_vendor = 'CPU'
try:
with open('/proc/cpuinfo', 'r') as f:
cpuinfo = f.read()
if 'GenuineIntel' in cpuinfo:
cpu_vendor = 'Intel'
elif 'AuthenticAMD' in cpuinfo:
cpu_vendor = 'AMD'
except:
pass
# This is cumulative energy, would need to track over time for watts
# For now, just indicate power monitoring is available
return {
'name': 'System Power',
'watts': 0, # Would need time-based calculation
'adapter': 'RAPL'
'name': 'CPU Power',
'watts': watts,
'adapter': f'{cpu_vendor} RAPL (CPU only)'
}
except Exception:
pass
return None
def main():
"""Main function to gather all hardware information."""
data = {
'temperatures': get_temperatures(),
'fans': get_fans(),
'network_cards': get_network_cards(),
'storage_devices': get_storage_devices(),
'pci_devices': get_pci_devices(),
}
power_info = get_power_info()
if power_info:
data['power_meter'] = power_info
print(json.dumps(data, indent=2))
if __name__ == '__main__':
import sys
main()
def get_hba_info() -> list[Dict[str, Any]]:
"""
Detect HBA/RAID controllers from lspci.
This function identifies LSI/Broadcom, Adaptec, and other RAID/HBA controllers
present in the system via lspci output.
Returns:
list: List of HBA controller dictionaries
Example: [
{
'pci_address': '01:00.0',
'vendor': 'LSI/Broadcom',
'model': 'SAS3008 PCI-Express Fusion-MPT SAS-3',
'controller_id': 0
}
]
"""
hba_list = []
try:
# Run lspci to find RAID/SAS controllers
result = subprocess.run(
['lspci', '-nn'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
controller_id = 0
for line in result.stdout.split('\n'):
# Look for RAID bus controller, SCSI storage controller, Serial Attached SCSI controller
if any(keyword in line for keyword in ['RAID bus controller', 'SCSI storage controller', 'Serial Attached SCSI']):
# Example: "01:00.0 RAID bus controller [0104]: Broadcom / LSI SAS3008 PCI-Express Fusion-MPT SAS-3 [1000:0097]"
match = re.match(r'^([0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f])\s+.*:\s+(.+?)\s+\[([0-9a-f]{4}):([0-9a-f]{4})\]', line)
if match:
pci_address = match.group(1)
device_name = match.group(2).strip()
# Extract vendor
vendor = 'Unknown'
if 'LSI' in device_name.upper() or 'BROADCOM' in device_name.upper() or 'AVAGO' in device_name.upper():
vendor = 'LSI/Broadcom'
elif 'ADAPTEC' in device_name.upper():
vendor = 'Adaptec'
elif 'ARECA' in device_name.upper():
vendor = 'Areca'
elif 'HIGHPOINT' in device_name.upper():
vendor = 'HighPoint'
elif 'DELL' in device_name.upper():
vendor = 'Dell'
elif 'HP' in device_name.upper() or 'HEWLETT' in device_name.upper():
vendor = 'HP'
# Extract model name
model_name = device_name
# Remove vendor prefix if present
for v in ['Broadcom / LSI', 'Broadcom', 'LSI Logic', 'LSI', 'Adaptec', 'Areca', 'HighPoint', 'Dell', 'HP', 'Hewlett-Packard']:
if model_name.startswith(v):
model_name = model_name[len(v):].strip()
hba_list.append({
'pci_address': pci_address,
'vendor': vendor,
'model': model_name,
'controller_id': controller_id,
'full_name': device_name
})
controller_id += 1
except Exception:
pass
return hba_list
def get_hba_temperatures() -> list[Dict[str, Any]]:
"""
Get HBA controller temperatures using storcli64 or megacli.
This function attempts to read temperature data from LSI/Broadcom RAID controllers
using the storcli64 tool (preferred) or megacli as fallback.
Returns:
list: List of temperature dictionaries
Example: [
{
'name': 'HBA Controller 0',
'temperature': 65,
'adapter': 'LSI/Broadcom SAS3008'
}
]
"""
temperatures = []
# Check which tool is available
storcli_paths = [
'/opt/MegaRAID/storcli/storcli64',
'/usr/sbin/storcli64',
'/usr/local/sbin/storcli64',
'storcli64'
]
megacli_paths = [
'/opt/MegaRAID/MegaCli/MegaCli64',
'/usr/sbin/megacli',
'/usr/local/sbin/megacli',
'megacli'
]
storcli_path = None
megacli_path = None
# Find storcli64
for path in storcli_paths:
try:
result = subprocess.run([path, '-v'], capture_output=True, timeout=2)
if result.returncode == 0:
storcli_path = path
break
except:
continue
# Try storcli64 first (preferred)
if storcli_path:
try:
# Get list of controllers
result = subprocess.run(
[storcli_path, 'show'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
# Parse controller IDs
controller_ids = []
for line in result.stdout.split('\n'):
match = re.search(r'^\s*(\d+)\s+', line)
if match and 'Ctl' in line:
controller_ids.append(match.group(1))
# Get temperature for each controller
for ctrl_id in controller_ids:
try:
temp_result = subprocess.run(
[storcli_path, f'/c{ctrl_id}', 'show', 'temperature'],
capture_output=True,
text=True,
timeout=10
)
if temp_result.returncode == 0:
# Parse temperature from output
for line in temp_result.stdout.split('\n'):
if 'ROC temperature' in line or 'Controller Temp' in line:
temp_match = re.search(r'(\d+)\s*C', line)
if temp_match:
temp_c = int(temp_match.group(1))
# Get HBA info for better naming
hba_list = get_hba_info()
adapter_name = 'LSI/Broadcom Controller'
if int(ctrl_id) < len(hba_list):
hba = hba_list[int(ctrl_id)]
adapter_name = f"{hba['vendor']} {hba['model']}"
temperatures.append({
'name': f'HBA Controller {ctrl_id}',
'temperature': temp_c,
'adapter': adapter_name
})
break
except:
continue
except:
pass
# Fallback to megacli if storcli not available
elif not temperatures:
for path in megacli_paths:
try:
result = subprocess.run([path, '-v'], capture_output=True, timeout=2)
if result.returncode == 0:
megacli_path = path
break
except:
continue
if megacli_path:
try:
# Get adapter count
result = subprocess.run(
[megacli_path, '-adpCount'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
# Parse adapter count
adapter_count = 0
for line in result.stdout.split('\n'):
if 'Controller Count' in line:
count_match = re.search(r'(\d+)', line)
if count_match:
adapter_count = int(count_match.group(1))
break
# Get temperature for each adapter
for adapter_id in range(adapter_count):
try:
temp_result = subprocess.run(
[megacli_path, '-AdpAllInfo', f'-a{adapter_id}'],
capture_output=True,
text=True,
timeout=10
)
if temp_result.returncode == 0:
# Parse temperature
for line in temp_result.stdout.split('\n'):
if 'ROC temperature' in line or 'Controller Temp' in line:
temp_match = re.search(r'(\d+)\s*C', line)
if temp_match:
temp_c = int(temp_match.group(1))
# Get HBA info for better naming
hba_list = get_hba_info()
adapter_name = 'LSI/Broadcom Controller'
if adapter_id < len(hba_list):
hba = hba_list[adapter_id]
adapter_name = f"{hba['vendor']} {hba['model']}"
temperatures.append({
'name': f'HBA Controller {adapter_id}',
'temperature': temp_c,
'adapter': adapter_name
})
break
except:
continue
except:
pass
return temperatures
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+929
View File
@@ -0,0 +1,929 @@
"""
ProxMenux Notification Channels
Provides transport adapters for Telegram, Gotify, and Discord.
Each channel implements send() and test() with:
- Retry with exponential backoff (3 attempts)
- Request timeout of 10s
- Rate limiting (max 30 msg/min per channel)
Author: MacRimi
"""
import json
import time
import urllib.request
import urllib.error
import urllib.parse
from abc import ABC, abstractmethod
from collections import deque
from typing import Tuple, Optional, Dict, Any
# ─── Rate Limiter ────────────────────────────────────────────────
class RateLimiter:
"""Token-bucket rate limiter: max N messages per window."""
def __init__(self, max_calls: int = 30, window_seconds: int = 60):
self.max_calls = max_calls
self.window = window_seconds
self._timestamps: deque = deque()
def allow(self) -> bool:
now = time.monotonic()
while self._timestamps and now - self._timestamps[0] > self.window:
self._timestamps.popleft()
if len(self._timestamps) >= self.max_calls:
return False
self._timestamps.append(now)
return True
def wait_time(self) -> float:
if not self._timestamps:
return 0.0
return max(0.0, self.window - (time.monotonic() - self._timestamps[0]))
# ─── Base Channel ────────────────────────────────────────────────
class NotificationChannel(ABC):
"""Abstract base for all notification channels."""
MAX_RETRIES = 3
RETRY_DELAYS = [2, 4, 8] # exponential backoff seconds
REQUEST_TIMEOUT = 10
def __init__(self):
self._rate_limiter = RateLimiter(max_calls=30, window_seconds=60)
@abstractmethod
def send(self, title: str, message: str, severity: str = 'INFO',
data: Optional[Dict] = None) -> Dict[str, Any]:
"""Send a notification. Returns {success, error, channel}."""
pass
@abstractmethod
def test(self) -> Tuple[bool, str]:
"""Send a test message. Returns (success, error_message)."""
pass
@abstractmethod
def validate_config(self) -> Tuple[bool, str]:
"""Check if config is valid without sending. Returns (valid, error)."""
pass
def _http_request(self, url: str, data: bytes, headers: Dict[str, str],
method: str = 'POST') -> Tuple[int, str]:
"""Execute HTTP request with timeout. Returns (status_code, body)."""
# Ensure User-Agent is set to avoid Cloudflare 1010 errors
if 'User-Agent' not in headers:
headers['User-Agent'] = 'ProxMenux-Monitor/1.1'
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=self.REQUEST_TIMEOUT) as resp:
body = resp.read().decode('utf-8', errors='replace')
return resp.status, body
except urllib.error.HTTPError as e:
body = e.read().decode('utf-8', errors='replace') if e.fp else str(e)
return e.code, body
except urllib.error.URLError as e:
return 0, str(e.reason)
except Exception as e:
return 0, str(e)
def _send_with_retry(self, send_fn) -> Dict[str, Any]:
"""Wrap a send function with rate limiting and retry logic."""
if not self._rate_limiter.allow():
wait = self._rate_limiter.wait_time()
return {
'success': False,
'error': f'Rate limited. Retry in {wait:.0f}s',
'rate_limited': True
}
last_error = ''
for attempt in range(self.MAX_RETRIES):
try:
status, body = send_fn()
if 200 <= status < 300:
return {'success': True, 'error': None}
last_error = f'HTTP {status}: {body[:200]}'
except Exception as e:
last_error = str(e)
if attempt < self.MAX_RETRIES - 1:
time.sleep(self.RETRY_DELAYS[attempt])
return {'success': False, 'error': last_error}
# ─── Telegram ────────────────────────────────────────────────────
class TelegramChannel(NotificationChannel):
"""Telegram Bot API channel using HTML parse mode."""
API_BASE = 'https://api.telegram.org/bot{token}/sendMessage'
API_PHOTO = 'https://api.telegram.org/bot{token}/sendPhoto'
MAX_LENGTH = 4096
SEVERITY_ICONS = {
'CRITICAL': '\U0001F534', # red circle
'WARNING': '\U0001F7E1', # yellow circle
'INFO': '\U0001F535', # blue circle
'OK': '\U0001F7E2', # green circle
'UNKNOWN': '\u26AA', # white circle
}
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)
if token.lower().startswith('bot') and ':' in token[3:]:
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:
return False, 'Bot token is required'
if not self.chat_id:
return False, 'Chat ID is required'
if ':' not in self.bot_token:
return False, 'Invalid bot token format (expected BOT_ID:TOKEN)'
return True, ''
def send(self, title: str, message: str, severity: str = 'INFO',
data: Optional[Dict] = None) -> Dict[str, Any]:
icon = self.SEVERITY_ICONS.get(severity, self.SEVERITY_ICONS['INFO'])
html_msg = f"<b>{icon} {self._escape_html(title)}</b>\n\n{self._escape_html(message)}"
# Split long messages
chunks = self._split_message(html_msg)
result = {'success': True, 'error': None, 'channel': 'telegram'}
for chunk in chunks:
res = self._send_with_retry(lambda c=chunk: self._post_message(c))
if not res['success']:
result = {**res, 'channel': 'telegram'}
break
return result
def send_photo(self, photo_url: str, caption: str = '') -> Dict[str, Any]:
"""Send a photo to Telegram chat."""
url = self.API_PHOTO.format(token=self.bot_token)
payload = {
'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'
body = json.dumps(payload).encode()
headers = {'Content-Type': 'application/json'}
result = self._send_with_retry(
lambda: self._http_request(url, body, headers)
)
result['channel'] = 'telegram'
return result
def test(self) -> Tuple[bool, str]:
valid, err = self.validate_config()
if not valid:
return False, err
result = self.send(
'ProxMenux Test',
'Notification service is working correctly.\nThis is a test message from ProxMenux Monitor.',
'INFO'
)
return result['success'], result.get('error', '')
def _post_message(self, text: str) -> Tuple[int, str]:
url = self.API_BASE.format(token=self.bot_token)
payload_dict = {
'chat_id': self.chat_id,
'text': text,
'parse_mode': 'HTML',
'disable_web_page_preview': True,
}
# 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:
if len(text) <= self.MAX_LENGTH:
return [text]
chunks = []
while text:
if len(text) <= self.MAX_LENGTH:
chunks.append(text)
break
split_at = text.rfind('\n', 0, self.MAX_LENGTH)
if split_at == -1:
split_at = self.MAX_LENGTH
chunks.append(text[:split_at])
text = text[split_at:].lstrip('\n')
return chunks
@staticmethod
def _escape_html(text: str) -> str:
return (text
.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;'))
# ─── Gotify ──────────────────────────────────────────────────────
class GotifyChannel(NotificationChannel):
"""Gotify push notification channel with priority mapping."""
PRIORITY_MAP = {
'OK': 1,
'INFO': 2,
'UNKNOWN': 3,
'WARNING': 5,
'CRITICAL': 10,
}
def __init__(self, server_url: str, app_token: str):
super().__init__()
self.server_url = server_url.rstrip('/').strip()
self.app_token = app_token.strip()
def validate_config(self) -> Tuple[bool, str]:
if not self.server_url:
return False, 'Server URL is required'
if not self.app_token:
return False, 'Application token is required'
if not self.server_url.startswith(('http://', 'https://')):
return False, 'Server URL must start with http:// or https://'
return True, ''
def send(self, title: str, message: str, severity: str = 'INFO',
data: Optional[Dict] = None) -> Dict[str, Any]:
priority = self.PRIORITY_MAP.get(severity, 2)
result = self._send_with_retry(
lambda: self._post_message(title, message, priority)
)
result['channel'] = 'gotify'
return result
def test(self) -> Tuple[bool, str]:
valid, err = self.validate_config()
if not valid:
return False, err
result = self.send(
'ProxMenux Test',
'Notification service is working correctly.\nThis is a test message from ProxMenux Monitor.',
'INFO'
)
return result['success'], result.get('error', '')
def _post_message(self, title: str, message: str, priority: int) -> Tuple[int, str]:
url = f"{self.server_url}/message?token={self.app_token}"
payload = json.dumps({
'title': title,
'message': message,
'priority': priority,
'extras': {
'client::display': {'contentType': 'text/markdown'}
}
}).encode('utf-8')
return self._http_request(url, payload, {'Content-Type': 'application/json'})
# ─── Discord ─────────────────────────────────────────────────────
class DiscordChannel(NotificationChannel):
"""Discord webhook channel with color-coded embeds."""
MAX_EMBED_DESC = 2048
SEVERITY_COLORS = {
'CRITICAL': 0xED4245, # red
'WARNING': 0xFEE75C, # yellow
'INFO': 0x5865F2, # blurple
'OK': 0x57F287, # green
'UNKNOWN': 0x99AAB5, # grey
}
def __init__(self, webhook_url: str):
super().__init__()
self.webhook_url = webhook_url.strip()
def validate_config(self) -> Tuple[bool, str]:
if not self.webhook_url:
return False, 'Webhook URL is required'
if 'discord.com/api/webhooks/' not in self.webhook_url:
return False, 'Invalid Discord webhook URL'
return True, ''
def send(self, title: str, message: str, severity: str = 'INFO',
data: Optional[Dict] = None) -> Dict[str, Any]:
color = self.SEVERITY_COLORS.get(severity, 0x5865F2)
desc = message[:self.MAX_EMBED_DESC] if len(message) > self.MAX_EMBED_DESC else message
embed = {
'title': title,
'description': desc,
'color': color,
'footer': {'text': 'ProxMenux Monitor'},
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
}
# Use structured fields from render_template if available
rendered_fields = (data or {}).get('_rendered_fields', [])
if rendered_fields:
embed['fields'] = [
{'name': name, 'value': val[:1024], 'inline': True}
for name, val in rendered_fields[:25] # Discord limit: 25 fields
]
elif data:
fields = []
if data.get('category'):
fields.append({'name': 'Category', 'value': data['category'], 'inline': True})
if data.get('hostname'):
fields.append({'name': 'Host', 'value': data['hostname'], 'inline': True})
if data.get('severity'):
fields.append({'name': 'Severity', 'value': data['severity'], 'inline': True})
if fields:
embed['fields'] = fields
result = self._send_with_retry(
lambda: self._post_webhook(embed)
)
result['channel'] = 'discord'
return result
def test(self) -> Tuple[bool, str]:
valid, err = self.validate_config()
if not valid:
return False, err
result = self.send(
'ProxMenux Test',
'Notification service is working correctly.\nThis is a test message from ProxMenux Monitor.',
'INFO'
)
return result['success'], result.get('error', '')
def _post_webhook(self, embed: Dict) -> Tuple[int, str]:
payload = json.dumps({
'username': 'ProxMenux',
'embeds': [embed]
}).encode('utf-8')
return self._http_request(
self.webhook_url, payload, {'Content-Type': 'application/json'}
)
# ─── Email Channel ──────────────────────────────────────────────
class EmailChannel(NotificationChannel):
"""Email notification channel using SMTP (smtplib) or sendmail fallback.
Config keys:
host, port, username, password, tls_mode (none|starttls|ssl),
from_address, to_addresses (comma-separated), subject_prefix, timeout
"""
def __init__(self, config: Dict[str, str]):
super().__init__()
self.host = config.get('host', '')
self.port = int(config.get('port', 587) or 587)
self.username = config.get('username', '')
self.password = config.get('password', '')
self.tls_mode = config.get('tls_mode', 'starttls') # none | starttls | ssl
self.from_address = config.get('from_address', '')
self.to_addresses = self._parse_recipients(config.get('to_addresses', ''))
self.subject_prefix = config.get('subject_prefix', '[ProxMenux]')
self.timeout = int(config.get('timeout', 10) or 10)
@staticmethod
def _parse_recipients(raw) -> list:
if isinstance(raw, list):
return [a.strip() for a in raw if a.strip()]
return [addr.strip() for addr in str(raw).split(',') if addr.strip()]
def validate_config(self) -> Tuple[bool, str]:
if not self.to_addresses:
return False, 'No recipients configured'
if not self.from_address:
return False, 'No from address configured'
# Must have SMTP host OR local sendmail available
if not self.host:
import os
if not os.path.exists('/usr/sbin/sendmail'):
return False, 'No SMTP host configured and /usr/sbin/sendmail not found'
return True, ''
def send(self, title: str, message: str, severity: str = 'INFO',
data: Optional[Dict] = None) -> Dict[str, Any]:
subject = f"{self.subject_prefix} [{severity}] {title}"
def _do_send():
if self.host:
return self._send_smtp(subject, message, severity, data)
else:
return self._send_sendmail(subject, message, severity, data)
return self._send_with_retry(_do_send)
def _send_smtp(self, subject: str, body: str, severity: str,
data: Optional[Dict] = None) -> Tuple[int, str]:
import smtplib
from email.message import EmailMessage
msg = EmailMessage()
msg['Subject'] = subject
msg['From'] = self.from_address
msg['To'] = ', '.join(self.to_addresses)
msg.set_content(body)
# Add HTML alternative
html_body = self._format_html(subject, body, severity, data)
if html_body:
msg.add_alternative(html_body, subtype='html')
server = None
try:
import ssl as _ssl
if self.tls_mode == 'ssl':
ctx = _ssl.create_default_context()
server = smtplib.SMTP_SSL(self.host, self.port,
timeout=self.timeout, context=ctx)
server.ehlo()
else:
server = smtplib.SMTP(self.host, self.port, timeout=self.timeout)
server.ehlo()
if self.tls_mode == 'starttls':
ctx = _ssl.create_default_context()
server.starttls(context=ctx)
server.ehlo() # Re-identify after TLS -- server re-announces AUTH
if self.username and self.password:
server.login(self.username, self.password)
server.send_message(msg)
server.quit()
server = None
return 200, 'OK'
except smtplib.SMTPAuthenticationError as e:
return 0, f'SMTP authentication failed (check username/password or app-specific password): {e}'
except smtplib.SMTPNotSupportedError as e:
return 0, (f'SMTP AUTH not supported by server. '
f'This may mean the server requires OAuth2 or an App Password '
f'instead of regular credentials: {e}')
except smtplib.SMTPConnectError as e:
return 0, f'SMTP connection failed: {e}'
except smtplib.SMTPException as e:
return 0, f'SMTP error: {e}'
except _ssl.SSLError as e:
return 0, f'TLS/SSL error (check TLS mode and port): {e}'
except (OSError, TimeoutError) as e:
return 0, f'Connection error: {e}'
finally:
if server:
try:
server.quit()
except Exception:
pass
def _send_sendmail(self, subject: str, body: str, severity: str,
data: Optional[Dict] = None) -> Tuple[int, str]:
import os
import subprocess
from email.message import EmailMessage
sendmail = '/usr/sbin/sendmail'
if not os.path.exists(sendmail):
return 0, 'sendmail not found at /usr/sbin/sendmail'
msg = EmailMessage()
msg['Subject'] = subject
msg['From'] = self.from_address or 'proxmenux@localhost'
msg['To'] = ', '.join(self.to_addresses)
msg.set_content(body)
# Add HTML alternative
html_body = self._format_html(subject, body, severity, data)
if html_body:
msg.add_alternative(html_body, subtype='html')
try:
proc = subprocess.run(
[sendmail, '-t', '-oi'],
input=msg.as_string(), capture_output=True, text=True, timeout=30
)
if proc.returncode == 0:
return 200, 'OK'
return 0, f'sendmail failed (rc={proc.returncode}): {proc.stderr[:200]}'
except subprocess.TimeoutExpired:
return 0, 'sendmail timed out after 30s'
except Exception as e:
return 0, f'sendmail error: {e}'
# Severity -> accent colour + label
_SEV_STYLE = {
'CRITICAL': {'color': '#dc2626', 'bg': '#fef2f2', 'border': '#fecaca', 'label': 'Critical'},
'WARNING': {'color': '#d97706', 'bg': '#fffbeb', 'border': '#fde68a', 'label': 'Warning'},
'INFO': {'color': '#2563eb', 'bg': '#eff6ff', 'border': '#bfdbfe', 'label': 'Information'},
'OK': {'color': '#16a34a', 'bg': '#f0fdf4', 'border': '#bbf7d0', 'label': 'Resolved'},
}
_SEV_DEFAULT = {'color': '#6b7280', 'bg': '#f9fafb', 'border': '#e5e7eb', 'label': 'Notice'}
# Group -> human-readable section header for the email
_GROUP_LABELS = {
'vm_ct': 'Virtual Machine / Container',
'backup': 'Backup & Snapshot',
'resources': 'System Resources',
'storage': 'Storage',
'network': 'Network',
'security': 'Security',
'cluster': 'Cluster',
'services': 'System Services',
'health': 'Health Monitor',
'updates': 'System Updates',
'other': 'System Notification',
}
def _format_html(self, subject: str, body: str, severity: str,
data: Optional[Dict] = None) -> str:
"""Build a professional HTML email with structured data sections."""
import html as html_mod
import time as _time
data = data or {}
sev = self._SEV_STYLE.get(severity, self._SEV_DEFAULT)
# Determine group for section header
event_type = data.get('_event_type', '')
group = data.get('_group', 'other')
section_label = self._GROUP_LABELS.get(group, 'System Notification')
# Timestamp
ts = data.get('timestamp', '') or _time.strftime('%Y-%m-%d %H:%M:%S UTC', _time.gmtime())
# ── Build structured detail rows from known data fields ──
detail_rows = self._build_detail_rows(data, event_type, group, html_mod)
# ── Fallback: if no structured rows, render body text lines ──
if not detail_rows:
for line in body.split('\n'):
stripped = line.strip()
if not stripped:
continue
# Try to split "Label: value" patterns
if ':' in stripped:
lbl, _, val = stripped.partition(':')
if val.strip() and len(lbl) < 40:
detail_rows.append((html_mod.escape(lbl.strip()), html_mod.escape(val.strip())))
continue
detail_rows.append(('', html_mod.escape(stripped)))
# ── Render detail rows as HTML table ──
rows_html = ''
for label, value in detail_rows:
if label:
rows_html += f'''<tr>
<td style="padding:8px 12px;font-size:13px;color:#374151;font-weight:500;white-space:nowrap;vertical-align:top;border-bottom:1px solid #e5e7eb;">{label}</td>
<td style="padding:8px 12px;font-size:13px;color:#111827;border-bottom:1px solid #e5e7eb;">{value}</td>
</tr>'''
else:
# Full-width row (no label, just description text)
rows_html += f'''<tr>
<td colspan="2" style="padding:8px 12px;font-size:13px;color:#1f2937;border-bottom:1px solid #e5e7eb;">{value}</td>
</tr>'''
# ── Reason / details block (long text, displayed separately) ──
reason = data.get('reason', '')
reason_html = ''
if reason and len(reason) > 80:
reason_html = f'''
<div style="margin:16px 0 0;padding:12px 16px;border:1px solid #d1d5db;border-radius:6px;">
<p style="margin:0 0 4px;font-size:11px;font-weight:600;color:#374151;text-transform:uppercase;letter-spacing:0.05em;">Details</p>
<p style="margin:0;font-size:13px;color:#1f2937;line-height:1.6;white-space:pre-wrap;">{html_mod.escape(reason)}</p>
</div>'''
# ── Clean subject for display (remove prefix if present) ──
display_title = subject
for prefix in [self.subject_prefix, '[CRITICAL]', '[WARNING]', '[INFO]', '[OK]']:
display_title = display_title.replace(prefix, '').strip()
return f'''<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"></head>
<body style="margin:0;padding:0;background-color:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<div style="max-width:640px;margin:24px auto;background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1);border:1px solid #d1d5db;">
<!-- Header -->
<div style="padding:20px 28px;background:#f8f9fa;border-bottom:1px solid {sev['border']};">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td>
<h1 style="margin:0;font-size:18px;font-weight:700;color:#111827;letter-spacing:-0.02em;">ProxMenux Monitor</h1>
<p style="margin:4px 0 0;font-size:12px;color:#4b5563;">{html_mod.escape(section_label)} Report</p>
</td>
<td style="text-align:right;vertical-align:top;">
<span style="display:inline-block;padding:4px 12px;border-radius:4px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:{sev['color']};background:{sev['bg']};border:1px solid {sev['border']};">{sev['label'].upper()}</span>
</td>
</tr>
</table>
</div>
<!-- Title bar -->
<div style="padding:16px 28px;background:{sev['bg']};border-bottom:1px solid {sev['border']};">
<h2 style="margin:0;font-size:15px;font-weight:600;color:{sev['color']};">{html_mod.escape(display_title)}</h2>
</div>
<!-- Body -->
<div style="padding:24px 28px;">
<!-- Metadata -->
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:16px;">
<tr>
<td style="font-size:12px;color:#4b5563;">
Host: <strong style="color:#111827;">{html_mod.escape(data.get('hostname', ''))}</strong>
</td>
<td style="font-size:12px;color:#4b5563;text-align:right;">
{html_mod.escape(ts)}
</td>
</tr>
</table>
<!-- Detail table -->
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #d1d5db;border-radius:6px;overflow:hidden;">
{rows_html}
</table>
{reason_html}
</div>
<!-- Footer -->
<div style="padding:14px 28px;border-top:1px solid #d1d5db;">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="font-size:11px;color:#4b5563;">ProxMenux Notification Service</td>
<td style="font-size:11px;color:#4b5563;text-align:right;">proxmenux.com</td>
</tr>
</table>
</div>
</div>
</body>
</html>'''
@staticmethod
def _build_detail_rows(data: Dict, event_type: str, group: str,
html_mod) -> list:
"""Build structured (label, value) rows from event data.
Returns list of (label_html, value_html) tuples.
An empty label means a full-width descriptive row.
"""
esc = html_mod.escape
rows = []
def _add(label: str, value, fmt: str = ''):
"""Add a row if value is truthy."""
v = str(value).strip() if value else ''
if not v or v == '0' and label not in ('Failures',):
return
if fmt == 'severity':
sev_colors = {
'CRITICAL': '#dc2626', 'WARNING': '#d97706',
'INFO': '#2563eb', 'OK': '#16a34a',
}
c = sev_colors.get(v, '#6b7280')
rows.append((esc(label), f'<span style="color:{c};font-weight:600;">{esc(v)}</span>'))
elif fmt == 'code':
rows.append((esc(label), f'<code style="padding:2px 6px;background:#f3f4f6;border-radius:3px;font-family:monospace;font-size:12px;">{esc(v)}</code>'))
elif fmt == 'bold':
rows.append((esc(label), f'<strong>{esc(v)}</strong>'))
else:
rows.append((esc(label), esc(v)))
# ── Common fields present in most events ──
# ── VM / CT events ──
if group == 'vm_ct':
_add('VM/CT ID', data.get('vmid'), 'code')
_add('Name', data.get('vmname'), 'bold')
_add('Action', event_type.replace('_', ' ').replace('vm ', 'VM ').replace('ct ', 'CT ').title())
_add('Target Node', data.get('target_node'))
_add('Reason', data.get('reason'))
# ── Backup events ──
elif group == 'backup':
_add('VM/CT ID', data.get('vmid'), 'code')
_add('Name', data.get('vmname'), 'bold')
_add('Status', 'Failed' if 'fail' in event_type else 'Completed' if 'complete' in event_type else 'Started',
'severity' if 'fail' in event_type else '')
_add('Size', data.get('size'))
_add('Duration', data.get('duration'))
_add('Snapshot', data.get('snapshot_name'), 'code')
# For backup_complete/fail with parsed body, add short reason only
reason = data.get('reason', '')
if reason and len(reason) <= 80:
_add('Details', reason)
# ── Resources ──
elif group == 'resources':
_add('Metric', event_type.replace('_', ' ').title())
_add('Current Value', data.get('value'), 'bold')
_add('Threshold', data.get('threshold'))
_add('CPU Cores', data.get('cores'))
_add('Memory', f"{data.get('used', '')} / {data.get('total', '')}" if data.get('used') else '')
_add('Temperature', f"{data.get('value')}C" if 'temp' in event_type else '')
# ── Storage ──
elif group == 'storage':
if 'disk_space' in event_type:
_add('Mount Point', data.get('mount'), 'code')
_add('Usage', f"{data.get('used')}%", 'bold')
_add('Available', data.get('available'))
elif 'io_error' in event_type:
_add('Device', data.get('device'), 'code')
_add('Severity', data.get('severity', ''), 'severity')
elif 'unavailable' in event_type:
_add('Storage Name', data.get('storage_name'), 'bold')
_add('Type', data.get('storage_type'), 'code')
reason = data.get('reason', '')
if reason and len(reason) <= 80:
_add('Details', reason)
# ── Network ──
elif group == 'network':
_add('Interface', data.get('interface'), 'code')
_add('Latency', f"{data.get('value')}ms" if data.get('value') else '')
_add('Threshold', f"{data.get('threshold')}ms" if data.get('threshold') else '')
reason = data.get('reason', '')
if reason and len(reason) <= 80:
_add('Details', reason)
# ── Security ──
elif group == 'security':
_add('Event', event_type.replace('_', ' ').title())
_add('Source IP', data.get('source_ip'), 'code')
_add('Username', data.get('username'), 'code')
_add('Service', data.get('service'))
_add('Jail', data.get('jail'), 'code')
_add('Failures', data.get('failures'))
_add('Change', data.get('change_details'))
# ── Cluster ──
elif group == 'cluster':
_add('Event', event_type.replace('_', ' ').title())
_add('Node', data.get('node_name'), 'bold')
_add('Quorum', data.get('quorum'))
_add('Nodes Affected', data.get('entity_list'))
# ── Services ──
elif group == 'services':
_add('Service', data.get('service_name'), 'code')
_add('Process', data.get('process'), 'code')
_add('Event', event_type.replace('_', ' ').title())
reason = data.get('reason', '')
if reason and len(reason) <= 80:
_add('Details', reason)
# ── Health monitor ──
elif group == 'health':
_add('Category', data.get('category'), 'bold')
_add('Severity', data.get('severity', ''), 'severity')
if data.get('original_severity'):
_add('Previous Severity', data.get('original_severity'), 'severity')
_add('Duration', data.get('duration'))
_add('Active Issues', data.get('count'))
reason = data.get('reason', '')
if reason and len(reason) <= 80:
_add('Details', reason)
# ── Updates ──
elif group == 'updates':
_add('Total Updates', data.get('total_count'), 'bold')
_add('Security Updates', data.get('security_count'))
_add('Proxmox Updates', data.get('pve_count'))
_add('Kernel Updates', data.get('kernel_count'))
imp = data.get('important_list', '')
if imp and imp != 'none':
# Render each package on its own line inside a single cell
pkg_lines = [l.strip() for l in imp.split('\n') if l.strip()]
if pkg_lines:
pkg_html = '<br>'.join(
f'<code style="padding:1px 5px;background:#f3f4f6;border-radius:3px;font-family:monospace;font-size:12px;">{esc(p)}</code>'
for p in pkg_lines
)
rows.append((esc('Important Packages'), pkg_html))
_add('Current Version', data.get('current_version'), 'code')
_add('New Version', data.get('new_version'), 'code')
# ── Other / unknown ──
else:
reason = data.get('reason', '')
if reason and len(reason) <= 80:
_add('Details', reason)
return rows
def test(self) -> Tuple[bool, str]:
import socket as _socket
hostname = _socket.gethostname().split('.')[0]
result = self.send(
'ProxMenux Test Notification',
'This is a test notification from ProxMenux Monitor.\n'
'If you received this, your email channel is working correctly.',
'INFO',
data={
'hostname': hostname,
'_event_type': 'webhook_test',
'_group': 'other',
'reason': 'Email notification channel connectivity verified successfully. '
'You will receive alerts from ProxMenux Monitor at this address.',
}
)
return result.get('success', False), result.get('error', '')
# ─── Channel Factory ─────────────────────────────────────────────
CHANNEL_TYPES = {
'telegram': {
'name': 'Telegram',
'config_keys': ['bot_token', 'chat_id', 'topic_id'],
'class': TelegramChannel,
},
'gotify': {
'name': 'Gotify',
'config_keys': ['url', 'token'],
'class': GotifyChannel,
},
'discord': {
'name': 'Discord',
'config_keys': ['webhook_url'],
'class': DiscordChannel,
},
'email': {
'name': 'Email (SMTP)',
'config_keys': ['host', 'port', 'username', 'password', 'tls_mode',
'from_address', 'to_addresses', 'subject_prefix'],
'class': EmailChannel,
},
}
def create_channel(channel_type: str, config: Dict[str, str]) -> Optional[NotificationChannel]:
"""Create a channel instance from type name and config dict.
Args:
channel_type: 'telegram', 'gotify', or 'discord'
config: Dict with channel-specific keys (see CHANNEL_TYPES)
Returns:
Channel instance or None if creation fails
"""
try:
if channel_type == 'telegram':
return TelegramChannel(
bot_token=config.get('bot_token', ''),
chat_id=config.get('chat_id', ''),
topic_id=config.get('topic_id', '')
)
elif channel_type == 'gotify':
return GotifyChannel(
server_url=config.get('url', ''),
app_token=config.get('token', '')
)
elif channel_type == 'discord':
return DiscordChannel(
webhook_url=config.get('webhook_url', '')
)
elif channel_type == 'email':
return EmailChannel(config)
except Exception as e:
print(f"[NotificationChannels] Failed to create {channel_type}: {e}")
return None
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
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""
ProxMenux - HTML Description Templates for OCI Containers
==========================================================
Generates beautiful HTML descriptions for the Proxmox Notes panel.
Can be used from both Python (oci_manager.py) and bash scripts.
Usage from bash:
python3 description_templates.py --app-id "secure-gateway" --hostname "my-gateway"
Usage from Python:
from description_templates import generate_description
html = generate_description(app_def, container_def, hostname)
"""
import sys
import json
import argparse
import urllib.parse
from pathlib import Path
from typing import Dict, Optional
# Default paths
CATALOG_PATH = Path(__file__).parent / "catalog.json"
def get_shield_icon_svg(color: str = "#0EA5E9") -> str:
"""Generate a shield icon SVG with checkmark."""
return f"""<svg xmlns='http://www.w3.org/2000/svg' width='48' height='48' viewBox='0 0 24 24' fill='none' stroke='{color}' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z'/><path d='M9 12l2 2 4-4'/></svg>"""
def get_default_icon_svg(color: str = "#0EA5E9") -> str:
"""Generate a default container icon SVG."""
return f"""<svg xmlns='http://www.w3.org/2000/svg' width='48' height='48' viewBox='0 0 24 24' fill='none' stroke='{color}' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z'/><polyline points='3.27 6.96 12 12.01 20.73 6.96'/><line x1='12' y1='22.08' x2='12' y2='12'/></svg>"""
# Pre-defined icon types
ICON_TYPES = {
"shield": get_shield_icon_svg,
"container": get_default_icon_svg,
"default": get_default_icon_svg,
}
def generate_description(
app_def: Dict,
container_def: Optional[Dict] = None,
hostname: str = "",
extra_info: str = ""
) -> str:
"""
Generate HTML description for Proxmox Notes panel.
Args:
app_def: Application definition from catalog
container_def: Container definition (optional)
hostname: Container hostname
extra_info: Additional info to display (e.g., disk info)
Returns:
HTML string for the description
"""
# Extract app info
app_name = app_def.get("name", "ProxMenux App")
app_subtitle = app_def.get("subtitle", "")
app_color = app_def.get("color", "#0EA5E9")
app_icon_type = app_def.get("icon_type", "default")
doc_url = app_def.get("documentation_url", "https://macrimi.github.io/ProxMenux/")
code_url = app_def.get("code_url", "https://github.com/MacRimi/ProxMenux")
installer_url = app_def.get("installer_url", "")
kofi_url = "https://ko-fi.com/macrimi"
# Get the icon SVG
icon_func = ICON_TYPES.get(app_icon_type, ICON_TYPES["default"])
icon_svg = icon_func(app_color)
icon_data = "data:image/svg+xml," + urllib.parse.quote(icon_svg)
# Build badge buttons
badges = []
badges.append(f"<a href='{doc_url}' target='_blank'><img src='https://img.shields.io/badge/📚_Docs-blue' alt='Docs'></a>")
badges.append(f"<a href='{code_url}' target='_blank'><img src='https://img.shields.io/badge/💻_Code-green' alt='Code'></a>")
if installer_url:
badges.append(f"<a href='{installer_url}' target='_blank'><img src='https://img.shields.io/badge/📦_Installer-orange' alt='Installer'></a>")
badges.append(f"<a href='{kofi_url}' target='_blank'><img src='https://img.shields.io/badge/☕_Ko--fi-red' alt='Ko-fi'></a>")
badges_html = "\n".join(badges)
# Build footer info
footer_parts = []
if hostname:
footer_parts.append(f"Hostname: {hostname}")
if extra_info:
footer_parts.append(extra_info)
footer_html = "<br>".join(footer_parts) if footer_parts else ""
# Build the complete HTML
html = f"""<div align='center'>
<table style='width: 100%; border-collapse: collapse;'>
<tr>
<td style='width: 100px; vertical-align: middle;'>
<img src="/images/design-mode/logo_desc.png" alt='ProxMenux Logo' style='height: 100px;'>
</td>
<td style='vertical-align: middle;'>
<h1 style='margin: 0;'>{app_name}</h1>
<p style='margin: 0;'>Created with ProxMenux</p>
</td>
</tr>
</table>
<div style='margin: 15px 0; padding: 10px; background: #2d2d2d; border-radius: 8px; display: inline-block;'>
<table style='border-collapse: collapse;'>
<tr>
<td style='vertical-align: middle; padding-right: 10px;'>
<img src='{icon_data}' alt='Icon' style='height: 48px;'>
</td>
<td style='vertical-align: middle; text-align: left;'>
<span style='font-size: 18px; font-weight: bold; color: {app_color};'>{app_name}</span><br>
<span style='color: #9ca3af;'>{app_subtitle}</span>
</td>
</tr>
</table>
</div>
<p>
{badges_html}
</p>
"""
if footer_html:
html += f"""
<p style='color: #6b7280; font-size: 12px;'>
{footer_html}
</p>
"""
html += "</div>"
return html
def generate_vm_description(
vm_name: str,
vm_version: str = "",
doc_url: str = "",
code_url: str = "",
installer_url: str = "",
extra_info: str = "",
icon_url: str = ""
) -> str:
"""
Generate HTML description for VMs (like ZimaOS).
Args:
vm_name: Name of the VM
vm_version: Version string
doc_url: Documentation URL
code_url: Code repository URL
installer_url: Installer URL
extra_info: Additional info (e.g., disk info)
icon_url: Custom icon URL for the VM
Returns:
HTML string for the description
"""
# Build badge buttons
badges = []
if doc_url:
badges.append(f"<a href='{doc_url}' target='_blank'><img src='https://img.shields.io/badge/📚_Docs-blue' alt='Docs'></a>")
if code_url:
badges.append(f"<a href='{code_url}' target='_blank'><img src='https://img.shields.io/badge/💻_Code-green' alt='Code'></a>")
if installer_url:
badges.append(f"<a href='{installer_url}' target='_blank'><img src='https://img.shields.io/badge/📦_Installer-orange' alt='Installer'></a>")
badges.append("<a href='https://ko-fi.com/macrimi' target='_blank'><img src='https://img.shields.io/badge/☕_Ko--fi-red' alt='Ko-fi'></a>")
badges_html = "\n".join(badges)
# Version line
version_html = f"<p style='margin: 0;'>{vm_version}</p>" if vm_version else ""
# Extra info
extra_html = f"<p style='color: #6b7280; font-size: 12px;'>{extra_info}</p>" if extra_info else ""
html = f"""<div align='center'>
<table style='width: 100%; border-collapse: collapse;'>
<tr>
<td style='width: 100px; vertical-align: middle;'>
<img src="/images/design-mode/logo_desc.png" alt='ProxMenux Logo' style='height: 100px;'>
</td>
<td style='vertical-align: middle;'>
<h1 style='margin: 0;'>{vm_name}</h1>
<p style='margin: 0;'>Created with ProxMenux</p>
{version_html}
</td>
</tr>
</table>
<p>
{badges_html}
</p>
{extra_html}
</div>"""
return html
def load_catalog() -> Dict:
"""Load the OCI catalog."""
if CATALOG_PATH.exists():
with open(CATALOG_PATH) as f:
return json.load(f)
return {"apps": {}}
def main():
"""CLI interface for generating descriptions."""
parser = argparse.ArgumentParser(description="Generate HTML descriptions for Proxmox")
parser.add_argument("--app-id", help="Application ID from catalog")
parser.add_argument("--hostname", default="", help="Container hostname")
parser.add_argument("--extra-info", default="", help="Additional info to display")
parser.add_argument("--output", choices=["html", "encoded"], default="html",
help="Output format: html or url-encoded")
# For VM descriptions (not from catalog)
parser.add_argument("--vm-name", help="VM name (for non-catalog VMs)")
parser.add_argument("--vm-version", default="", help="VM version")
parser.add_argument("--doc-url", default="", help="Documentation URL")
parser.add_argument("--code-url", default="", help="Code repository URL")
parser.add_argument("--installer-url", default="", help="Installer URL")
args = parser.parse_args()
if args.app_id:
# Generate from catalog
catalog = load_catalog()
apps = catalog.get("apps", {})
if args.app_id not in apps:
print(f"Error: App '{args.app_id}' not found in catalog", file=sys.stderr)
sys.exit(1)
app_def = apps[args.app_id]
html = generate_description(app_def, hostname=args.hostname, extra_info=args.extra_info)
elif args.vm_name:
# Generate for VM
html = generate_vm_description(
vm_name=args.vm_name,
vm_version=args.vm_version,
doc_url=args.doc_url,
code_url=args.code_url,
installer_url=args.installer_url,
extra_info=args.extra_info
)
else:
parser.print_help()
sys.exit(1)
if args.output == "encoded":
print(urllib.parse.quote(html))
else:
print(html)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+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]
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ProxMenux - Proxmox Storage Monitor
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 (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'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
nodes = json.loads(result.stdout)
hostname = socket.gethostname()
for node in nodes:
if node.get('node') == hostname:
cache['name'] = hostname
cache['time'] = current_time
return hostname
if nodes:
name = nodes[0].get('node', hostname)
cache['name'] = name
cache['time'] = current_time
return name
return socket.gethostname()
except Exception:
return socket.gethostname()
def _load_configured_storages(self) -> None:
"""Load configured storages from Proxmox configuration"""
try:
local_node = self._get_node_name()
# Read storage configuration from pvesh
result = subprocess.run(
['pvesh', 'get', '/storage', '--output-format', 'json'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode != 0:
return
storages = json.loads(result.stdout)
for storage in storages:
storage_id = storage.get('storage')
if not storage_id:
continue
# Check if storage is enabled for this node
nodes = storage.get('nodes')
if nodes and local_node not in nodes.split(','):
continue
disabled = storage.get('disable', 0)
if disabled == 1:
continue
self.configured_storages[storage_id] = {
'name': storage_id,
'type': storage.get('type', 'unknown'),
'content': storage.get('content', ''),
'path': storage.get('path', ''),
'enabled': True
}
except Exception:
pass
def get_storage_status(self) -> Dict[str, List[Dict[str, Any]]]:
"""
Get storage status, including unavailable storages (cached)
Returns:
{
'available': [...],
'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()
# Get current storage status from pvesh
result = subprocess.run(
['pvesh', 'get', '/cluster/resources', '--type', 'storage', '--output-format', 'json'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode != 0:
return {'available': [], 'unavailable': list(self.configured_storages.values())}
resources = json.loads(result.stdout)
# Track which configured storages are available
available_storages = []
unavailable_storages = []
seen_storage_names = set()
for resource in resources:
node = resource.get('node', '')
# Filter only local node storages
if node != local_node:
continue
name = resource.get('storage', 'unknown')
seen_storage_names.add(name)
storage_type = resource.get('plugintype', 'unknown')
status = resource.get('status', 'unknown')
try:
total = int(resource.get('maxdisk', 0))
used = int(resource.get('disk', 0))
available = total - used if total > 0 else 0
except (ValueError, TypeError):
total = 0
used = 0
available = 0
# Calculate percentage
percent = (used / total * 100) if total > 0 else 0.0
# Convert bytes to GB
total_gb = round(total / (1024**3), 2)
used_gb = round(used / (1024**3), 2)
available_gb = round(available / (1024**3), 2)
storage_info = {
'name': name,
'type': storage_type,
'total': total_gb,
'used': used_gb,
'available': available_gb,
'percent': round(percent, 2),
'node': node
}
# Check if storage is available
if total == 0 or status.lower() != "available":
storage_info['status'] = 'error'
storage_info['status_detail'] = 'unavailable' if total == 0 else status
unavailable_storages.append(storage_info)
else:
storage_info['status'] = 'active'
available_storages.append(storage_info)
# Check for configured storages that are completely missing
for storage_name, storage_config in self.configured_storages.items():
if storage_name not in seen_storage_names:
unavailable_storages.append({
'name': storage_name,
'type': storage_config['type'],
'status': 'error',
'status_detail': 'not_found',
'total': 0,
'used': 0,
'available': 0,
'percent': 0,
'node': local_node
})
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 {
'available': [],
'unavailable': list(self.configured_storages.values())
}
def get_unavailable_count(self) -> int:
"""Get count of unavailable storages"""
status = self.get_storage_status()
return len(status['unavailable'])
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
proxmox_storage_monitor = ProxmoxStorageMonitor()
File diff suppressed because it is too large Load Diff
+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
+481
View File
@@ -0,0 +1,481 @@
#!/bin/bash
# ============================================================================
# ProxMenux Notification System - Complete Test Suite
# ============================================================================
#
# Usage:
# chmod +x test_all_notifications.sh
# ./test_all_notifications.sh # Run ALL tests (with 3s pause between)
# ./test_all_notifications.sh system # Run only System category
# ./test_all_notifications.sh vm_ct # Run only VM/CT category
# ./test_all_notifications.sh backup # Run only Backup category
# ./test_all_notifications.sh resources # Run only Resources category
# ./test_all_notifications.sh storage # Run only Storage category
# ./test_all_notifications.sh network # Run only Network category
# ./test_all_notifications.sh security # Run only Security category
# ./test_all_notifications.sh cluster # Run only Cluster category
# ./test_all_notifications.sh burst # Run only Burst aggregation tests
#
# Each test sends a simulated webhook to the local notification endpoint.
# Check your Telegram/Gotify/Discord/Email for the notifications.
# ============================================================================
API="http://127.0.0.1:8008/api/notifications/webhook"
PAUSE=3 # seconds between tests
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
BOLD='\033[1m'
test_count=0
pass_count=0
fail_count=0
send_test() {
local name="$1"
local payload="$2"
test_count=$((test_count + 1))
echo -e "${CYAN} [$test_count] ${BOLD}$name${NC}"
response=$(curl -s -w "\n%{http_code}" -X POST "$API" \
-H "Content-Type: application/json" \
-d "$payload" 2>&1)
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [ "$http_code" = "200" ] || [ "$http_code" = "202" ]; then
echo -e " ${GREEN}HTTP $http_code${NC} - $body"
pass_count=$((pass_count + 1))
else
echo -e " ${RED}HTTP $http_code${NC} - $body"
fail_count=$((fail_count + 1))
fi
sleep "$PAUSE"
}
# ============================================================================
# SYSTEM CATEGORY (group: system)
# ============================================================================
test_system() {
echo ""
echo -e "${YELLOW}========================================${NC}"
echo -e "${YELLOW} SYSTEM - Startup, shutdown, kernel${NC}"
echo -e "${YELLOW}========================================${NC}"
echo ""
# 1. state_change (disabled by default -- test to verify it does NOT arrive)
send_test "state_change (should NOT arrive - disabled by default)" \
'{"type":"state_change","component":"health","severity":"warning","title":"overall changed to WARNING","body":"overall status changed from OK to WARNING."}'
# 2. new_error
send_test "new_error" \
'{"type":"new_error","component":"health","severity":"warning","title":"New WARNING - cpu","body":"CPU usage exceeds 90% for more than 5 minutes","category":"cpu"}'
# 3. error_resolved
send_test "error_resolved" \
'{"type":"error_resolved","component":"health","severity":"info","title":"Resolved - cpu","body":"CPU usage returned to normal.\nDuration: 15 minutes","category":"cpu","duration":"15 minutes"}'
# 4. error_escalated
send_test "error_escalated" \
'{"type":"error_escalated","component":"health","severity":"critical","title":"Escalated to CRITICAL - memory","body":"Memory usage exceeded 95% and swap is active","category":"memory"}'
# 5. system_shutdown
send_test "system_shutdown" \
'{"type":"system_shutdown","component":"system","severity":"warning","title":"System shutting down","body":"The system is shutting down.\nUser initiated shutdown."}'
# 6. system_reboot
send_test "system_reboot" \
'{"type":"system_reboot","component":"system","severity":"warning","title":"System rebooting","body":"The system is rebooting.\nKernel update applied."}'
# 7. system_problem
send_test "system_problem" \
'{"type":"system_problem","component":"system","severity":"critical","title":"System problem detected","body":"Kernel panic: Attempted to kill init! exitcode=0x00000009"}'
# 8. service_fail
send_test "service_fail" \
'{"type":"service_fail","component":"systemd","severity":"warning","title":"Service failed - pvedaemon","body":"Service pvedaemon has failed.\nUnit pvedaemon.service entered failed state.","service_name":"pvedaemon"}'
# 9. update_available (legacy, superseded by update_summary)
send_test "update_available" \
'{"type":"update_available","component":"apt","severity":"info","title":"Updates available","body":"Total updates: 12\nSecurity: 3\nProxmox: 5\nKernel: 1\nImportant: pve-manager (8.3.5 -> 8.4.1)","total_count":"12","security_count":"3","pve_count":"5","kernel_count":"1","important_list":"pve-manager (8.3.5 -> 8.4.1)"}'
# 10. update_complete
send_test "update_complete" \
'{"type":"update_complete","component":"apt","severity":"info","title":"Update completed","body":"12 packages updated successfully."}'
# 11. unknown_persistent
send_test "unknown_persistent" \
'{"type":"unknown_persistent","component":"health","severity":"warning","title":"Check unavailable - temperature","body":"Health check for temperature has been unavailable for 3+ cycles.\nSensor not responding.","category":"temperature"}'
# 12. health_persistent
send_test "health_persistent" \
'{"type":"health_persistent","component":"health","severity":"warning","title":"3 active health issue(s)","body":"The following health issues remain active:\n- CPU at 92%\n- Memory at 88%\n- Disk /dev/sda at 94%\n\nThis digest is sent once every 24 hours while issues persist.","count":"3"}'
# 13. health_issue_new
send_test "health_issue_new" \
'{"type":"health_issue_new","component":"health","severity":"warning","title":"New health issue - disk","body":"New WARNING issue detected:\nDisk /dev/sda usage at 94%","category":"disk"}'
# 14. health_issue_resolved
send_test "health_issue_resolved" \
'{"type":"health_issue_resolved","component":"health","severity":"info","title":"Resolved - disk","body":"disk issue has been resolved.\nDisk usage dropped to 72%.\nDuration: 3 hours","category":"disk","duration":"3 hours"}'
# 15. update_summary
send_test "update_summary" \
'{"type":"update_summary","component":"apt","severity":"info","title":"Updates available","body":"Total updates: 70\nSecurity updates: 9\nProxmox-related updates: 24\nKernel updates: 1\nImportant packages: pve-manager (8.3.5 -> 8.4.1), proxmox-ve (8.3.0 -> 8.4.0), qemu-server (8.3.8 -> 8.4.2)","total_count":"70","security_count":"9","pve_count":"24","kernel_count":"1","important_list":"pve-manager (8.3.5 -> 8.4.1), proxmox-ve (8.3.0 -> 8.4.0), qemu-server (8.3.8 -> 8.4.2)"}'
# 16. pve_update
send_test "pve_update" \
'{"type":"pve_update","component":"apt","severity":"info","title":"Proxmox VE 8.4.1 available","body":"Proxmox VE 8.3.5 -> 8.4.1\npve-manager 8.3.5 -> 8.4.1","current_version":"8.3.5","new_version":"8.4.1","version":"8.4.1","details":"pve-manager 8.3.5 -> 8.4.1"}'
}
# ============================================================================
# VM / CT CATEGORY (group: vm_ct)
# ============================================================================
test_vm_ct() {
echo ""
echo -e "${YELLOW}========================================${NC}"
echo -e "${YELLOW} VM / CT - Start, stop, crash, migration${NC}"
echo -e "${YELLOW}========================================${NC}"
echo ""
# 1. vm_start
send_test "vm_start" \
'{"type":"vm_start","component":"qemu","severity":"info","title":"VM 100 started","body":"ubuntu-server (100) has been started.","vmid":"100","vmname":"ubuntu-server"}'
# 2. vm_stop
send_test "vm_stop" \
'{"type":"vm_stop","component":"qemu","severity":"info","title":"VM 100 stopped","body":"ubuntu-server (100) has been stopped.","vmid":"100","vmname":"ubuntu-server"}'
# 3. vm_shutdown
send_test "vm_shutdown" \
'{"type":"vm_shutdown","component":"qemu","severity":"info","title":"VM 100 shutdown","body":"ubuntu-server (100) has been shut down.","vmid":"100","vmname":"ubuntu-server"}'
# 4. vm_fail
send_test "vm_fail" \
'{"type":"vm_fail","component":"qemu","severity":"critical","title":"VM 100 FAILED","body":"ubuntu-server (100) has failed.\nKVM: internal error: unexpected exit to hypervisor","vmid":"100","vmname":"ubuntu-server","reason":"KVM: internal error: unexpected exit to hypervisor"}'
# 5. vm_restart
send_test "vm_restart" \
'{"type":"vm_restart","component":"qemu","severity":"info","title":"VM 100 restarted","body":"ubuntu-server (100) has been restarted.","vmid":"100","vmname":"ubuntu-server"}'
# 6. ct_start
send_test "ct_start" \
'{"type":"ct_start","component":"lxc","severity":"info","title":"CT 200 started","body":"nginx-proxy (200) has been started.","vmid":"200","vmname":"nginx-proxy"}'
# 7. ct_stop
send_test "ct_stop" \
'{"type":"ct_stop","component":"lxc","severity":"info","title":"CT 200 stopped","body":"nginx-proxy (200) has been stopped.","vmid":"200","vmname":"nginx-proxy"}'
# 8. ct_fail
send_test "ct_fail" \
'{"type":"ct_fail","component":"lxc","severity":"critical","title":"CT 200 FAILED","body":"nginx-proxy (200) has failed.\nContainer exited with error code 137","vmid":"200","vmname":"nginx-proxy","reason":"Container exited with error code 137"}'
# 9. migration_start
send_test "migration_start" \
'{"type":"migration_start","component":"qemu","severity":"info","title":"Migration started - 100","body":"ubuntu-server (100) migration to pve-node2 started.","vmid":"100","vmname":"ubuntu-server","target_node":"pve-node2"}'
# 10. migration_complete
send_test "migration_complete" \
'{"type":"migration_complete","component":"qemu","severity":"info","title":"Migration complete - 100","body":"ubuntu-server (100) migrated successfully to pve-node2.","vmid":"100","vmname":"ubuntu-server","target_node":"pve-node2"}'
# 11. migration_fail
send_test "migration_fail" \
'{"type":"migration_fail","component":"qemu","severity":"critical","title":"Migration FAILED - 100","body":"ubuntu-server (100) migration to pve-node2 failed.\nNetwork timeout during memory transfer","vmid":"100","vmname":"ubuntu-server","target_node":"pve-node2","reason":"Network timeout during memory transfer"}'
# 12. replication_fail
send_test "replication_fail" \
'{"type":"replication_fail","component":"replication","severity":"critical","title":"Replication FAILED - 100","body":"Replication of ubuntu-server (100) has failed.\nTarget storage unreachable","vmid":"100","vmname":"ubuntu-server","reason":"Target storage unreachable"}'
# 13. replication_complete
send_test "replication_complete" \
'{"type":"replication_complete","component":"replication","severity":"info","title":"Replication complete - 100","body":"Replication of ubuntu-server (100) completed successfully.","vmid":"100","vmname":"ubuntu-server"}'
}
# ============================================================================
# BACKUP CATEGORY (group: backup)
# ============================================================================
test_backup() {
echo ""
echo -e "${YELLOW}========================================${NC}"
echo -e "${YELLOW} BACKUPS - Backup start, complete, fail${NC}"
echo -e "${YELLOW}========================================${NC}"
echo ""
# 1. backup_start
send_test "backup_start" \
'{"type":"backup_start","component":"vzdump","severity":"info","title":"Backup started - 100","body":"Backup of ubuntu-server (100) has started.","vmid":"100","vmname":"ubuntu-server"}'
# 2. backup_complete
send_test "backup_complete" \
'{"type":"backup_complete","component":"vzdump","severity":"info","title":"Backup complete - 100","body":"Backup of ubuntu-server (100) completed successfully.\nSize: 12.4 GB","vmid":"100","vmname":"ubuntu-server","size":"12.4 GB"}'
# 3. backup_fail
send_test "backup_fail" \
'{"type":"backup_fail","component":"vzdump","severity":"critical","title":"Backup FAILED - 100","body":"Backup of ubuntu-server (100) has failed.\nStorage local-lvm is full","vmid":"100","vmname":"ubuntu-server","reason":"Storage local-lvm is full"}'
# 4. snapshot_complete
send_test "snapshot_complete" \
'{"type":"snapshot_complete","component":"qemu","severity":"info","title":"Snapshot created - 100","body":"Snapshot of ubuntu-server (100) created: pre-upgrade-2026","vmid":"100","vmname":"ubuntu-server","snapshot_name":"pre-upgrade-2026"}'
# 5. snapshot_fail
send_test "snapshot_fail" \
'{"type":"snapshot_fail","component":"qemu","severity":"critical","title":"Snapshot FAILED - 100","body":"Snapshot of ubuntu-server (100) failed.\nInsufficient space on storage","vmid":"100","vmname":"ubuntu-server","reason":"Insufficient space on storage"}'
}
# ============================================================================
# RESOURCES CATEGORY (group: resources)
# ============================================================================
test_resources() {
echo ""
echo -e "${YELLOW}========================================${NC}"
echo -e "${YELLOW} RESOURCES - CPU, memory, temperature${NC}"
echo -e "${YELLOW}========================================${NC}"
echo ""
# 1. cpu_high
send_test "cpu_high" \
'{"type":"cpu_high","component":"health","severity":"warning","title":"High CPU usage (94%)","body":"CPU usage is at 94% on 16 cores.\nTop process: kvm (VM 100)","value":"94","cores":"16","details":"Top process: kvm (VM 100)"}'
# 2. ram_high
send_test "ram_high" \
'{"type":"ram_high","component":"health","severity":"warning","title":"High memory usage (91%)","body":"Memory usage: 58.2 GB / 64 GB (91%).\n4 VMs running, swap at 2.1 GB","value":"91","used":"58.2 GB","total":"64 GB","details":"4 VMs running, swap at 2.1 GB"}'
# 3. temp_high
send_test "temp_high" \
'{"type":"temp_high","component":"health","severity":"critical","title":"High temperature (89C)","body":"CPU temperature: 89C (threshold: 80C).\nCheck cooling system immediately","value":"89","threshold":"80","details":"Check cooling system immediately"}'
# 4. load_high
send_test "load_high" \
'{"type":"load_high","component":"health","severity":"warning","title":"High system load (24.5)","body":"System load average: 24.5 on 16 cores.\nI/O wait: 35%","value":"24.5","cores":"16","details":"I/O wait: 35%"}'
}
# ============================================================================
# STORAGE CATEGORY (group: storage)
# ============================================================================
test_storage() {
echo ""
echo -e "${YELLOW}========================================${NC}"
echo -e "${YELLOW} STORAGE - Disk space, I/O errors, SMART${NC}"
echo -e "${YELLOW}========================================${NC}"
echo ""
# 1. disk_space_low
send_test "disk_space_low" \
'{"type":"disk_space_low","component":"storage","severity":"warning","title":"Low disk space on /var","body":"/var: 93% used (4.2 GB available).","mount":"/var","used":"93","available":"4.2 GB"}'
# 2. disk_io_error
send_test "disk_io_error" \
'{"type":"disk_io_error","component":"smart","severity":"critical","title":"Disk I/O error","body":"I/O error detected on /dev/sdb.\nSMART error: Current Pending Sector Count = 8","device":"/dev/sdb","reason":"SMART error: Current Pending Sector Count = 8"}'
# 3. burst_disk_io
send_test "burst_disk_io" \
'{"type":"burst_disk_io","component":"storage","severity":"critical","title":"5 disk I/O errors on /dev/sdb, /dev/sdc","body":"5 I/O errors detected in 60s.\nDevices: /dev/sdb, /dev/sdc","count":"5","window":"60s","entity_list":"/dev/sdb, /dev/sdc"}'
}
# ============================================================================
# NETWORK CATEGORY (group: network)
# ============================================================================
test_network() {
echo ""
echo -e "${YELLOW}========================================${NC}"
echo -e "${YELLOW} NETWORK - Connectivity, bond, latency${NC}"
echo -e "${YELLOW}========================================${NC}"
echo ""
# 1. network_down
send_test "network_down" \
'{"type":"network_down","component":"network","severity":"critical","title":"Network connectivity lost","body":"Network connectivity check failed.\nGateway 192.168.1.1 unreachable. Bond vmbr0 degraded.","reason":"Gateway 192.168.1.1 unreachable. Bond vmbr0 degraded."}'
# 2. network_latency
send_test "network_latency" \
'{"type":"network_latency","component":"network","severity":"warning","title":"High network latency (450ms)","body":"Latency to gateway: 450ms (threshold: 100ms).","value":"450","threshold":"100"}'
}
# ============================================================================
# SECURITY CATEGORY (group: security)
# ============================================================================
test_security() {
echo ""
echo -e "${YELLOW}========================================${NC}"
echo -e "${YELLOW} SECURITY - Auth failures, fail2ban, firewall${NC}"
echo -e "${YELLOW}========================================${NC}"
echo ""
# 1. auth_fail
send_test "auth_fail" \
'{"type":"auth_fail","component":"auth","severity":"warning","title":"Authentication failure","body":"Failed login attempt from 203.0.113.42.\nUser: root\nService: sshd","source_ip":"203.0.113.42","username":"root","service":"sshd"}'
# 2. ip_block
send_test "ip_block" \
'{"type":"ip_block","component":"security","severity":"info","title":"IP blocked by Fail2Ban","body":"IP 203.0.113.42 has been banned.\nJail: sshd\nFailures: 5","source_ip":"203.0.113.42","jail":"sshd","failures":"5"}'
# 3. firewall_issue
send_test "firewall_issue" \
'{"type":"firewall_issue","component":"firewall","severity":"warning","title":"Firewall issue detected","body":"Firewall rule conflict detected on vmbr0.\nRule 15 overlaps with rule 23, potentially blocking cluster traffic.","reason":"Firewall rule conflict detected on vmbr0. Rule 15 overlaps with rule 23."}'
# 4. user_permission_change
send_test "user_permission_change" \
'{"type":"user_permission_change","component":"auth","severity":"info","title":"User permission changed","body":"User: admin@pam\nChange: Added PVEAdmin role on /vms/100","username":"admin@pam","change_details":"Added PVEAdmin role on /vms/100"}'
# 5. burst_auth_fail
send_test "burst_auth_fail" \
'{"type":"burst_auth_fail","component":"security","severity":"warning","title":"8 auth failures in 2m","body":"8 authentication failures detected in 2m.\nSources: 203.0.113.42, 198.51.100.7, 192.0.2.15","count":"8","window":"2m","entity_list":"203.0.113.42, 198.51.100.7, 192.0.2.15"}'
# 6. burst_ip_block
send_test "burst_ip_block" \
'{"type":"burst_ip_block","component":"security","severity":"info","title":"Fail2Ban banned 4 IPs in 5m","body":"4 IPs banned by Fail2Ban in 5m.\nIPs: 203.0.113.42, 198.51.100.7, 192.0.2.15, 10.0.0.99","count":"4","window":"5m","entity_list":"203.0.113.42, 198.51.100.7, 192.0.2.15, 10.0.0.99"}'
}
# ============================================================================
# CLUSTER CATEGORY (group: cluster)
# ============================================================================
test_cluster() {
echo ""
echo -e "${YELLOW}========================================${NC}"
echo -e "${YELLOW} CLUSTER - Quorum, split-brain, HA fencing${NC}"
echo -e "${YELLOW}========================================${NC}"
echo ""
# 1. split_brain
send_test "split_brain" \
'{"type":"split_brain","component":"cluster","severity":"critical","title":"SPLIT-BRAIN detected","body":"Cluster split-brain condition detected.\nQuorum status: No quorum - 1/3 nodes visible","quorum":"No quorum - 1/3 nodes visible"}'
# 2. node_disconnect
send_test "node_disconnect" \
'{"type":"node_disconnect","component":"corosync","severity":"critical","title":"Node disconnected","body":"Node pve-node3 has disconnected from the cluster.","node_name":"pve-node3"}'
# 3. node_reconnect
send_test "node_reconnect" \
'{"type":"node_reconnect","component":"corosync","severity":"info","title":"Node reconnected","body":"Node pve-node3 has reconnected to the cluster.","node_name":"pve-node3"}'
# 4. burst_cluster
send_test "burst_cluster" \
'{"type":"burst_cluster","component":"cluster","severity":"critical","title":"Cluster flapping detected (6 changes)","body":"Cluster state changed 6 times in 5m.\nNodes: pve-node2, pve-node3","count":"6","window":"5m","entity_list":"pve-node2, pve-node3"}'
}
# ============================================================================
# BURST AGGREGATION TESTS (send rapid events to trigger burst detection)
# ============================================================================
test_burst() {
echo ""
echo -e "${YELLOW}========================================${NC}"
echo -e "${YELLOW} BURST - Rapid events to trigger aggregation${NC}"
echo -e "${YELLOW}========================================${NC}"
echo ""
echo -e "${BLUE} Sending 5 rapid auth_fail events (should trigger burst_auth_fail)...${NC}"
for i in $(seq 1 5); do
curl -s -X POST "$API" \
-H "Content-Type: application/json" \
-d "{\"type\":\"auth_fail\",\"component\":\"auth\",\"severity\":\"warning\",\"title\":\"Auth fail from 10.0.0.$i\",\"body\":\"Failed login from 10.0.0.$i\",\"source_ip\":\"10.0.0.$i\"}" > /dev/null
echo -e " ${CYAN}Sent auth_fail $i/5${NC}"
sleep 0.5
done
echo -e " ${GREEN}Done. Wait ~10s for burst aggregation...${NC}"
sleep 10
echo ""
echo -e "${BLUE} Sending 4 rapid disk_io_error events (should trigger burst_disk_io)...${NC}"
for i in $(seq 1 4); do
curl -s -X POST "$API" \
-H "Content-Type: application/json" \
-d "{\"type\":\"disk_io_error\",\"component\":\"smart\",\"severity\":\"critical\",\"title\":\"I/O error on /dev/sd${i}\",\"body\":\"Error on device\",\"device\":\"/dev/sd${i}\"}" > /dev/null
echo -e " ${CYAN}Sent disk_io_error $i/4${NC}"
sleep 0.5
done
echo -e " ${GREEN}Done. Wait ~10s for burst aggregation...${NC}"
sleep 10
echo ""
echo -e "${BLUE} Sending 3 rapid node_disconnect events (should trigger burst_cluster)...${NC}"
for i in $(seq 1 3); do
curl -s -X POST "$API" \
-H "Content-Type: application/json" \
-d "{\"type\":\"node_disconnect\",\"component\":\"corosync\",\"severity\":\"critical\",\"title\":\"Node pve-node$i disconnected\",\"body\":\"Node lost\",\"node_name\":\"pve-node$i\"}" > /dev/null
echo -e " ${CYAN}Sent node_disconnect $i/3${NC}"
sleep 0.5
done
echo -e " ${GREEN}Done. Wait ~10s for burst aggregation...${NC}"
sleep 10
}
# ============================================================================
# MAIN
# ============================================================================
echo ""
echo -e "${BOLD}============================================================${NC}"
echo -e "${BOLD} ProxMenux Notification System - Complete Test Suite${NC}"
echo -e "${BOLD}============================================================${NC}"
echo -e " API: $API"
echo -e " Pause: ${PAUSE}s between tests"
echo ""
# Check that the service is reachable
status=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:8008/api/notifications/status" 2>/dev/null)
if [ "$status" != "200" ]; then
echo -e "${RED}ERROR: Notification service not reachable (HTTP $status)${NC}"
echo -e " Make sure ProxMenux Monitor is running."
exit 1
fi
echo -e "${GREEN}Service is reachable.${NC}"
# Parse argument
category="${1:-all}"
case "$category" in
system) test_system ;;
vm_ct) test_vm_ct ;;
backup) test_backup ;;
resources) test_resources ;;
storage) test_storage ;;
network) test_network ;;
security) test_security ;;
cluster) test_cluster ;;
burst) test_burst ;;
all)
test_system
test_vm_ct
test_backup
test_resources
test_storage
test_network
test_security
test_cluster
test_burst
;;
*)
echo -e "${RED}Unknown category: $category${NC}"
echo "Usage: $0 [system|vm_ct|backup|resources|storage|network|security|cluster|burst|all]"
exit 1
;;
esac
# ============================================================================
# SUMMARY
# ============================================================================
echo ""
echo -e "${BOLD}============================================================${NC}"
echo -e "${BOLD} SUMMARY${NC}"
echo -e "${BOLD}============================================================${NC}"
echo -e " Total tests: $test_count"
echo -e " ${GREEN}Accepted:${NC} $pass_count"
echo -e " ${RED}Rejected:${NC} $fail_count"
echo ""
echo -e " Check your notification channels for the messages."
echo -e " Note: Some events may be filtered by your current settings"
echo -e " (severity filter, disabled categories, disabled individual events)."
echo ""
echo -e " To check notification history (all events):"
echo -e " ${CYAN}curl -s 'http://127.0.0.1:8008/api/notifications/history?limit=200' | python3 -m json.tool${NC}"
echo ""
echo -e " To count events by type:"
echo -e " ${CYAN}curl -s 'http://127.0.0.1:8008/api/notifications/history?limit=200' | python3 -c \"import sys,json; h=json.load(sys.stdin)['history']; [print(f' {t}: {c}') for t,c in sorted(dict((e['event_type'],sum(1 for x in h if x['event_type']==e['event_type'])) for e in h).items())]\"${NC}
echo ""
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""
Test script to simulate a disk error and verify observation recording.
Usage: python3 test_disk_observation.py [device_name] [error_type]
Examples:
python3 test_disk_observation.py sdh io_error
python3 test_disk_observation.py sdh smart_error
python3 test_disk_observation.py sdh fs_error
"""
import sys
import os
# Add possible module locations to path
script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, script_dir)
sys.path.insert(0, '/usr/local/share/proxmenux')
sys.path.insert(0, '/tmp/.mount_ProxMeztyU13/usr/bin') # AppImage mount point
# Try to find the module
for path in sys.path:
if os.path.exists(os.path.join(path, 'health_persistence.py')):
print(f"[INFO] Found health_persistence.py in: {path}")
break
from health_persistence import HealthPersistence
from datetime import datetime
def main():
device_name = sys.argv[1] if len(sys.argv) > 1 else 'sdh'
error_type = sys.argv[2] if len(sys.argv) > 2 else 'io_error'
# Known serial for sdh (WDC 2TB)
serial_map = {
'sdh': 'WD-WX72A30AA72R',
'nvme0n1': '2241E675EA6C',
'nvme1n1': '2241E675EBE6',
'sda': '22440F443504',
'sdb': 'WWZ1SJ18',
'sdc': '52X0A0D9FZ1G',
'sdd': '50026B7784446E63',
'sde': '22440F442105',
'sdf': 'WRQ0X2GP',
'sdg': '23Q0A0MPFZ1G',
}
serial = serial_map.get(device_name, None)
# Error messages by type
error_messages = {
'io_error': f'Test I/O error on /dev/{device_name}: sector read failed at LBA 12345678',
'smart_error': f'/dev/{device_name}: SMART warning - 1 Currently unreadable (pending) sectors detected',
'fs_error': f'EXT4-fs error (device {device_name}1): inode 123456: block 789012: error reading data',
}
error_signatures = {
'io_error': f'io_test_{device_name}',
'smart_error': f'smart_test_{device_name}',
'fs_error': f'fs_test_{device_name}',
}
message = error_messages.get(error_type, f'Test error on /dev/{device_name}')
signature = error_signatures.get(error_type, f'test_{device_name}')
print(f"\n{'='*60}")
print(f"Testing Disk Observation Recording")
print(f"{'='*60}")
print(f"Device: /dev/{device_name}")
print(f"Serial: {serial or 'Unknown'}")
print(f"Error Type: {error_type}")
print(f"Message: {message}")
print(f"Signature: {signature}")
print(f"{'='*60}\n")
# Initialize persistence
hp = HealthPersistence()
# Record the observation
print("[1] Recording observation...")
hp.record_disk_observation(
device_name=device_name,
serial=serial,
error_type=error_type,
error_signature=signature,
raw_message=message,
severity='warning'
)
print(" OK - Observation recorded\n")
# Query observations for this device
print("[2] Querying observations for this device...")
observations = hp.get_disk_observations(device_name=device_name, serial=serial)
if observations:
print(f" Found {len(observations)} observation(s):\n")
for obs in observations:
print(f" ID: {obs['id']}")
print(f" Type: {obs['error_type']}")
print(f" Signature: {obs['error_signature']}")
print(f" Message: {obs['raw_message'][:80]}...")
print(f" Severity: {obs['severity']}")
print(f" First: {obs['first_occurrence']}")
print(f" Last: {obs['last_occurrence']}")
print(f" Count: {obs['occurrence_count']}")
print(f" Dismissed: {obs['dismissed']}")
print()
else:
print(" No observations found!\n")
# Also show the disk registry
print("[3] Checking disk registry...")
all_devices = hp.get_all_observed_devices()
for dev in all_devices:
if dev.get('device_name') == device_name or dev.get('serial') == serial:
print(f" Found in registry:")
print(f" ID: {dev.get('id')}")
print(f" Device: {dev.get('device_name')}")
print(f" Serial: {dev.get('serial')}")
print(f" First seen: {dev.get('first_seen')}")
print(f" Last seen: {dev.get('last_seen')}")
print()
print(f"{'='*60}")
print("Test complete! Check the Storage section in the UI.")
print(f"The disk /dev/{device_name} should now show an observations badge.")
print(f"{'='*60}\n")
if __name__ == '__main__':
main()
+732
View File
@@ -0,0 +1,732 @@
#!/bin/bash
# ============================================================================
# ProxMenux - Real Proxmox Event Simulator
# ============================================================================
# This script triggers ACTUAL events on Proxmox so that PVE's notification
# system fires real webhooks through the full pipeline:
#
# PVE event -> PVE notification -> webhook POST -> our pipeline -> Telegram
#
# Unlike test_all_notifications.sh (which injects directly via API), this
# script makes Proxmox generate the events itself.
#
# Usage:
# chmod +x test_real_events.sh
# ./test_real_events.sh # interactive menu
# ./test_real_events.sh disk # run disk tests only
# ./test_real_events.sh backup # run backup tests only
# ./test_real_events.sh all # run all tests
# ============================================================================
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
API="http://127.0.0.1:8008"
LOG_FILE="/tmp/proxmenux_real_test_$(date +%Y%m%d_%H%M%S).log"
# ── Helpers ─────────────────────────────────────────────────────
log() { echo -e "$1" | tee -a "$LOG_FILE"; }
header() {
echo "" | tee -a "$LOG_FILE"
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" | tee -a "$LOG_FILE"
echo -e "${BOLD} $1${NC}" | tee -a "$LOG_FILE"
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" | tee -a "$LOG_FILE"
}
warn() { log "${YELLOW} [!] $1${NC}"; }
ok() { log "${GREEN} [OK] $1${NC}"; }
fail() { log "${RED} [FAIL] $1${NC}"; }
info() { log "${CYAN} [i] $1${NC}"; }
confirm() {
echo ""
echo -e "${YELLOW} $1${NC}"
echo -ne " Continue? [Y/n]: "
read -r ans
[[ -z "$ans" || "$ans" =~ ^[Yy] ]]
}
wait_webhook() {
local seconds=${1:-10}
log " Waiting ${seconds}s for webhook delivery..."
sleep "$seconds"
}
snapshot_history() {
curl -s "${API}/api/notifications/history?limit=200" 2>/dev/null | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
count = len(data.get('history', []))
print(count)
except:
print(0)
" 2>/dev/null || echo "0"
}
check_new_events() {
local before=$1
local after
after=$(snapshot_history)
local diff=$((after - before))
if [ "$diff" -gt 0 ]; then
ok "Received $diff new notification(s) via webhook"
# Show the latest events
curl -s "${API}/api/notifications/history?limit=$((diff + 2))" 2>/dev/null | python3 -c "
import sys, json
data = json.load(sys.stdin)
for h in data.get('history', [])[:$diff]:
sev = h.get('severity', '?')
icon = {'CRITICAL': ' RED', 'WARNING': ' YEL', 'INFO': ' BLU'}.get(sev, ' ???')
print(f'{icon} {h[\"event_type\"]:25s} {h.get(\"title\", \"\")[:60]}')
" 2>/dev/null | tee -a "$LOG_FILE"
else
warn "No new notifications detected (may need more time or check filters)"
fi
}
# ── Pre-flight checks ──────────────────────────────────────────
preflight() {
header "Pre-flight Checks"
# Check if running as root
if [ "$(id -u)" -ne 0 ]; then
fail "This script must be run as root"
exit 1
fi
ok "Running as root"
# Check ProxMenux is running
if curl -s "${API}/api/health" >/dev/null 2>&1; then
ok "ProxMenux Monitor is running"
else
fail "ProxMenux Monitor not reachable at ${API}"
exit 1
fi
# Check webhook is configured by querying PVE directly
if pvesh get /cluster/notifications/endpoints/webhook --output-format json 2>/dev/null | python3 -c "
import sys, json
endpoints = json.load(sys.stdin)
found = any('proxmenux' in e.get('name','').lower() for e in (endpoints if isinstance(endpoints, list) else [endpoints]))
exit(0 if found else 1)
" 2>/dev/null; then
ok "PVE webhook endpoint 'proxmenux-webhook' is configured"
else
warn "PVE webhook may not be configured. Run setup from the UI first."
if ! confirm "Continue anyway?"; then
exit 1
fi
fi
# Check notification config
# API returns { config: { enabled: true/false/'true'/'false', ... }, success: true }
if curl -s "${API}/api/notifications/settings" 2>/dev/null | python3 -c "
import sys, json
d = json.load(sys.stdin)
cfg = d.get('config', d)
enabled = cfg.get('enabled', False)
exit(0 if enabled is True or str(enabled).lower() == 'true' else 1)
" 2>/dev/null; then
ok "Notifications are enabled"
else
fail "Notifications are NOT enabled. Enable them in the UI first."
exit 1
fi
# Re-run webhook setup to ensure priv config and body template exist
info "Re-configuring PVE webhook (ensures priv config + body template)..."
local setup_result
setup_result=$(curl -s -X POST "${API}/api/notifications/proxmox/setup-webhook" 2>/dev/null)
if echo "$setup_result" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get('configured') else 1)" 2>/dev/null; then
ok "PVE webhook re-configured successfully"
else
local setup_err
setup_err=$(echo "$setup_result" | python3 -c "import sys,json; print(json.load(sys.stdin).get('error','unknown'))" 2>/dev/null)
warn "Webhook setup returned: ${setup_err}"
warn "PVE webhook events may not work. Manual commands below:"
echo "$setup_result" | python3 -c "
import sys, json
d = json.load(sys.stdin)
for cmd in d.get('fallback_commands', []):
print(f' {cmd}')
" 2>/dev/null
if ! confirm "Continue anyway?"; then
exit 1
fi
fi
# Find a VM/CT for testing
VMID=""
VMNAME=""
VMTYPE=""
# Try to find a stopped CT first (safest)
local cts
cts=$(pvesh get /cluster/resources --type vm --output-format json 2>/dev/null || echo "[]")
# Look for a stopped container
VMID=$(echo "$cts" | python3 -c "
import sys, json
vms = json.load(sys.stdin)
# Prefer stopped CTs, then stopped VMs
for v in sorted(vms, key=lambda x: (0 if x.get('type')=='lxc' else 1, 0 if x.get('status')=='stopped' else 1)):
if v.get('status') == 'stopped':
print(v.get('vmid', ''))
break
" 2>/dev/null || echo "")
if [ -n "$VMID" ]; then
VMTYPE=$(echo "$cts" | python3 -c "
import sys, json
vms = json.load(sys.stdin)
for v in vms:
if str(v.get('vmid')) == '$VMID':
print(v.get('type', 'qemu'))
break
" 2>/dev/null)
VMNAME=$(echo "$cts" | python3 -c "
import sys, json
vms = json.load(sys.stdin)
for v in vms:
if str(v.get('vmid')) == '$VMID':
print(v.get('name', 'unknown'))
break
" 2>/dev/null)
ok "Found stopped ${VMTYPE} for testing: ${VMID} (${VMNAME})"
else
warn "No stopped VM/CT found. Backup tests will use ID 0 (host backup)."
fi
# List available storage
info "Available storage:"
pvesh get /storage --output-format json 2>/dev/null | python3 -c "
import sys, json
stores = json.load(sys.stdin)
for s in stores:
sid = s.get('storage', '?')
stype = s.get('type', '?')
content = s.get('content', '?')
print(f' {sid:20s} type={stype:10s} content={content}')
" 2>/dev/null | tee -a "$LOG_FILE" || warn "Could not list storage"
echo ""
log " Log file: ${LOG_FILE}"
}
# ============================================================================
# TEST CATEGORY: DISK ERRORS
# ============================================================================
test_disk() {
header "DISK ERROR TESTS"
# ── Test D1: SMART error injection ──
log ""
log "${BOLD} Test D1: SMART error log injection${NC}"
info "Writes a simulated SMART error to syslog so JournalWatcher catches it."
info "This tests the journal -> notification_events -> pipeline flow."
local before
before=$(snapshot_history)
# Inject a realistic SMART error into the system journal
logger -t kernel -p kern.err "ata1.00: exception Emask 0x0 SAct 0x0 SErr 0x0 action 0x6 frozen"
sleep 1
logger -t kernel -p kern.crit "ata1.00: failed command: READ FPDMA QUEUED"
sleep 1
logger -t smartd -p daemon.warning "Device: /dev/sda [SAT], 1 Currently unreadable (pending) sectors"
wait_webhook 8
check_new_events "$before"
# ── Test D2: ZFS error simulation ──
log ""
log "${BOLD} Test D2: ZFS scrub error simulation${NC}"
# Check if ZFS is available
if command -v zpool >/dev/null 2>&1; then
local zpools
zpools=$(zpool list -H -o name 2>/dev/null || echo "")
if [ -n "$zpools" ]; then
local pool
pool=$(echo "$zpools" | head -1)
info "ZFS pool found: ${pool}"
info "Injecting ZFS checksum error into syslog (non-destructive)."
before=$(snapshot_history)
# Simulate ZFS error events via syslog (non-destructive)
logger -t kernel -p kern.warning "ZFS: pool '${pool}' has experienced an error"
sleep 1
logger -t zfs-module -p daemon.err "CHECKSUM error on ${pool}:mirror-0/sda: zio error"
wait_webhook 8
check_new_events "$before"
else
warn "ZFS installed but no pools found. Skipping ZFS test."
fi
else
warn "ZFS not installed. Skipping ZFS test."
fi
# ── Test D3: Filesystem space pressure ──
log ""
log "${BOLD} Test D3: Disk space pressure simulation${NC}"
info "Creates a large temporary file to fill disk, triggering space warnings."
info "The Health Monitor should detect low disk space within ~60s."
# Check current free space on /
local free_pct
free_pct=$(df / | tail -1 | awk '{print 100-$5}' | tr -d '%')
info "Current free space on /: ${free_pct}%"
if [ "$free_pct" -gt 15 ]; then
info "Disk has ${free_pct}% free. Need to reduce below threshold for test."
# Calculate how much to fill (leave only 8% free)
local total_k free_k fill_k
total_k=$(df / | tail -1 | awk '{print $2}')
free_k=$(df / | tail -1 | awk '{print $4}')
fill_k=$((free_k - (total_k * 8 / 100)))
if [ "$fill_k" -gt 0 ] && [ "$fill_k" -lt 50000000 ]; then
info "Will create ${fill_k}KB temp file to simulate low space."
if confirm "This will temporarily fill disk to ~92% on /. Safe to proceed?"; then
before=$(snapshot_history)
dd if=/dev/zero of=/tmp/.proxmenux_disk_test bs=1024 count="$fill_k" 2>/dev/null || true
ok "Temp file created. Disk pressure active."
info "Waiting 90s for Health Monitor to detect low space..."
# Wait for health monitor polling cycle
for i in $(seq 1 9); do
echo -ne "\r Waiting... ${i}0/90s"
sleep 10
done
echo ""
# Clean up immediately
rm -f /tmp/.proxmenux_disk_test
ok "Temp file removed. Disk space restored."
check_new_events "$before"
else
warn "Skipped disk pressure test."
fi
else
warn "Cannot safely fill disk (would need ${fill_k}KB). Skipping."
fi
else
warn "Disk already at ${free_pct}% free. Health Monitor may already be alerting."
fi
# ── Test D4: I/O error in syslog ──
log ""
log "${BOLD} Test D4: Generic I/O error injection${NC}"
info "Injects I/O errors into syslog for JournalWatcher."
before=$(snapshot_history)
logger -t kernel -p kern.err "Buffer I/O error on dev sdb1, logical block 0, async page read"
sleep 1
logger -t kernel -p kern.err "EXT4-fs error (device sdb1): ext4_find_entry:1455: inode #2: comm ls: reading directory lblock 0"
wait_webhook 8
check_new_events "$before"
}
# ============================================================================
# TEST CATEGORY: BACKUP EVENTS
# ============================================================================
test_backup() {
header "BACKUP EVENT TESTS"
local backup_storage=""
# Find backup-capable storage
backup_storage=$(pvesh get /storage --output-format json 2>/dev/null | python3 -c "
import sys, json
stores = json.load(sys.stdin)
for s in stores:
content = s.get('content', '')
if 'backup' in content or 'vztmpl' in content:
print(s.get('storage', ''))
break
# Fallback: try 'local'
else:
for s in stores:
if s.get('storage') == 'local':
print('local')
break
" 2>/dev/null || echo "local")
info "Using backup storage: ${backup_storage}"
# ── Test B1: Successful vzdump backup ──
if [ -n "$VMID" ]; then
log ""
log "${BOLD} Test B1: Real vzdump backup (success)${NC}"
info "Running a real vzdump backup of ${VMTYPE} ${VMID} (${VMNAME})."
info "This triggers PVE's notification system with a real backup event."
if confirm "This will backup ${VMTYPE} ${VMID} to '${backup_storage}'. Proceed?"; then
local before
before=$(snapshot_history)
# Use snapshot mode for VMs (non-disruptive), stop mode for CTs
local bmode="snapshot"
if [ "$VMTYPE" = "lxc" ]; then
bmode="suspend"
fi
info "Starting vzdump (mode=${bmode}, compress=zstd)..."
if vzdump "$VMID" --storage "$backup_storage" --mode "$bmode" --compress zstd --notes-template "ProxMenux test backup" 2>&1 | tee -a "$LOG_FILE"; then
ok "vzdump completed successfully!"
else
warn "vzdump returned non-zero (check output above)"
fi
wait_webhook 12
check_new_events "$before"
# Clean up the test backup
info "Cleaning up test backup file..."
local latest_bak
latest_bak=$(find "/var/lib/vz/dump/" -name "vzdump-*-${VMID}-*" -type f -newer /tmp/.proxmenux_bak_marker 2>/dev/null | head -1 || echo "")
# Create a marker for cleanup
touch /tmp/.proxmenux_bak_marker 2>/dev/null || true
else
warn "Skipped backup success test."
fi
# ── Test B2: Failed vzdump backup ──
log ""
log "${BOLD} Test B2: vzdump backup failure (invalid storage)${NC}"
info "Attempting backup to non-existent storage to trigger a backup failure event."
before=$(snapshot_history)
# This WILL fail because the storage doesn't exist
info "Starting vzdump to fake storage (will fail intentionally)..."
vzdump "$VMID" --storage "nonexistent_storage_12345" --mode snapshot 2>&1 | tail -5 | tee -a "$LOG_FILE" || true
warn "vzdump failed as expected (this is intentional)."
wait_webhook 12
check_new_events "$before"
else
warn "No VM/CT available for backup tests."
info "You can create a minimal LXC container for testing:"
info " pct create 9999 local:vztmpl/debian-12-standard_12.2-1_amd64.tar.zst --storage local-lvm --memory 128 --cores 1"
fi
# ── Test B3: Snapshot create/delete ──
if [ -n "$VMID" ] && [ "$VMTYPE" = "qemu" ]; then
log ""
log "${BOLD} Test B3: VM Snapshot create & delete${NC}"
info "Creating a snapshot of VM ${VMID} to test snapshot events."
if confirm "Create snapshot 'proxmenux_test' on VM ${VMID}?"; then
local before
before=$(snapshot_history)
if qm snapshot "$VMID" proxmenux_test --description "ProxMenux test snapshot" 2>&1 | tee -a "$LOG_FILE"; then
ok "Snapshot created!"
else
warn "Snapshot creation returned non-zero"
fi
wait_webhook 10
check_new_events "$before"
# Clean up snapshot
info "Cleaning up test snapshot..."
qm delsnapshot "$VMID" proxmenux_test 2>/dev/null || true
ok "Snapshot removed."
fi
elif [ -n "$VMID" ] && [ "$VMTYPE" = "lxc" ]; then
log ""
log "${BOLD} Test B3: CT Snapshot create & delete${NC}"
info "Creating a snapshot of CT ${VMID}."
if confirm "Create snapshot 'proxmenux_test' on CT ${VMID}?"; then
local before
before=$(snapshot_history)
if pct snapshot "$VMID" proxmenux_test --description "ProxMenux test snapshot" 2>&1 | tee -a "$LOG_FILE"; then
ok "Snapshot created!"
else
warn "Snapshot creation returned non-zero"
fi
wait_webhook 10
check_new_events "$before"
# Clean up
info "Cleaning up test snapshot..."
pct delsnapshot "$VMID" proxmenux_test 2>/dev/null || true
ok "Snapshot removed."
fi
fi
# ── Test B4: PVE scheduled backup notification ──
log ""
log "${BOLD} Test B4: Trigger PVE notification system directly${NC}"
info "Using 'pvesh create /notifications/endpoints/...' to test PVE's own system."
info "This sends a test notification through PVE, which should hit our webhook."
local before
before=$(snapshot_history)
# PVE 8.x has a test endpoint for notifications
if pvesh create /notifications/targets/test --target proxmenux-webhook 2>&1 | tee -a "$LOG_FILE"; then
ok "PVE test notification sent!"
else
# Try alternative method
info "Direct test not available. Trying via API..."
pvesh set /notifications/endpoints/webhook/proxmenux-webhook --test 1 2>/dev/null || \
warn "Could not send PVE test notification (requires PVE 8.1+)"
fi
wait_webhook 8
check_new_events "$before"
}
# ============================================================================
# TEST CATEGORY: VM/CT LIFECYCLE
# ============================================================================
test_vmct() {
header "VM/CT LIFECYCLE TESTS"
if [ -z "$VMID" ]; then
warn "No stopped VM/CT found for lifecycle tests."
info "Create a minimal CT: pct create 9999 local:vztmpl/debian-12-standard_12.2-1_amd64.tar.zst --storage local-lvm --memory 128 --cores 1"
return
fi
log ""
log "${BOLD} Test V1: Start ${VMTYPE} ${VMID} (${VMNAME})${NC}"
if confirm "Start ${VMTYPE} ${VMID}? It will be stopped again after the test."; then
local before
before=$(snapshot_history)
if [ "$VMTYPE" = "lxc" ]; then
pct start "$VMID" 2>&1 | tee -a "$LOG_FILE" || true
else
qm start "$VMID" 2>&1 | tee -a "$LOG_FILE" || true
fi
ok "Start command sent."
wait_webhook 10
check_new_events "$before"
# Wait a moment
sleep 5
# ── Test V2: Stop ──
log ""
log "${BOLD} Test V2: Stop ${VMTYPE} ${VMID}${NC}"
before=$(snapshot_history)
if [ "$VMTYPE" = "lxc" ]; then
pct stop "$VMID" 2>&1 | tee -a "$LOG_FILE" || true
else
qm stop "$VMID" 2>&1 | tee -a "$LOG_FILE" || true
fi
ok "Stop command sent."
wait_webhook 10
check_new_events "$before"
fi
}
# ============================================================================
# TEST CATEGORY: SYSTEM EVENTS (via syslog injection)
# ============================================================================
test_system() {
header "SYSTEM EVENT TESTS (syslog injection)"
# ── Test S1: Authentication failures ──
log ""
log "${BOLD} Test S1: SSH auth failure injection${NC}"
info "Injecting SSH auth failure messages into syslog."
local before
before=$(snapshot_history)
logger -t sshd -p auth.warning "Failed password for root from 192.168.1.200 port 44312 ssh2"
sleep 2
logger -t sshd -p auth.warning "Failed password for invalid user admin from 10.0.0.50 port 55123 ssh2"
sleep 2
logger -t sshd -p auth.warning "Failed password for root from 192.168.1.200 port 44315 ssh2"
wait_webhook 8
check_new_events "$before"
# ── Test S2: Firewall event ──
log ""
log "${BOLD} Test S2: Firewall drop event${NC}"
before=$(snapshot_history)
logger -t kernel -p kern.warning "pve-fw-reject: IN=vmbr0 OUT= MAC=00:11:22:33:44:55 SRC=10.0.0.99 DST=192.168.1.1 PROTO=TCP DPT=22 REJECT"
sleep 2
logger -t pvefw -p daemon.warning "firewall: blocked incoming connection from 10.0.0.99:45678 to 192.168.1.1:8006"
wait_webhook 8
check_new_events "$before"
# ── Test S3: Service failure ──
log ""
log "${BOLD} Test S3: Service failure injection${NC}"
before=$(snapshot_history)
logger -t systemd -p daemon.err "pvedaemon.service: Main process exited, code=exited, status=1/FAILURE"
sleep 1
logger -t systemd -p daemon.err "Failed to start Proxmox VE API Daemon."
wait_webhook 8
check_new_events "$before"
}
# ============================================================================
# SUMMARY & REPORT
# ============================================================================
show_summary() {
header "TEST SUMMARY"
info "Fetching full notification history..."
echo ""
curl -s "${API}/api/notifications/history?limit=200" 2>/dev/null | python3 -c "
import sys, json
from collections import Counter
data = json.load(sys.stdin)
history = data.get('history', [])
if not history:
print(' No notifications in history.')
sys.exit(0)
# Group by event_type
by_type = Counter(h['event_type'] for h in history)
# Group by severity
by_sev = Counter(h.get('severity', '?') for h in history)
# Group by source
by_src = Counter(h.get('source', '?') for h in history)
print(f' Total notifications: {len(history)}')
print()
sev_icons = {'CRITICAL': '\033[0;31mCRITICAL\033[0m', 'WARNING': '\033[1;33mWARNING\033[0m', 'INFO': '\033[0;36mINFO\033[0m'}
print(' By severity:')
for sev, count in by_sev.most_common():
icon = sev_icons.get(sev, sev)
print(f' {icon}: {count}')
print()
print(' By source:')
for src, count in by_src.most_common():
print(f' {src:20s}: {count}')
print()
print(' By event type:')
for etype, count in by_type.most_common():
print(f' {etype:30s}: {count}')
print()
print(' Latest 15 events:')
for h in history[:15]:
sev = h.get('severity', '?')
icon = {'CRITICAL': ' \033[0;31mRED\033[0m', 'WARNING': ' \033[1;33mYEL\033[0m', 'INFO': ' \033[0;36mBLU\033[0m'}.get(sev, ' ???')
ts = h.get('sent_at', '?')[:19]
src = h.get('source', '?')[:12]
print(f' {icon} {ts} {src:12s} {h[\"event_type\"]:25s} {h.get(\"title\", \"\")[:50]}')
" 2>/dev/null | tee -a "$LOG_FILE"
echo ""
info "Full log saved to: ${LOG_FILE}"
echo ""
info "To see all history:"
echo -e " ${CYAN}curl -s '${API}/api/notifications/history?limit=200' | python3 -m json.tool${NC}"
echo ""
info "To check Telegram delivery, look at your Telegram bot chat."
}
# ============================================================================
# INTERACTIVE MENU
# ============================================================================
show_menu() {
echo ""
echo -e "${BOLD} ProxMenux Real Event Test Suite${NC}"
echo ""
echo -e " ${CYAN}1)${NC} Disk error tests (SMART, ZFS, I/O, space pressure)"
echo -e " ${CYAN}2)${NC} Backup tests (vzdump success/fail, snapshots)"
echo -e " ${CYAN}3)${NC} VM/CT lifecycle tests (start/stop real VMs)"
echo -e " ${CYAN}4)${NC} System event tests (auth, firewall, service failures)"
echo -e " ${CYAN}5)${NC} Run ALL tests"
echo -e " ${CYAN}6)${NC} Show summary report"
echo -e " ${CYAN}q)${NC} Exit"
echo ""
echo -ne " Select: "
}
# ── Main ────────────────────────────────────────────────────────
main() {
local mode="${1:-menu}"
echo ""
echo -e "${BOLD}============================================================${NC}"
echo -e "${BOLD} ProxMenux - Real Proxmox Event Simulator${NC}"
echo -e "${BOLD}============================================================${NC}"
echo -e " Tests REAL events through the full PVE -> webhook pipeline."
echo -e " Log file: ${CYAN}${LOG_FILE}${NC}"
echo ""
preflight
case "$mode" in
disk) test_disk; show_summary ;;
backup) test_backup; show_summary ;;
vmct) test_vmct; show_summary ;;
system) test_system; show_summary ;;
all)
test_disk
test_backup
test_vmct
test_system
show_summary
;;
menu|*)
while true; do
show_menu
read -r choice
case "$choice" in
1) test_disk ;;
2) test_backup ;;
3) test_vmct ;;
4) test_system ;;
5) test_disk; test_backup; test_vmct; test_system; show_summary; break ;;
6) show_summary ;;
q|Q) echo " Bye!"; break ;;
*) warn "Invalid option" ;;
esac
done
;;
esac
}
main "${1:-menu}"
+46
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
@@ -208,6 +252,8 @@ export interface HardwareData {
fans?: Fan[]
power_supplies?: PowerSupply[]
ups?: UPS | UPS[]
coral_tpus?: CoralTPU[]
usb_devices?: UsbDevice[]
}
export const fetcher = async (url: string) => {
+335
View File
@@ -1,3 +1,338 @@
# <img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/logo.png" alt="ProxMenux logo" width="40"/> 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*
![ProxMenux Offline](https://macrimi.github.io/ProxMenux/ProxMenux_offline.png)
---
### Added
- **Offline Execution Mode (no GitHub dependency)**
All ProxMenux core scripts now run **entirely locally**, without requiring live requests to GitHub (`raw.githubusercontent.com`).
This change provides:
- Greater stability during execution
- No interruptions due to network timeouts or regional GitHub blocks
- Support for **offline or isolated environments**
⚠️ This update resolves recent issues where users in certain regions were unable to run scripts due to CDN or TLS filtering errors while downloading `.sh` files from GitHub raw URLs.
**🎖 Special Acknowledgment: @cod378**
This offline conversion has been made possible thanks to the extraordinary work of **@cod378**,
who redesigned the entire internal logic of the installer and updater, refactored the file management system,
and implemented the new fully local execution workflow.
Without his collaboration, dedication, and technical contribution, this transformation would not have been possible.
- **ProxMenux Monitor v1.0.1**
This update brings a major leap in the **ProxMenux Monitor** interface.
New features and improvements:
- `Proxy Support`: Access ProxMenux through reverse proxies with full functionality
- `Authentication System`: Secure your dashboard with password protection
- `Two-Factor Authentication (2FA)`: Optional TOTP support for enhanced security
- `PCIe Link Speed Detection`: View NVMe connection speeds and detect performance bottlenecks
- `Enhanced Storage Display`: Auto-formats disk sizes (GB → TB when appropriate)
- `SATA/SAS Interface Info`: Detect and show storage type (SATA, SAS, NVMe, etc.)
- `Health Monitoring System`: Built-in system health check with dismissible alerts
- Improved rendering across browsers and better performance
- **Helper Scripts Menu (Mirror Support)**
The `Helper Scripts` menu now:
- Detects **mirror URLs** and shows alternative download options when available
- Lists available OS versions when a helper script is version-dependent (e.g. template installers)
---
### Fixed
- Minor fixes and refinements throughout the codebase to ensure full offline compatibility and a smoother user experience.
## 2025-09-04
### New version v1.1.7
+32 -29
View File
@@ -1,34 +1,37 @@
ProxMenux An Interactive Menu for Proxmox VE Management
ProxMenux - An Interactive Menu for Proxmox VE Management
Copyright (c) 2025 MacRimi
This project is licensed under the Creative Commons Attribution-NonCommercial 4.0 International License.
See the full license terms below.
======================================================================
LICENSE: GNU General Public License v3.0 (GPL-3.0)
======================================================================
ProxMenux is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
Under this license:
1. Attribution: You must give appropriate credit to the original author (MacRimi).
2. Copyleft: If you remix, transform, or build upon ProxMenux, you must
distribute your contributions under the same GPL-3.0 license.
3. Source Code: Anyone distributing a modified version must make the
source code available.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
======================================================================
Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)
This is a human-readable summary of (and not a substitute for) the license.
You may obtain a copy of the full license at:
https://creativecommons.org/licenses/by-nc/4.0/
You are free to:
- Share — copy and redistribute the material in any medium or format.
- Adapt — remix, transform, and build upon the material.
Under the following terms:
- Attribution — You must give appropriate credit, provide a link to the license,
and indicate if changes were made.
- NonCommercial — You may not use the material for commercial purposes.
No additional restrictions — You may not apply legal terms or technological
measures that legally restrict others from doing anything the license permits.
Disclaimer:
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, AND NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
DISCLAIMER:
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING
FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
+101 -6
View File
@@ -57,25 +57,120 @@ Then, follow the on-screen options to manage your Proxmox server efficiently.
---
## 📌 System Requirements
🖥 **Compatible with:**
- Proxmox VE 8.x and 9.x
📦 **Dependencies:**
- `bash`, `curl`, `wget`, `jq`, `whiptail`, `python3-venv` (These dependencies are installed automatically during setup.)
- **Translations are handled in a Python virtual environment using `googletrans-env`.**
## 🧪 Beta Program
Want to try the latest features before the official release and help shape the final version?
The **ProxMenux Beta Program** gives early access to new functionality — including the newest builds of ProxMenux Monitor — directly from the `develop` branch. Beta builds may contain bugs or incomplete features. Your feedback is what helps fix them before the stable release.
**Install the beta version:**
```bash
bash -c "$(wget -qLO - https://raw.githubusercontent.com/MacRimi/ProxMenux/develop/install_proxmenux_beta.sh)"
```
**What to expect:**
- You'll get new features and Monitor builds before anyone else
- Some things may not work perfectly — that's expected and normal
- When a stable release is published, ProxMenux will notify you on the next `menu` launch and offer to switch automatically
**How to report issues:**
Open a [GitHub Issue](https://github.com/MacRimi/ProxMenux/issues) and include:
- What you did and what you expected to happen
- Any error messages shown on screen
- Logs from the Monitor if relevant:
```bash
journalctl -u proxmenux-monitor -n 50
```
> 💙 Thank you for being part of the beta program. Your help makes ProxMenux better for everyone.
---
## 🖥️ ProxMenux Monitor
ProxMenux Monitor is an integrated web dashboard that provides real-time visibility into your Proxmox infrastructure — accessible from any browser on your network, without needing a terminal.
**What it offers:**
- Real-time monitoring of CPU, RAM, disk usage and network traffic
- Overview of running VMs and LXC containers with status indicators
- Login authentication to protect access
- Two-Factor Authentication (2FA) with TOTP support
- Reverse proxy support (Nginx / Traefik)
- Designed to work across desktop and mobile devices
**Access:**
Once installed, the dashboard is available at:
```
http://<your-proxmox-ip>:8008
```
The Monitor is installed automatically as part of the standard ProxMenux installation and runs as a systemd service (`proxmenux-monitor.service`) that starts automatically on boot.
**Useful commands:**
```bash
# Check service status
systemctl status proxmenux-monitor
# View logs
journalctl -u proxmenux-monitor -n 50
# Restart the service
systemctl restart proxmenux-monitor
```
---
## 🔧 Dependencies
The following dependencies are installed automatically during setup:
| Package | Purpose |
|---|---|
| `dialog` | Interactive terminal menus |
| `curl` | Downloads and connectivity checks |
| `jq` | JSON processing |
| `git` | Repository cloning and updates |
| `python3` + `python3-venv` | Translation support *(Translation version only)* |
| `googletrans` | Google Translate library *(Translation version only)* |
---
## ⭐ Support the Project!
If you find **ProxMenux** useful, consider giving it a ⭐ on GitHub to help others discover it!
## 🤝 Contributing
Contributions, bug reports and feature suggestions are welcome!
- 🐛 [Report a bug](https://github.com/MacRimi/ProxMenux/issues/new)
- 💡 [Suggest a feature](https://github.com/MacRimi/ProxMenux/discussions)
- 🔀 [Submit a pull request](https://github.com/MacRimi/ProxMenux/pulls)
---
## Star History
[![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
Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

+58 -5
View File
@@ -7,7 +7,7 @@
# Contributors : cod378
# Subproject : ProxMenux Monitor (System Health & Web Dashboard)
# Copyright : (c) 2024-2025 MacRimi
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.4
# Last Updated : 12/11/2025
# ==========================================================
@@ -440,7 +440,7 @@ update_config() {
local status="$2"
local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
local tracked_components=("dialog" "curl" "jq" "python3" "python3-venv" "python3-pip" "virtual_environment" "pip" "googletrans" "proxmenux_monitor")
local tracked_components=("dialog" "curl" "jq" "git" "python3" "python3-venv" "python3-pip" "virtual_environment" "pip" "googletrans" "proxmenux_monitor")
if [[ " ${tracked_components[@]} " =~ " ${component} " ]]; then
mkdir -p "$(dirname "$CONFIG_FILE")"
@@ -727,7 +727,17 @@ install_normal_version() {
update_config "jq" "already_installed"
fi
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
@@ -741,9 +751,42 @@ install_normal_version() {
update_config "$pkg" "already_installed"
fi
done
if ! command -v git > /dev/null 2>&1; then
msg_info "Installing git (required to clone the ProxMenux repository)."
if [ -z "${APT_UPDATED:-}" ]; then
apt-get update -y > /dev/null 2>&1 || true
APT_UPDATED=1
fi
if ! apt-get install -y git > /dev/null 2>&1; then
msg_error "Failed to install git. Please run 'apt-get install git' manually and rerun the installer."
update_config "git" "failed"
return 1
fi
if ! command -v git > /dev/null 2>&1; then
msg_error "Git is still not available after installation. Aborting to avoid a broken setup."
update_config "git" "failed"
return 1
fi
update_config "git" "installed"
else
update_config "git" "already_installed"
fi
msg_ok "jq, dialog, curl and git installed successfully."
((current_step++))
show_progress $current_step $total_steps "Install ProxMenux repository"
@@ -778,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"
@@ -1037,6 +1088,8 @@ install_proxmenux() {
type_text "To run ProxMenux, simply execute this command in the console or terminal:"
echo -e "${YWB} menu${CL}"
echo
# -------
exit 0
}
if [ "$(id -u)" -ne 0 ]; then
+13368 -2506
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+126 -57
View File
@@ -4,92 +4,161 @@
# ProxMenux - A menu-driven script for Proxmox VE management
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.1
# Last Updated: 04/07/2025
# Copyright : (c) 2024-2025 MacRimi
# License : GPL-3.0 (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.2
# Last Updated: 18/03/2026
# ==========================================================
# Description:
# This script serves as the main entry point for ProxMenux,
# a menu-driven tool designed for Proxmox VE management.
#
# - Displays the ProxMenux logo on startup.
# - Loads necessary configurations and language settings.
# - Checks for available updates and installs them if confirmed.
# - Downloads and executes the latest main menu script.
#
# Key Features:
# - Ensures ProxMenux is always up-to-date by fetching the latest version.
# - Uses whiptail for interactive menus and language selection.
# - Loads utility functions and translation support.
# - Maintains a cache system to improve performance.
# - Executes the ProxMenux main menu dynamically from the repository.
#
# This script ensures a streamlined and automated experience
# for managing Proxmox VE using ProxMenux.
# Main entry point for ProxMenux.
# - Loads configuration and utility functions.
# - Detects if running in Beta Program mode (develop branch).
# - Checks for updates from the appropriate branch (main or develop).
# - In beta mode: compares beta_version.txt; notifies when a stable
# release is available and prompts the user to switch.
# - Launches the main menu.
# ==========================================================
# Configuration ============================================
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
# ── Configuration ──────────────────────────────────────────
BASE_DIR="/usr/local/share/proxmenux"
LOCAL_SCRIPTS="$BASE_DIR/scripts"
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"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
REPO_MAIN="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
REPO_DEVELOP="https://raw.githubusercontent.com/MacRimi/ProxMenux/develop"
# =========================================================
# For now, update is not available in the local version.
# Take in mind that in future versions, updates must be
# a warning to update the .deb package
# =========================================================
# ── Load utilities ─────────────────────────────────────────
[[ -f "$UTILS_FILE" ]] && source "$UTILS_FILE"
: "${LOCAL_SCRIPTS:=/usr/local/share/proxmenux/scripts}"
# ── Detect beta mode ───────────────────────────────────────
# Returns 0 (true) if this install is part of the beta program.
is_beta() {
[[ -f "$CONFIG_FILE" ]] || return 1
local beta_flag
beta_flag=$(jq -r '.beta_program.status // empty' "$CONFIG_FILE" 2>/dev/null)
[[ "$beta_flag" == "active" ]]
}
# ── Check for updates ──────────────────────────────────────
check_updates() {
if is_beta; then
check_updates_beta
else
check_updates_stable
fi
}
# ── Stable update check (main branch) ─────────────────────
check_updates_stable() {
local VERSION_URL="$REPO_MAIN/version.txt"
local INSTALL_URL="$REPO_MAIN/install_proxmenux.sh"
local INSTALL_SCRIPT="$BASE_DIR/install_proxmenux.sh"
local REMOTE_VERSION
REMOTE_VERSION=$(curl -fsSL "$REPO_URL/version.txt" | head -n 1)
[[ ! -f "$LOCAL_VERSION_FILE" ]] && return 0
if [ -z "$REMOTE_VERSION" ]; then
return 0
fi
local REMOTE_VERSION LOCAL_VERSION
REMOTE_VERSION="$(curl -fsSL "$VERSION_URL" 2>/dev/null | head -n 1)"
[[ -z "$REMOTE_VERSION" ]] && return 0
local LOCAL_VERSION
LOCAL_VERSION=$(head -n 1 "$LOCAL_VERSION_FILE")
LOCAL_VERSION="$(head -n 1 "$LOCAL_VERSION_FILE" 2>/dev/null)"
[[ -z "$LOCAL_VERSION" ]] && return 0
[[ "$LOCAL_VERSION" = "$REMOTE_VERSION" ]] && return 0
[ "$LOCAL_VERSION" = "$REMOTE_VERSION" ] && return 0
if whiptail --title "$(translate "Update Available")" \
--yesno "$(translate "New version available") ($REMOTE_VERSION)\n\n$(translate "Do you want to update now?")" \
if whiptail --title "$(translate 'Update Available')" \
--yesno "$(translate 'New version available') ($REMOTE_VERSION)\n\n$(translate 'Do you want to update now?')" \
10 60 --defaultno; then
msg_warn "$(translate "Starting ProxMenux update...")"
if wget -qO "$INSTALL_SCRIPT" "$REPO_URL/install_proxmenux.sh"; then
msg_warn "$(translate 'Starting ProxMenux update...')"
if curl -fsSL "$INSTALL_URL" -o "$INSTALL_SCRIPT"; then
chmod +x "$INSTALL_SCRIPT"
source "$INSTALL_SCRIPT"
bash "$INSTALL_SCRIPT" --update
fi
else
msg_warn "$(translate "Update postponed. You can update later from the menu.")"
fi
}
# ── Beta update check (develop branch) ────────────────────
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"
main_menu() {
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
# ── 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 ─────────────
[[ ! -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
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
msg_warn "Updating to beta build $REMOTE_BETA_VERSION ..."
if curl -fsSL "$INSTALL_BETA_URL" -o "$INSTALL_SCRIPT"; then
chmod +x "$INSTALL_SCRIPT"
bash "$INSTALL_SCRIPT" --update
else
msg_error "Could not download the beta installer from the develop branch."
fi
fi
}
# ── Main ───────────────────────────────────────────────────
main_menu() {
local MAIN_MENU="$LOCAL_SCRIPTS/menus/main_menu.sh"
exec bash "$MAIN_MENU"
}
load_language
initialize_cache
# Check updates doesn't make sense in offline mode
# check_updates
check_updates
main_menu
+254
View File
@@ -0,0 +1,254 @@
{
"version": "1.0.0",
"last_updated": "2025-01-15T10:00:00Z",
"apps": {
"secure-gateway": {
"id": "secure-gateway",
"name": "Secure Gateway",
"short_name": "VPN Gateway",
"subtitle": "Tailscale VPN Gateway",
"version": "1.0.0",
"category": "security",
"subcategory": "remote_access",
"icon": "shield-check",
"icon_type": "shield",
"color": "#0EA5E9",
"summary": "Secure remote access without opening ports",
"description": "Deploy a managed VPN gateway using Tailscale for zero-trust access to your Proxmox infrastructure. Access ProxMenux Monitor, Proxmox UI, VMs, and LXC containers from anywhere without exposing ports to the internet.",
"documentation_url": "https://macrimi.github.io/ProxMenux/docs/secure-gateway",
"code_url": "https://github.com/MacRimi/ProxMenux/tree/main/Scripts/oci",
"features": [
"Zero-trust network access",
"No port forwarding required",
"End-to-end encryption",
"Easy mobile access",
"MagicDNS for easy hostname access",
"Access control via Tailscale admin"
],
"container": {
"type": "lxc",
"template": "alpine",
"install_method": "apk",
"packages": ["tailscale"],
"services": ["tailscale"],
"privileged": false,
"memory": 256,
"cores": 1,
"disk_size": 2,
"requires_ip_forward": true,
"features": ["nesting=1"],
"lxc_config": [
"lxc.cgroup2.devices.allow: c 10:200 rwm",
"lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file"
]
},
"volumes": {
"state": {
"container_path": "/var/lib/tailscale",
"persistent": true,
"description": "Tailscale state and keys"
}
},
"environment": [
{
"name": "TS_STATE_DIR",
"value": "/var/lib/tailscale"
},
{
"name": "TS_USERSPACE",
"value": "false"
},
{
"name": "TS_AUTHKEY",
"value": "$auth_key"
},
{
"name": "TS_HOSTNAME",
"value": "$hostname"
},
{
"name": "TS_ROUTES",
"value": "$advertise_routes"
},
{
"name": "TS_EXTRA_ARGS",
"value": "$extra_args"
}
],
"config_schema": {
"auth_key": {
"type": "password",
"label": "Tailscale Auth Key",
"description": "Pre-authentication key from Tailscale admin console. Generate one at the link below.",
"placeholder": "tskey-auth-xxxxx",
"required": true,
"sensitive": true,
"env_var": "TS_AUTHKEY",
"help_url": "https://login.tailscale.com/admin/settings/keys",
"help_text": "Generate Auth Key"
},
"hostname": {
"type": "text",
"label": "Device Hostname",
"description": "Name shown in Tailscale admin console",
"placeholder": "proxmox-gateway",
"default": "proxmox-gateway",
"required": false,
"env_var": "TS_HOSTNAME",
"validation": {
"pattern": "^[a-zA-Z0-9-]+$",
"max_length": 63,
"message": "Only letters, numbers, and hyphens allowed"
}
},
"access_mode": {
"type": "select",
"label": "Access Scope",
"description": "What should be accessible through this gateway",
"default": "host_only",
"required": true,
"options": [
{
"value": "host_only",
"label": "Proxmox Only",
"description": "Access only this Proxmox server (UI and ProxMenux Monitor)"
},
{
"value": "proxmox_network",
"label": "Full Local Network",
"description": "Access all devices on your local network (NAS, printers, VMs, etc.)"
},
{
"value": "custom",
"label": "Custom Subnets",
"description": "Select specific subnets to expose"
}
]
},
"advertise_routes": {
"type": "networks",
"label": "Advertised Networks",
"description": "Select networks to make accessible through the VPN",
"required": false,
"depends_on": {
"field": "access_mode",
"values": ["custom"]
},
"env_var": "TS_ROUTES",
"env_format": "csv"
},
"exit_node": {
"type": "boolean",
"label": "Exit Node",
"description": "Use this gateway as your internet exit point when away from home. All your internet traffic will appear to come from your Proxmox server's IP address.",
"default": false,
"required": false,
"flag": "--advertise-exit-node",
"warning": "Requires approval in Tailscale Admin. When enabled on your device, ALL internet traffic routes through your Proxmox server."
},
"accept_routes": {
"type": "boolean",
"label": "Accept Routes",
"description": "Allow this gateway to access networks advertised by OTHER Tailscale nodes in your tailnet. Useful if you have multiple Tailscale subnet routers.",
"default": false,
"required": false,
"flag": "--accept-routes"
}
},
"healthcheck": {
"command": ["tailscale", "status", "--json"],
"interval_seconds": 30,
"timeout_seconds": 10,
"retries": 3,
"healthy_condition": "BackendState == Running"
},
"requirements": {
"min_memory_mb": 64,
"min_disk_mb": 100,
"proxmox_min_version": "9.1",
"checks": [
{
"type": "proxmox_version",
"min": "9.1",
"message": "OCI containers require Proxmox VE 9.1+"
}
]
},
"security_notes": [
"Requires NET_ADMIN capability for VPN tunneling",
"Uses /dev/net/tun for network virtualization",
"Auth key is stored encrypted at rest",
"No ports are opened on the host firewall",
"All traffic is end-to-end encrypted"
],
"ui": {
"wizard_steps": [
{
"id": "intro",
"title": "Secure Remote Access",
"description": "Set up secure VPN access to your Proxmox server"
},
{
"id": "auth",
"title": "Tailscale Authentication",
"description": "Connect to your Tailscale account",
"fields": ["auth_key", "hostname"]
},
{
"id": "access",
"title": "Access Scope",
"description": "Choose what to make accessible",
"fields": ["access_mode", "advertise_routes"]
},
{
"id": "options",
"title": "Advanced Options",
"description": "Additional configuration",
"fields": ["exit_node", "accept_routes"]
},
{
"id": "deploy",
"title": "Deploy Gateway",
"description": "Review and deploy"
}
],
"show_in_sections": ["security"],
"dashboard_widget": false,
"status_indicators": {
"running": {
"color": "green",
"icon": "check-circle",
"label": "Connected"
},
"stopped": {
"color": "yellow",
"icon": "pause-circle",
"label": "Stopped"
},
"error": {
"color": "red",
"icon": "x-circle",
"label": "Error"
}
}
},
"metadata": {
"author": "ProxMenux",
"license": "MIT",
"upstream": "https://tailscale.com",
"tags": ["vpn", "remote-access", "tailscale", "zero-trust", "security"]
}
}
}
}
-861
View File
@@ -1,861 +0,0 @@
#!/bin/bash
# ==========================================================
# ProxMenux - Complete Post-Installation Script with Registration
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.0
# Last Updated: 06/07/2025
# ==========================================================
# Description:
#
# The script performs system optimizations including:
# - Repository configuration and system upgrades
# - Subscription banner removal and UI enhancements
# - Advanced memory management and kernel optimizations
# - Network stack tuning and security hardening
# - Storage optimizations including log2ram for SSD protection
# - System limits increases and entropy generation improvements
# - Journald and logrotate optimizations for better log management
# - Security enhancements including RPC disabling and time synchronization
# - Bash environment customization and system monitoring setup
#
# Key Features:
# - Zero-interaction automation: Runs completely unattended
# - Intelligent hardware detection: Automatically detects SSD/NVMe for log2ram
# - RAM-aware configurations: Adjusts settings based on available system memory
# - Comprehensive error handling: Robust installation with fallback mechanisms
# - Registration system: Tracks installed optimizations for easy management
# - Reboot management: Intelligently handles reboot requirements
# - Translation support: Multi-language compatible through ProxMenux framework
# - Rollback compatibility: All optimizations can be reversed using the uninstall script
#
# This script is based on the post-install script customizable
# ==========================================================
# 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
# Global variables
OS_CODENAME="$(grep "VERSION_CODENAME=" /etc/os-release | cut -d"=" -f 2 | xargs)"
RAM_SIZE_GB=$(( $(vmstat -s | grep -i "total memory" | xargs | cut -d" " -f 1) / 1024 / 1000))
NECESSARY_REBOOT=0
SCRIPT_TITLE="Customizable post-installation optimization script"
# ==========================================================
# 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"
}
# ==========================================================
lvm_repair_check() {
msg_info "$(translate "Checking and repairing old LVM PV headers (if needed)...")"
pvs_output=$(LC_ALL=C pvs -v 2>&1 | grep "old PV header")
if [ -z "$pvs_output" ]; then
msg_ok "$(translate "No PVs with old headers found.")"
register_tool "lvm_repair" true
return
fi
declare -A vg_map
while read -r line; do
pv=$(echo "$line" | grep -o '/dev/[^ ]*')
vg=$(pvs -o vg_name --noheadings "$pv" | awk '{print $1}')
if [ -n "$vg" ]; then
vg_map["$vg"]=1
fi
done <<< "$pvs_output"
for vg in "${!vg_map[@]}"; do
msg_warn "$(translate "Old PV header(s) found in VG $vg. Updating metadata...")"
vgck --updatemetadata "$vg"
vgchange -ay "$vg"
if [ $? -ne 0 ]; then
msg_warn "$(translate "Metadata update failed for VG $vg. Review manually.")"
else
msg_ok "$(translate "Metadata updated successfully for VG $vg")"
fi
done
msg_ok "$(translate "LVM PV headers check completed")"
register_tool "lvm_repair" true
}
# ==========================================================
cleanup_duplicate_repos() {
local sources_file="/etc/apt/sources.list"
local temp_file=$(mktemp)
local cleaned_count=0
declare -A seen_repos
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" =~ ^[[:space:]]*# ]] || [[ -z "$line" ]]; then
echo "$line" >> "$temp_file"
continue
fi
if [[ "$line" =~ ^deb ]]; then
read -r _ url dist components <<< "$line"
local key="${url}_${dist}"
if [[ -v "seen_repos[$key]" ]]; then
echo "# $line" >> "$temp_file"
cleaned_count=$((cleaned_count + 1))
else
echo "$line" >> "$temp_file"
seen_repos[$key]="$components"
fi
else
echo "$line" >> "$temp_file"
fi
done < "$sources_file"
mv "$temp_file" "$sources_file"
chmod 644 "$sources_file"
local pve_files=(/etc/apt/sources.list.d/*proxmox*.list /etc/apt/sources.list.d/*pve*.list)
local pve_content="deb http://download.proxmox.com/debian/pve ${OS_CODENAME} pve-no-subscription"
local pve_public_repo="/etc/apt/sources.list.d/pve-public-repo.list"
local pve_public_repo_exists=false
if [ -f "$pve_public_repo" ] && grep -q "^deb.*pve-no-subscription" "$pve_public_repo"; then
pve_public_repo_exists=true
fi
for file in "${pve_files[@]}"; do
if [ -f "$file" ] && grep -q "^deb.*pve-no-subscription" "$file"; then
if ! $pve_public_repo_exists && [[ "$file" == "$pve_public_repo" ]]; then
sed -i 's/^# *deb/deb/' "$file"
pve_public_repo_exists=true
elif [[ "$file" != "$pve_public_repo" ]]; then
sed -i 's/^deb/# deb/' "$file"
cleaned_count=$((cleaned_count + 1))
fi
fi
done
apt update
}
apt_upgrade() {
NECESSARY_REBOOT=1
if [ -f /etc/apt/sources.list.d/pve-enterprise.list ] && grep -q "^deb" /etc/apt/sources.list.d/pve-enterprise.list; then
msg_info "$(translate "Disabling enterprise Proxmox repository...")"
sed -i "s/^deb/#deb/g" /etc/apt/sources.list.d/pve-enterprise.list
msg_ok "$(translate "Enterprise Proxmox repository disabled")"
fi
if [ -f /etc/apt/sources.list.d/ceph.list ] && grep -q "^deb" /etc/apt/sources.list.d/ceph.list; then
msg_info "$(translate "Disabling enterprise Proxmox Ceph repository...")"
sed -i "s/^deb/#deb/g" /etc/apt/sources.list.d/ceph.list
msg_ok "$(translate "Enterprise Proxmox Ceph repository disabled")"
fi
if [ ! -f /etc/apt/sources.list.d/pve-public-repo.list ] || ! grep -q "pve-no-subscription" /etc/apt/sources.list.d/pve-public-repo.list; then
msg_info "$(translate "Enabling free public Proxmox repository...")"
echo "deb http://download.proxmox.com/debian/pve ${OS_CODENAME} pve-no-subscription" > /etc/apt/sources.list.d/pve-public-repo.list
msg_ok "$(translate "Free public Proxmox repository enabled")"
fi
sources_file="/etc/apt/sources.list"
need_update=false
sed -i 's|ftp.es.debian.org|deb.debian.org|g' "$sources_file"
if grep -q "^deb http://security.debian.org ${OS_CODENAME}-security main contrib" "$sources_file"; then
sed -i "s|^deb http://security.debian.org ${OS_CODENAME}-security main contrib|deb http://security.debian.org/debian-security ${OS_CODENAME}-security main contrib non-free non-free-firmware|" "$sources_file"
msg_ok "$(translate "Replaced security repository with full version")"
need_update=true
fi
if ! grep -q "deb http://security.debian.org/debian-security ${OS_CODENAME}-security" "$sources_file"; then
echo "deb http://security.debian.org/debian-security ${OS_CODENAME}-security main contrib non-free non-free-firmware" >> "$sources_file"
need_update=true
fi
if ! grep -q "deb http://deb.debian.org/debian ${OS_CODENAME} " "$sources_file"; then
echo "deb http://deb.debian.org/debian ${OS_CODENAME} main contrib non-free non-free-firmware" >> "$sources_file"
need_update=true
fi
if ! grep -q "deb http://deb.debian.org/debian ${OS_CODENAME}-updates" "$sources_file"; then
echo "deb http://deb.debian.org/debian ${OS_CODENAME}-updates main contrib non-free non-free-firmware" >> "$sources_file"
need_update=true
fi
msg_ok "$(translate "Debian repositories configured correctly")"
# ===================================================
if [ ! -f /etc/apt/apt.conf.d/no-bookworm-firmware.conf ]; then
msg_info "$(translate "Disabling non-free firmware warnings...")"
echo 'APT::Get::Update::SourceListWarnings::NonFreeFirmware "false";' > /etc/apt/apt.conf.d/no-bookworm-firmware.conf
msg_ok "$(translate "Non-free firmware warnings disabled")"
fi
msg_info "$(translate "Updating package lists...")"
if apt-get update > /dev/null 2>&1; then
msg_ok "$(translate "Package lists updated")"
else
msg_error "$(translate "Failed to update package lists")"
return 1
fi
msg_info "$(translate "Removing conflicting utilities...")"
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 "Conflicting utilities removed")"
else
msg_error "$(translate "Failed to remove conflicting utilities")"
fi
msg_info "$(translate "Performing packages upgrade...")"
apt-get install pv -y > /dev/null 2>&1
total_packages=$(apt-get -s dist-upgrade | grep "^Inst" | wc -l)
if [ "$total_packages" -eq 0 ]; then
total_packages=1
fi
msg_ok "$(translate "Packages upgrade successful")"
tput civis
tput sc
(
/usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' dist-upgrade 2>&1 | \
while IFS= read -r line; do
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")"
tput rc
tput ed
row=$(( $(tput lines) - 6 ))
tput cup $row 0; echo "$(translate "Installing packages...")"
tput cup $((row + 1)) 0; echo "──────────────────────────────────────────────"
tput cup $((row + 2)) 0; echo "Package: $package_name"
tput cup $((row + 3)) 0; echo "Progress: [ ] 0%"
tput cup $((row + 4)) 0; echo "──────────────────────────────────────────────"
for i in $(seq 1 10); do
progress=$((i * 10))
tput cup $((row + 3)) 9
printf "[%-50s] %3d%%" "$(printf "#%.0s" $(seq 1 $((progress/2))))" "$progress"
done
fi
done
)
if [ $? -eq 0 ]; then
tput rc
tput ed
msg_ok "$(translate "System upgrade completed")"
fi
msg_info "$(translate "Installing additional Proxmox packages...")"
if /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' install zfsutils-linux proxmox-backup-restore-image chrony > /dev/null 2>&1; then
msg_ok "$(translate "Additional Proxmox packages installed")"
else
msg_error "$(translate "Failed to install additional Proxmox packages")"
fi
lvm_repair_check
cleanup_duplicate_repos
msg_ok "$(translate "Proxmox update completed")"
}
# ==========================================================
remove_subscription_banner() {
msg_info "$(translate "Removing Proxmox subscription nag banner...")"
local JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
local GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
local APT_HOOK="/etc/apt/apt.conf.d/no-nag-script"
if [[ ! -f "$APT_HOOK" ]]; then
cat <<'EOF' > "$APT_HOOK"
DPkg::Post-Invoke { "dpkg -V proxmox-widget-toolkit | grep -q '/proxmoxlib\.js$'; if [ $? -eq 1 ]; then { echo 'Removing subscription nag from UI...'; sed -i '/.*data\.status.*{/{s/\!//;s/active/NoMoreNagging/;s/Active/NoMoreNagging/}' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js; rm -f /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz; }; fi"; };
EOF
fi
if [[ -f "$JS_FILE" ]]; then
sed -i '/.*data\.status.*{/{s/\!//;s/active/NoMoreNagging/;s/Active/NoMoreNagging/}' "$JS_FILE"
[[ -f "$GZ_FILE" ]] && rm -f "$GZ_FILE"
touch "$JS_FILE"
fi
apt --reinstall install proxmox-widget-toolkit -y > /dev/null 2>&1
msg_ok "$(translate "Subscription nag banner removed successfully")"
register_tool "subscription_banner" true
}
# ==========================================================
configure_time_sync() {
msg_info "$(translate "Configuring system time settings...")"
this_ip=$(dig +short myip.opendns.com @resolver1.opendns.com)
if [ -z "$this_ip" ]; then
msg_warn "$(translate "Failed to obtain public IP address")"
timezone="UTC"
else
timezone=$(curl -s "https://ipapi.co/${this_ip}/timezone")
if [ -z "$timezone" ]; then
msg_warn "$(translate "Failed to determine timezone from IP address")"
timezone="UTC"
else
msg_ok "$(translate "Found timezone $timezone for IP $this_ip")"
fi
fi
msg_info "$(translate "Enabling automatic time synchronization...")"
if timedatectl set-ntp true; then
msg_ok "$(translate "Time settings configured - Timezone:") $timezone"
register_tool "time_sync" true
else
msg_error "$(translate "Failed to enable automatic time synchronization")"
fi
}
# ==========================================================
skip_apt_languages() {
msg_info "$(translate "Configuring APT to skip downloading additional languages...")"
local default_locale=""
if [ -f /etc/default/locale ]; then
default_locale=$(grep '^LANG=' /etc/default/locale | cut -d= -f2 | tr -d '"')
elif [ -f /etc/environment ]; then
default_locale=$(grep '^LANG=' /etc/environment | cut -d= -f2 | tr -d '"')
fi
default_locale="${default_locale:-en_US.UTF-8}"
local normalized_locale=$(echo "$default_locale" | tr 'A-Z' 'a-z' | sed 's/utf-8/utf8/;s/-/_/')
if ! locale -a | grep -qi "^$normalized_locale$"; then
if ! grep -qE "^${default_locale}[[:space:]]+UTF-8" /etc/locale.gen; then
echo "$default_locale UTF-8" >> /etc/locale.gen
fi
locale-gen "$default_locale" > /dev/null 2>&1
fi
echo 'Acquire::Languages "none";' > /etc/apt/apt.conf.d/99-disable-translations
msg_ok "$(translate "APT configured to skip additional languages")"
register_tool "apt_languages" true
}
# ==========================================================
optimize_journald() {
msg_info "$(translate "Limiting size and optimizing journald...")"
NECESSARY_REBOOT=1
cat <<EOF > /etc/systemd/journald.conf
[Journal]
Storage=persistent
SplitMode=none
RateLimitInterval=0
RateLimitIntervalSec=0
RateLimitBurst=0
ForwardToSyslog=no
ForwardToWall=yes
Seal=no
Compress=yes
SystemMaxUse=64M
RuntimeMaxUse=60M
MaxLevelStore=warning
MaxLevelSyslog=warning
MaxLevelKMsg=warning
MaxLevelConsole=notice
MaxLevelWall=crit
EOF
systemctl restart systemd-journald.service > /dev/null 2>&1
journalctl --vacuum-size=64M --vacuum-time=1d > /dev/null 2>&1
journalctl --rotate > /dev/null 2>&1
msg_ok "$(translate "Journald optimized - Max size: 64M")"
register_tool "journald" true
}
# ==========================================================
optimize_logrotate() {
msg_info "$(translate "Optimizing logrotate configuration...")"
local logrotate_conf="/etc/logrotate.conf"
local backup_conf="${logrotate_conf}.bak"
if ! grep -q "# ProxMenux optimized configuration" "$logrotate_conf"; then
cp "$logrotate_conf" "$backup_conf"
cat <<EOF > "$logrotate_conf"
# ProxMenux optimized configuration
daily
su root adm
rotate 7
create
compress
size=10M
delaycompress
copytruncate
include /etc/logrotate.d
EOF
systemctl restart logrotate > /dev/null 2>&1
fi
msg_ok "$(translate "Logrotate optimization completed")"
register_tool "logrotate" true
}
# ==========================================================
increase_system_limits() {
msg_info "$(translate "Increasing various system limits...")"
NECESSARY_REBOOT=1
cat > /etc/sysctl.d/99-maxwatches.conf << EOF
# ProxMenux configuration
fs.inotify.max_user_watches = 1048576
fs.inotify.max_user_instances = 1048576
fs.inotify.max_queued_events = 1048576
EOF
cat > /etc/security/limits.d/99-limits.conf << EOF
# ProxMenux configuration
* soft nproc 1048576
* hard nproc 1048576
* soft nofile 1048576
* hard nofile 1048576
root soft nproc unlimited
root hard nproc unlimited
root soft nofile unlimited
root hard nofile unlimited
EOF
cat > /etc/sysctl.d/99-maxkeys.conf << EOF
# ProxMenux configuration
kernel.keys.root_maxkeys=1000000
kernel.keys.maxkeys=1000000
EOF
for file in /etc/systemd/system.conf /etc/systemd/user.conf; do
if ! grep -q "^DefaultLimitNOFILE=" "$file"; then
echo "DefaultLimitNOFILE=256000" >> "$file"
fi
done
for file in /etc/pam.d/common-session /etc/pam.d/runuser-l; do
if ! grep -q "^session required pam_limits.so" "$file"; then
echo 'session required pam_limits.so' >> "$file"
fi
done
if ! grep -q "ulimit -n 256000" /root/.profile; then
echo "ulimit -n 256000" >> /root/.profile
fi
cat > /etc/sysctl.d/99-swap.conf << EOF
# ProxMenux configuration
vm.swappiness = 10
vm.vfs_cache_pressure = 100
EOF
cat > /etc/sysctl.d/99-fs.conf << EOF
# ProxMenux configuration
fs.nr_open = 12000000
fs.file-max = 9223372036854775807
fs.aio-max-nr = 1048576
EOF
msg_ok "$(translate "System limits increase completed.")"
register_tool "system_limits" true
}
# ==========================================================
configure_entropy() {
msg_info "$(translate "Configuring entropy generation to prevent slowdowns...")"
/usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' install haveged > /dev/null 2>&1
cat <<EOF > /etc/default/haveged
# -w sets low entropy watermark (in bits)
DAEMON_ARGS="-w 1024"
EOF
systemctl daemon-reload > /dev/null 2>&1
systemctl enable haveged > /dev/null 2>&1
msg_ok "$(translate "Entropy generation configuration completed")"
register_tool "entropy" true
}
# ==========================================================
optimize_memory_settings() {
msg_info "$(translate "Optimizing memory settings...")"
NECESSARY_REBOOT=1
cat <<EOF > /etc/sysctl.d/99-memory.conf
# Balanced Memory Optimization
vm.swappiness = 10
vm.dirty_ratio = 15
vm.dirty_background_ratio = 5
vm.overcommit_memory = 1
vm.max_map_count = 65530
EOF
if [ -f /proc/sys/vm/compaction_proactiveness ]; then
echo "vm.compaction_proactiveness = 20" >> /etc/sysctl.d/99-memory.conf
fi
msg_ok "$(translate "Memory optimization completed.")"
register_tool "memory_settings" true
}
# ==========================================================
configure_kernel_panic() {
msg_info "$(translate "Configuring kernel panic behavior")"
NECESSARY_REBOOT=1
cat <<EOF > /etc/sysctl.d/99-kernelpanic.conf
# Enable restart on kernel panic, kernel oops and hardlockup
kernel.core_pattern = /var/crash/core.%t.%p
kernel.panic = 10
kernel.panic_on_oops = 1
kernel.hardlockup_panic = 1
EOF
msg_ok "$(translate "Kernel panic behavior configuration completed")"
register_tool "kernel_panic" true
}
# ==========================================================
force_apt_ipv4() {
msg_info "$(translate "Configuring APT to use IPv4...")"
echo 'Acquire::ForceIPv4 "true";' > /etc/apt/apt.conf.d/99-force-ipv4
msg_ok "$(translate "APT IPv4 configuration completed")"
register_tool "apt_ipv4" true
}
# ==========================================================
apply_network_optimizations() {
msg_info "$(translate "Optimizing network settings...")"
NECESSARY_REBOOT=1
cat <<EOF > /etc/sysctl.d/99-network.conf
net.core.netdev_max_backlog=8192
net.core.optmem_max=8192
net.core.rmem_max=16777216
net.core.somaxconn=8151
net.core.wmem_max=16777216
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.all.log_martians = 0
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.all.secure_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv4.conf.default.log_martians = 0
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.default.secure_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
net.ipv4.ip_local_port_range=1024 65535
net.ipv4.tcp_base_mss = 1024
net.ipv4.tcp_challenge_ack_limit = 999999999
net.ipv4.tcp_fin_timeout=10
net.ipv4.tcp_keepalive_intvl=30
net.ipv4.tcp_keepalive_probes=3
net.ipv4.tcp_keepalive_time=240
net.ipv4.tcp_limit_output_bytes=65536
net.ipv4.tcp_max_syn_backlog=8192
net.ipv4.tcp_max_tw_buckets = 1440000
net.ipv4.tcp_mtu_probing = 1
net.ipv4.tcp_rfc1337=1
net.ipv4.tcp_rmem=8192 87380 16777216
net.ipv4.tcp_sack=1
net.ipv4.tcp_slow_start_after_idle=0
net.ipv4.tcp_syn_retries=3
net.ipv4.tcp_synack_retries = 2
net.ipv4.tcp_tw_recycle = 0
net.ipv4.tcp_tw_reuse = 0
net.ipv4.tcp_wmem=8192 65536 16777216
net.netfilter.nf_conntrack_generic_timeout = 60
net.netfilter.nf_conntrack_helper=0
net.netfilter.nf_conntrack_max = 524288
net.netfilter.nf_conntrack_tcp_timeout_established = 28800
net.unix.max_dgram_qlen = 4096
EOF
sysctl --system > /dev/null 2>&1
local interfaces_file="/etc/network/interfaces"
if ! grep -q 'source /etc/network/interfaces.d/*' "$interfaces_file"; then
echo "source /etc/network/interfaces.d/*" >> "$interfaces_file"
fi
msg_ok "$(translate "Network optimization completed")"
register_tool "network_optimization" true
}
# ==========================================================
disable_rpc() {
msg_info "$(translate "Disabling portmapper/rpcbind for security...")"
systemctl disable rpcbind > /dev/null 2>&1
systemctl stop rpcbind > /dev/null 2>&1
msg_ok "$(translate "portmapper/rpcbind has been disabled and removed")"
register_tool "disable_rpc" true
}
# ==========================================================
customize_bashrc() {
msg_info "$(translate "Customizing bashrc for root user...")"
local bashrc="/root/.bashrc"
local bash_profile="/root/.bash_profile"
if [ ! -f "${bashrc}.bak" ]; then
cp "$bashrc" "${bashrc}.bak"
fi
cat >> "$bashrc" << 'EOF'
# ProxMenux customizations
export HISTTIMEFORMAT="%d/%m/%y %T "
export PS1="\[\e[31m\][\[\e[m\]\[\e[38;5;172m\]\u\[\e[m\]@\[\e[38;5;153m\]\h\[\e[m\] \[\e[38;5;214m\]\W\[\e[m\]\[\e[31m\]]\[\e[m\]\\$ "
alias l='ls -CF'
alias la='ls -A'
alias ll='ls -alF'
alias ls='ls --color=auto'
alias grep='grep --color=auto'
alias fgrep='fgrep --color=auto'
alias egrep='egrep --color=auto'
source /etc/profile.d/bash_completion.sh
EOF
if ! grep -q "source /root/.bashrc" "$bash_profile"; then
echo "source /root/.bashrc" >> "$bash_profile"
fi
msg_ok "$(translate "Bashrc customization completed")"
register_tool "bashrc_custom" true
}
# ==========================================================
install_log2ram_auto() {
msg_info "$(translate "Checking if system disk is SSD or M.2...")"
ROOT_PART=$(lsblk -no NAME,MOUNTPOINT | grep ' /$' | awk '{print $1}')
SYSTEM_DISK=$(lsblk -no PKNAME /dev/$ROOT_PART 2>/dev/null)
SYSTEM_DISK=${SYSTEM_DISK:-sda}
if [[ "$SYSTEM_DISK" == nvme* || "$(cat /sys/block/$SYSTEM_DISK/queue/rotational 2>/dev/null)" == "0" ]]; then
msg_ok "$(translate "System disk ($SYSTEM_DISK) is SSD or M.2. Proceeding with log2ram setup.")"
else
msg_warn "$(translate "System disk ($SYSTEM_DISK) is not SSD/M.2. Skipping log2ram installation.")"
return 0
fi
# Clean up previous state
rm -rf /tmp/log2ram
rm -f /etc/systemd/system/log2ram*
rm -f /etc/systemd/system/log2ram-daily.*
rm -f /etc/cron.d/log2ram*
rm -f /usr/sbin/log2ram
rm -f /etc/log2ram.conf
rm -f /usr/local/bin/log2ram-check.sh
rm -rf /var/log.hdd
systemctl daemon-reexec >/dev/null 2>&1
systemctl daemon-reload >/dev/null 2>&1
msg_info "$(translate "Installing log2ram from GitHub...")"
git clone https://github.com/azlux/log2ram.git /tmp/log2ram >/dev/null 2>>/tmp/log2ram_install.log
cd /tmp/log2ram || return 1
bash install.sh >>/tmp/log2ram_install.log 2>&1
if [[ -f /etc/log2ram.conf ]] && systemctl list-units --all | grep -q log2ram; then
msg_ok "$(translate "log2ram installed successfully")"
else
msg_error "$(translate "Failed to install log2ram. See /tmp/log2ram_install.log")"
return 1
fi
# Detect RAM (in MB first for better accuracy)
RAM_SIZE_MB=$(free -m | awk '/^Mem:/{print $2}')
RAM_SIZE_GB=$((RAM_SIZE_MB / 1024))
[[ -z "$RAM_SIZE_GB" || "$RAM_SIZE_GB" -eq 0 ]] && RAM_SIZE_GB=4
if (( RAM_SIZE_GB <= 8 )); then
LOG2RAM_SIZE="128M"
CRON_HOURS=1
elif (( RAM_SIZE_GB <= 16 )); then
LOG2RAM_SIZE="256M"
CRON_HOURS=3
else
LOG2RAM_SIZE="512M"
CRON_HOURS=6
fi
msg_ok "$(translate "Detected RAM:") $RAM_SIZE_GB GB — $(translate "log2ram size set to:") $LOG2RAM_SIZE"
sed -i "s/^SIZE=.*/SIZE=$LOG2RAM_SIZE/" /etc/log2ram.conf
rm -f /etc/cron.hourly/log2ram
echo "0 */$CRON_HOURS * * * root /usr/sbin/log2ram write" > /etc/cron.d/log2ram
msg_ok "$(translate "log2ram write scheduled every") $CRON_HOURS $(translate "hour(s)")"
cat << 'EOF' > /usr/local/bin/log2ram-check.sh
#!/bin/bash
CONF_FILE="/etc/log2ram.conf"
SIZE_VALUE=$(grep '^SIZE=' "$CONF_FILE" | cut -d'=' -f2)
# Convert to KB: handle M (megabytes) and G (gigabytes)
if [[ "$SIZE_VALUE" == *"G" ]]; then
LIMIT_KB=$(($(echo "$SIZE_VALUE" | tr -d 'G') * 1024 * 1024))
else
LIMIT_KB=$(($(echo "$SIZE_VALUE" | tr -d 'M') * 1024))
fi
USED_KB=$(df /var/log --output=used | tail -1)
THRESHOLD=$(( LIMIT_KB * 90 / 100 ))
if (( USED_KB > THRESHOLD )); then
/usr/sbin/log2ram write
fi
EOF
chmod +x /usr/local/bin/log2ram-check.sh
echo "*/5 * * * * root /usr/local/bin/log2ram-check.sh" > /etc/cron.d/log2ram-auto-sync
msg_ok "$(translate "Auto-sync enabled when /var/log exceeds 90% of") $LOG2RAM_SIZE"
register_tool "log2ram" true
}
# ==========================================================
run_complete_optimization() {
clear
show_proxmenux_logo
msg_title "$(translate "ProxMenux Optimization Post-Installation")"
ensure_tools_json
apt_upgrade
remove_subscription_banner
configure_time_sync
skip_apt_languages
optimize_journald
optimize_logrotate
increase_system_limits
configure_entropy
optimize_memory_settings
configure_kernel_panic
force_apt_ipv4
apply_network_optimizations
disable_rpc
customize_bashrc
install_log2ram_auto
echo -e
msg_success "$(translate "Complete post-installation optimization finished!")"
if [[ "$NECESSARY_REBOOT" -eq 1 ]]; then
whiptail --title "Reboot Required" \
--yesno "$(translate "Some changes require a reboot to take effect. Do you want to restart now?")" 10 60
if [[ $? -eq 0 ]]; then
msg_info "$(translate "Removing no longer required packages and purging old cached updates...")"
apt-get -y autoremove >/dev/null 2>&1
apt-get -y autoclean >/dev/null 2>&1
msg_ok "$(translate "Cleanup finished")"
msg_success "$(translate "Press Enter to continue...")"
read -r
msg_warn "$(translate "Rebooting the system...")"
reboot
else
msg_info "$(translate "Removing no longer required packages and purging old cached updates...")"
apt-get -y autoremove >/dev/null 2>&1
apt-get -y autoclean >/dev/null 2>&1
msg_ok "$(translate "Cleanup finished")"
msg_info2 "$(translate "You can reboot later manually.")"
msg_success "$(translate "Press Enter to continue...")"
read -r
exit 0
fi
fi
msg_success "$(translate "All changes applied. No reboot required.")"
msg_success "$(translate "Press Enter to return to menu...")"
read -r
clear
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
run_complete_optimization
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

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