Compare commits
1539 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fb05858c8 | |||
| d0d0f30d64 | |||
| 04185f9a31 | |||
| 5227a1370c | |||
| c33f306944 | |||
| 3306780507 | |||
| 84d7b60cd2 | |||
| 0d435a5d4a | |||
| 4994543d56 | |||
| 026ceab2ef | |||
| c92bf504fb | |||
| a7eac42c9d | |||
| f20550d5f6 | |||
| 742b08c86d | |||
| a85ddcd6c1 | |||
| 758ac4ab62 | |||
| 56e5d7ff54 | |||
| 596a8ba21d | |||
| 19b67b3c8a | |||
| eaa97ce301 | |||
| a05d16a789 | |||
| f11aa77e3a | |||
| 1bbcf60e58 | |||
| c90ee58bc0 | |||
| 2983e20695 | |||
| 4e7570cb26 | |||
| 7987b61345 | |||
| 6fbd47ed8a | |||
| bada4ef524 | |||
| 8918d37bb6 | |||
| b0d94a3594 | |||
| 1291204d2b | |||
| 804f201589 | |||
| 65779b1eb6 | |||
| 7eac978950 | |||
| b1d0cfa2e7 | |||
| 282b1c2b0a | |||
| b4b0d4cd7e | |||
| 6a66ee75ac | |||
| 45dcdcccbb | |||
| 5d47f1dfca | |||
| 1e07e5f17b | |||
| 8adb1b9730 | |||
| 9d3720b6a1 | |||
| 6496601e4e | |||
| b96df3b501 | |||
| 489460f509 | |||
| 192870d658 | |||
| bed4b5ee49 | |||
| f0eb402100 | |||
| e73486904b | |||
| 1119a45e93 | |||
| 85860cdddd | |||
| da2e89bc94 | |||
| 01ed2da10e | |||
| 5fea839e34 | |||
| d725910e7a | |||
| 99f73ad745 | |||
| b599a990f6 | |||
| c742393efc | |||
| c403300cd2 | |||
| c3a5d6201e | |||
| 9e7350c3bb | |||
| 5bee471884 | |||
| 77eb8c7b78 | |||
| acf2302755 | |||
| 8fedd3defe | |||
| adda8181a6 | |||
| 789494cc89 | |||
| 561086e940 | |||
| 20c1140676 | |||
| 9bf99c0fdd | |||
| 899eb61dcf | |||
| a5e6e112a5 | |||
| 834795d6d9 | |||
| 8442cbca77 | |||
| bcca760403 | |||
| 102a58a068 | |||
| 4e849d5309 | |||
| 44e92c8bf0 | |||
| 873f5ae51e | |||
| 3e0b907138 | |||
| 7ee6b6a96b | |||
| 14adb673f6 | |||
| 91381f0850 | |||
| d3e91b5d06 | |||
| 74fcd7d569 | |||
| 0843cd8363 | |||
| 8c3d022506 | |||
| a5a55f3c7d | |||
| 2fb9e74a13 | |||
| f950882ffd | |||
| 18b3b572f0 | |||
| 023e3ff59b | |||
| 7318c81fe0 | |||
| f516a1cf4c | |||
| 43959fc758 | |||
| ff7b1e10a4 | |||
| b049712cd6 | |||
| 2cdfc60fe1 | |||
| 2739cb0894 | |||
| 3e5ef4fa08 | |||
| a7a010d660 | |||
| 67000f5ff1 | |||
| efa111e2dd | |||
| 813798ec2b | |||
| c8b1cd0fab | |||
| 9220dfb7a3 | |||
| f6e9497f1e | |||
| 45e7713638 | |||
| 802dc491f8 | |||
| 3ca5a36240 | |||
| 3046299414 | |||
| 46edd4e3e4 | |||
| 800a18ac60 | |||
| 37c60cb82a | |||
| 9446112081 | |||
| 07384e4d7c | |||
| 1f7bf74970 | |||
| 4b72490486 | |||
| 9e4e0bc24a | |||
| 18687666a6 | |||
| ccb924f8f6 | |||
| 58ac4ee743 | |||
| d515448113 | |||
| ec3de30b14 | |||
| 09ff203662 | |||
| 4837b66089 | |||
| c312cf3d38 | |||
| 46f5af1966 | |||
| 85f98dff9a | |||
| bd56964860 | |||
| 481a653b75 | |||
| f94b59954f | |||
| 4f7977b5ca | |||
| 512cc11894 | |||
| 51be0bd3bd | |||
| 0f6095f8c3 | |||
| 831bf67ee4 | |||
| e7cca5e532 | |||
| 75b08de934 | |||
| 987b665115 | |||
| 68ca68c6f1 | |||
| 5c4ca290fb | |||
| 1d61442a49 | |||
| 0e2ede5e66 | |||
| 0db74814be | |||
| 75c6f74fc4 | |||
| e6faec24fa | |||
| 35b7d01d7e | |||
| 415bc439bb | |||
| fe47522275 | |||
| 24b97831a4 | |||
| ac95a5afba | |||
| 03850d2958 | |||
| c7b49cfc4a | |||
| 5398211ab5 | |||
| dc8ebb651a | |||
| 039e35f3c5 | |||
| ffadb2c508 | |||
| 019e98e6b6 | |||
| c998e39038 | |||
| baa2ff4fa9 | |||
| 4b6a91e74c | |||
| 37f56c8a16 | |||
| 09bb47f408 | |||
| 5db6762690 | |||
| b1cc880253 | |||
| 7d4ea806a2 | |||
| 2b306c9033 | |||
| a776d6b746 | |||
| 07f87de742 | |||
| cf871da880 | |||
| 774d42d5be | |||
| 6660122e69 | |||
| 1ef4bc4fed | |||
| ee1204c566 | |||
| 7f2b0c5de1 | |||
| 9737ffd996 | |||
| be60b7e17c | |||
| 97368a6f44 | |||
| c3d7f01b40 | |||
| cbebd5147c | |||
| 4611be734f | |||
| 528b57664f | |||
| cc86d68507 | |||
| 7c8da462db | |||
| b7963c3b70 | |||
| d51dd35376 | |||
| 196086498e | |||
| f6ff76f9ce | |||
| 20a2db6739 | |||
| aa3b8ebe82 | |||
| d03afa1793 | |||
| 056cee2f94 | |||
| f80e087429 | |||
| eea765300e | |||
| c2396d7e81 | |||
| 0b94acf7f6 | |||
| 324cb23f75 | |||
| b341ba8297 | |||
| f5b9da0908 | |||
| c83672a4bc | |||
| af8e3f6a71 | |||
| 5025d38a76 | |||
| f3c7fb97fb | |||
| cb2ab5f67b | |||
| 003c8850b7 | |||
| d9461c170d | |||
| 57b5de4a4a | |||
| c406c52086 | |||
| df4855ec47 | |||
| 8c7f4a4c20 | |||
| 3ec733d9c6 | |||
| 71c64d1ae5 | |||
| 98becfd368 | |||
| 4eea90bd97 | |||
| 2344935357 | |||
| 550279ec68 | |||
| 44aefb5d3b | |||
| 7d3cf4d364 | |||
| f6ef383598 | |||
| a6149e3cd8 | |||
| 07f1098418 | |||
| 3d00f33dbf | |||
| 98c859fbf8 | |||
| 9460bee72f | |||
| 5a957fe904 | |||
| 59a4b6e4ca | |||
| 9000316224 | |||
| da96c57819 | |||
| b3cef0b009 | |||
| 78ace237dd | |||
| e94e065eca | |||
| 4ffe0f3f46 | |||
| 7af4150e44 | |||
| 71950369e1 | |||
| adb4815c9b | |||
| 1841feb643 | |||
| f14a0393b7 | |||
| 710f77764b | |||
| ae2e86d1d1 | |||
| 167fcb2921 | |||
| 47145ab9d1 | |||
| 441ee8e948 | |||
| 9c4528dfcb | |||
| d7c60631b4 | |||
| 530f5c2dbc | |||
| 03dc2afe8d | |||
| 7d35d91415 | |||
| 4843fae0eb | |||
| 3dfbeac541 | |||
| 4fa4bbb08b | |||
| 4204e619db | |||
| a663b83daa | |||
| fb218a9331 | |||
| 75b677576e | |||
| 04bda0bf10 | |||
| 8ca33dec6f | |||
| eed9303e41 | |||
| 5277e7b47d | |||
| 727c86a804 | |||
| 21edae5944 | |||
| a0d48a1191 | |||
| 086ba9e577 | |||
| bda5fdbecd | |||
| 13d2eeb9b2 | |||
| 1bbf814ea3 | |||
| 6d0e5add0b | |||
| 06849ca666 | |||
| 4346a5554f | |||
| 2a174df697 | |||
| 28df51092c | |||
| ca70852060 | |||
| 86df5cda6e | |||
| 7db7b7d98f | |||
| 3cede88a3d | |||
| af63b71ab8 | |||
| 2805c46a22 | |||
| 104353f013 | |||
| 435f346d98 | |||
| 2b8caa924f | |||
| dd22f303ac | |||
| 02141ae16f | |||
| b9b10a69d5 | |||
| d8631a8594 | |||
| 463769aba9 | |||
| a9fbaf15b2 | |||
| b04c3b9f78 | |||
| f2229c2393 | |||
| b4aadfee3e | |||
| 840e8a774e | |||
| c429d391f5 | |||
| 2fda3dd1d5 | |||
| 4018093c9d | |||
| 2f5b8ce4ed | |||
| 2174c04e4f | |||
| 109a4d7f54 | |||
| 080ee5cff0 | |||
| 257668ab96 | |||
| d747ac7659 | |||
| d7c04ebbc7 | |||
| c5b01b4bb7 | |||
| e8cc90b83d | |||
| d3974018d8 | |||
| 10ce9dbcde | |||
| 5be4bd2ae9 | |||
| ea1d8ab037 | |||
| adde2ce5b9 | |||
| 9d37a7293b | |||
| 975d4aab60 | |||
| 5ead9ee661 | |||
| 95e876b37f | |||
| 4c72d0b3ef | |||
| e7dc030304 | |||
| c9d5c84d35 | |||
| 4b01ba1d2f | |||
| 723e56ada2 | |||
| e9851da12f | |||
| 5826b0419b | |||
| 77124c4549 | |||
| 00dbd1c87e | |||
| e0e732dd2c | |||
| ce69c0ba1f | |||
| 58c4e115ba | |||
| 44478057dd | |||
| d1efae37a4 | |||
| 8d8e4bab26 | |||
| db113f0433 | |||
| 5a66167709 | |||
| 7326201b0a | |||
| f116a6b0f2 | |||
| 3087ab36da | |||
| 28a7189905 | |||
| 0f8e215706 | |||
| fd92bed465 | |||
| 281f6975ec | |||
| e2c40eae48 | |||
| 26968b02a1 | |||
| 0d8070455d | |||
| 2537e964a5 | |||
| ca7134e610 | |||
| a8c591affd | |||
| e3842f200d | |||
| cc952d8c79 | |||
| e11daa0b36 | |||
| 873c77d659 | |||
| 1756a6eb28 | |||
| 9636d3671c | |||
| 22e2ebb96c | |||
| c53d3807e7 | |||
| 23a6392979 | |||
| fa599ad183 | |||
| e79fdcfe58 | |||
| 3746f356b9 | |||
| a5f3362d6e | |||
| 2072264918 | |||
| a5cb01133e | |||
| 62b55cbf16 | |||
| 7ea0c4d36c | |||
| 546200844e | |||
| 007e3d1c0e | |||
| 74a508e3a8 | |||
| 5f5dc171be | |||
| bccba6e9b9 | |||
| c2073a5db5 | |||
| 52ad229d93 | |||
| f6a7352672 | |||
| 46e0322e6f | |||
| 618538a854 | |||
| d62396717a | |||
| a734fa5566 | |||
| f98b302b94 | |||
| 215d36900a | |||
| 2df55d2839 | |||
| e00051caa7 | |||
| 5138b2f1d5 | |||
| 65dfb9103f | |||
| 39bbc036cd | |||
| aaf6dd36f0 | |||
| e7519e68a3 | |||
| ad5803ef9c | |||
| c04b514a2a | |||
| cb9f567154 | |||
| 80afa789e7 | |||
| 43f2ce52a5 | |||
| cf2e24269e | |||
| 6899650bf8 | |||
| c16df51892 | |||
| c549737ad0 | |||
| a85b51843a | |||
| 60d401f5ea | |||
| ca02b9001f | |||
| 2fc5e2865d | |||
| 261b2bfb3c | |||
| 30e32e89b2 | |||
| 8b1a2b9bff | |||
| f71289b248 | |||
| 54eab9af49 | |||
| a05546e811 | |||
| 276c648f29 | |||
| 8c389f4790 | |||
| a09144d21a | |||
| cb9a43f496 | |||
| 30606e4743 | |||
| d05913fbdf | |||
| a34efb50e0 | |||
| a26c69fc8d | |||
| 107803705c | |||
| 858b1689bf | |||
| 641acbd1f4 | |||
| 1de76ae6c1 | |||
| 94f535b8ec | |||
| f072e285fc | |||
| 2c363bbb8e | |||
| 1c2b87d584 | |||
| e55154bd5e | |||
| 71098abb65 | |||
| 7a3a4d1413 | |||
| 7858fb0283 | |||
| 2f9959c009 | |||
| e7d3b20295 | |||
| 264fa4982f | |||
| 9636614761 | |||
| ac6561ca52 | |||
| 923172d39b | |||
| d46c42d26b | |||
| d628233982 | |||
| e9e1d471ec | |||
| f4740916f5 | |||
| 3a2c9b1b05 | |||
| 4cc1147579 | |||
| 976f23a90e | |||
| 8ed500adf7 | |||
| 8658044c0c | |||
| 0edc2cc3af | |||
| f4db4cde13 | |||
| 6bb9313b95 | |||
| 6447dfef50 | |||
| 55cb3a1267 | |||
| 7c5e7208b9 | |||
| aad4b13fda | |||
| 839a20df97 | |||
| d497763e38 | |||
| 12c088c10b | |||
| 8c6a6bece6 | |||
| 819ca8a212 | |||
| 8d1becbd8c | |||
| ebb7491c58 | |||
| 4f1278c37a | |||
| 8ddee9013b | |||
| 7fe233ae2e | |||
| f7b37b1559 | |||
| 0b3624dbd5 | |||
| fb066eb2e9 | |||
| 22fadbb87b | |||
| 3b119d528c | |||
| bb3d3d759e | |||
| 3c08ae9399 | |||
| 7a6fa2afa5 | |||
| ba4e3c3adb | |||
| 66892f69ce | |||
| e37469ac2b | |||
| cdc2d7bbcb | |||
| 92c0e6ff09 | |||
| 9b3fd324c3 | |||
| 23bd692f8e | |||
| 29b4573ca9 | |||
| 8b6755d866 | |||
| 6da20aab05 | |||
| 5aa5942bcd | |||
| 68872d0e06 | |||
| d53c1dc402 | |||
| 6c2b03ae76 | |||
| 9f79d2b737 | |||
| 2241b125d6 | |||
| 152624302c | |||
| 6a703ee6a4 | |||
| 0c0caa422d | |||
| 6fa7c1d4eb | |||
| 509fff3972 | |||
| bcacd8b98e | |||
| d2c8178772 | |||
| 365a246461 | |||
| 098ae13f94 | |||
| 6a92225630 | |||
| c98044be9a | |||
| e6eb81cc61 | |||
| 0e50caadec | |||
| aad44ad42f | |||
| 59fd055526 | |||
| 5fce75a60d | |||
| 74390726b4 | |||
| 10f37b88c3 | |||
| 9bfacd9da9 | |||
| a286770fd2 | |||
| 3101e84830 | |||
| 815b3bebda | |||
| c71eda1229 | |||
| 60518be5bd | |||
| 83254d9d70 | |||
| d34cebc90d | |||
| c7ef51a73c | |||
| ab34fb08c1 | |||
| 6f99e1e8c1 | |||
| 5fe87a04f0 | |||
| 4ac71381da | |||
| f95e1ad4b8 | |||
| 168726c131 | |||
| 4545aeb9c6 | |||
| 84cf3e6a15 | |||
| cd123b3479 | |||
| 4b99de8841 | |||
| 147ca0a41a | |||
| 3f24d55945 | |||
| 70871330d3 | |||
| 08bc354fa5 | |||
| 54c7322b23 | |||
| 4d2ceee26b | |||
| b6321e9698 | |||
| dd197c9826 | |||
| 01427f4926 | |||
| c47a7ba2a5 | |||
| 04564bc9cf | |||
| 04661ce340 | |||
| fcc54e2d6a | |||
| 52fc3b03b7 | |||
| 70c29ed7b6 | |||
| d33741a90d | |||
| 484f117b8e | |||
| 317739b508 | |||
| d8062b4859 | |||
| 452eb70faf | |||
| 83889d7e3c | |||
| 2eb970a6a2 | |||
| 7838762a4e | |||
| f370a670ad | |||
| 41fa6f4b10 | |||
| 18aa9a77dd | |||
| e53f6c0c52 | |||
| 642539cdfc | |||
| 6534fa7171 | |||
| ff08f4c0b5 | |||
| 134c62d543 | |||
| 1243842c68 | |||
| c1093be548 | |||
| b6f58758f2 | |||
| 1dbb59bc3f | |||
| 5e32857729 | |||
| e3a611f33d | |||
| 8fb2a9094e | |||
| 46b9309336 | |||
| e60d2db8cb | |||
| e5e6c00100 | |||
| 40709b7480 | |||
| 900c7154b6 | |||
| 2f4ea02544 | |||
| c24c10a13a | |||
| 22cd2e4bb3 | |||
| 53ac43eb49 | |||
| fa29c46a95 | |||
| d871b4c78e | |||
| 03acdce2c8 | |||
| 9aa3b61efc | |||
| 0a62e3deca | |||
| f454d5f045 | |||
| a5dca65e57 | |||
| 979a7e5d18 | |||
| d1e7154040 | |||
| 4750ff8cd5 | |||
| 72d02010c7 | |||
| b49be42f2d | |||
| eddc183b85 | |||
| 812cf83de4 | |||
| 502cb8403f | |||
| cf78bff21d | |||
| 1e352f4a7e | |||
| 47be85fdc0 | |||
| 5c9849e729 | |||
| e695b4e764 | |||
| d4d2e33619 | |||
| 1603f1ae66 | |||
| 88da476249 | |||
| 1218fde6ea | |||
| 5046398e80 | |||
| 20e5b6cc5a | |||
| c867c4ef51 | |||
| f2e804783b | |||
| 817e18ded2 | |||
| f99b498608 | |||
| d6cd4763f5 | |||
| fdac846ede | |||
| d75c73df30 | |||
| 55c8dffe37 | |||
| 8b35861602 | |||
| 6db7e64ca9 | |||
| 9484f78fb6 | |||
| 1949aeb10f | |||
| 65fad4cc37 | |||
| 876194cdc8 | |||
| cefeac72fc | |||
| 71708c3874 | |||
| b1eae7b768 | |||
| f02985e367 | |||
| 2848f672c1 | |||
| f2210946c2 | |||
| 4b79b9a417 | |||
| 10f8735f55 | |||
| 19a3a14417 | |||
| aeaea1289c | |||
| 014ffa9b74 | |||
| 0a9efe0122 | |||
| 82082a4b89 | |||
| 55bb5b5a1c | |||
| fd399edce7 | |||
| 5611b69ad2 | |||
| 7b181046d3 | |||
| b593d50b9a | |||
| 8fe9426f5c | |||
| 0ce5f72df4 | |||
| d88c6570d0 | |||
| 2980f7c9b8 | |||
| 7b2825e5ce | |||
| e47f79bcd4 | |||
| 415020bf5d | |||
| 6ab1582db8 | |||
| 751a02aae8 | |||
| bee637aa48 | |||
| ad6c7ffda8 | |||
| 7f59ca37f2 | |||
| 9056964bb9 | |||
| 154441bc1b | |||
| 873ec75586 | |||
| e8232a9ea0 | |||
| 751b361528 | |||
| 8990a3e243 | |||
| bc44490e96 | |||
| fe2d0b1d2a | |||
| bf3c5c1602 | |||
| 6364931322 | |||
| 536d7141d9 | |||
| 69e0bfe89a | |||
| aeabb99be6 | |||
| 38ee6d836d | |||
| 7bb4bd3da5 | |||
| 7524615671 | |||
| f5fe883d49 | |||
| bec6406216 | |||
| c13c7ba626 | |||
| ef041f2702 | |||
| 46b222180a | |||
| cd69b317c0 | |||
| 05fa751137 | |||
| 820317b9bd | |||
| bce01ad7a1 | |||
| bbe014798e | |||
| beea4dea04 | |||
| 71505362b4 | |||
| ff6904d436 | |||
| 1915bb3a9b | |||
| df0f15419e | |||
| 04474d2e07 | |||
| 518bf0f217 | |||
| ac8f06c3a2 | |||
| feaf7b8abd | |||
| ac71057a3d | |||
| 0cb8900374 | |||
| dc531eaa37 | |||
| 9a51a9e635 | |||
| 754a0988ee | |||
| 1985c0f815 | |||
| 889b778d43 | |||
| 26e90aa39e | |||
| 1eaabd14bd | |||
| f406342b53 | |||
| b7c800b550 | |||
| b6780ba876 | |||
| c9c8987cca | |||
| f74d336072 | |||
| 6aaaa910af | |||
| 35eee03aa5 | |||
| 06dc6ea23f | |||
| c1f8e7f511 | |||
| 6d39acc627 | |||
| 11f768d26c | |||
| 602afc2954 | |||
| b7203b8219 | |||
| 513774bb7b | |||
| 7375e306fb | |||
| 785d58cb59 | |||
| af61d145da | |||
| 0b75e967f3 | |||
| cfa4210b0a | |||
| 0d6d570ae8 | |||
| 65add36b2f | |||
| 793b3dde12 | |||
| 8b3a76dfc5 | |||
| 60398210c7 | |||
| 9112bcc52f | |||
| e534cffcf7 | |||
| a184dcc38f | |||
| 2c80223fc4 | |||
| 59a578fb2d | |||
| 91c3f3520b | |||
| e169200f40 | |||
| 486c7ef530 | |||
| 26c75e8309 | |||
| bc6eb0b5a0 | |||
| 9a057ef646 | |||
| a7b06bd5fc | |||
| 9d706d3aa3 | |||
| fdc4253117 | |||
| ff168937aa | |||
| b7d060a1f3 | |||
| c37466e948 | |||
| 09513c0beb | |||
| a3f4277bdc | |||
| b43d8918bd | |||
| 4546adb894 | |||
| db5ac37ad3 | |||
| 098c14f9e0 | |||
| 94131097a5 | |||
| 6d69e009dc | |||
| 6d9b132ab8 | |||
| 461a353e92 | |||
| efec1aff18 | |||
| 258d6d9a49 | |||
| 1c4b7c7b97 | |||
| 8bc6306813 | |||
| 2923c00738 | |||
| b30b6a062a | |||
| 8f5df889ab | |||
| 4ec8b19251 | |||
| 1035a94775 | |||
| 3ca2ae7175 | |||
| 4ba1ca890c | |||
| cba012bd15 | |||
| 9515ccd816 | |||
| 46622f5028 | |||
| 1fd896fb72 | |||
| d081cc6c21 | |||
| 9190c8e5bf | |||
| 109498e2df | |||
| 21cfc63fc0 | |||
| a5f14146b9 | |||
| 2c18f6d975 | |||
| 84d9146c04 | |||
| 6d4006fd93 | |||
| b4a2e5ee11 | |||
| 304b814bb1 | |||
| c5e4774b29 | |||
| e90651b55b | |||
| 60d7c395bc | |||
| 2b5c9c2d61 | |||
| a703f1db73 | |||
| 4aaba7619e | |||
| 1a88dd801d | |||
| 83352ab9fe | |||
| 6e268a1bf4 | |||
| 4d65e54576 | |||
| c131ec722e | |||
| 574e12f336 | |||
| 1705868457 | |||
| 8392d111dc | |||
| 8c5ccbadac | |||
| e4aa081e64 | |||
| 8cc74eceb6 | |||
| 45365e3860 | |||
| 3739560956 | |||
| b8cff3e699 | |||
| d1d44afc9d | |||
| 782d847e54 | |||
| be2bfa0087 | |||
| 1ea28d66df | |||
| 8c51957bfa | |||
| 858a1bba4f | |||
| 17e4227978 | |||
| f8b5e07518 | |||
| d96e4019aa | |||
| acc9760690 | |||
| 56dab535c3 | |||
| 94670711e7 | |||
| 673c206e02 | |||
| decd3bd134 | |||
| 6e5c7aeab5 | |||
| 2647550324 | |||
| 424a63011b | |||
| 0e6a125c60 | |||
| 758cae4f86 | |||
| 6e8368c62a | |||
| a14e554323 | |||
| 6435202fa1 | |||
| cf8425ff14 | |||
| 9bb1c1b233 | |||
| c9ccc5e27e | |||
| 7115b2ff54 | |||
| b89e234ba4 | |||
| 70fbaa0bfd | |||
| f6cdd4ff36 | |||
| 1857f46452 | |||
| f95a6f4fd7 | |||
| b0bc66f548 | |||
| 34b4a6c3d8 | |||
| b4c7463226 | |||
| ca5b33ef69 | |||
| acd980091d | |||
| de5317987e | |||
| 6b438bc4aa | |||
| 9b1495a490 | |||
| f638011d63 | |||
| 8447a95c8a | |||
| 4feceaa1d1 | |||
| 8383e381d1 | |||
| a064a7471e | |||
| f0e3d7d09a | |||
| 2b7f4ccd6c | |||
| 46fa89233b | |||
| 591099e42b | |||
| d08398ea57 | |||
| 83f49742b6 | |||
| 50d07f81fd | |||
| 594ee21fcd | |||
| ea2763c48c | |||
| 925fe1cce0 | |||
| d927b462b6 | |||
| 5a79556ab2 | |||
| 260870ad8a | |||
| 7d69e64adc | |||
| 5af51096d8 | |||
| 898392725a | |||
| 9089035f18 | |||
| 66d2a68167 | |||
| 4a41e40592 | |||
| 2a75b920a0 | |||
| 2851eae423 | |||
| efc2295b8d | |||
| 2bee28a1d8 | |||
| a8cc995558 | |||
| 9a11c41424 | |||
| 2a4d056b59 | |||
| 5a77a398bd | |||
| 4cf2238c99 | |||
| 58df4f1481 | |||
| da3f99a254 | |||
| c2fa6095cc | |||
| f0b8ed20a2 | |||
| 0b8b72be5c | |||
| 18c6455837 | |||
| e0477015c4 | |||
| e99a4e2b08 | |||
| c44d06b0dc | |||
| 0e8327c085 | |||
| 5c5a86c7fc | |||
| a785213cb2 | |||
| e041440c97 | |||
| 688ca8a604 | |||
| fd6f0967b0 | |||
| 9fe58935c4 | |||
| 0dfb35730f | |||
| dc52f4c692 | |||
| bcf5395868 | |||
| 3e96a89adf | |||
| c0a882251d | |||
| 6a53b895e5 | |||
| c5354d014c | |||
| 5e9ef37646 | |||
| cb96bea73d | |||
| 95fa2440ce | |||
| ca9698f75d | |||
| 0f1413f130 | |||
| 52a4b604dd | |||
| 3c64ee7af2 | |||
| 026719cd88 | |||
| 9bac00ee29 | |||
| 828c0f66a6 | |||
| 9841e92634 | |||
| 171e7ddcae | |||
| 968a5bd789 | |||
| be119a69af | |||
| 800c3c11be | |||
| 1242da5ed1 | |||
| 8bf4fa0cf1 | |||
| 0693acc07b | |||
| 17eecfca9d | |||
| 4d24d6d17b | |||
| 1fe4ee5b81 | |||
| 137aeac91a | |||
| ccb0b58a2d | |||
| ffc202f6a3 | |||
| f7fd728683 | |||
| 46c04e5a81 | |||
| f43feb825f | |||
| 05cd21d44e | |||
| 4182af75ff | |||
| 680123eb64 | |||
| aec04f0b8c | |||
| e75bbc0a22 | |||
| 81fc625c5d | |||
| f85683239f | |||
| 507f769357 | |||
| e3f7e8c97a | |||
| 49e9e26bff | |||
| fccd4c12ca | |||
| 06c9ff481e | |||
| 50e5775062 | |||
| 91da8db589 | |||
| 0d854ae42b | |||
| ec21050fad | |||
| 67c61a5829 | |||
| e685668959 | |||
| de13eb5b96 | |||
| c0f54c334e | |||
| f134fcb528 | |||
| d5954a3a32 | |||
| bd28e312fc | |||
| 7208d5b2bf | |||
| 5c2d4e4718 | |||
| 8cdeae6c3f | |||
| e7bc6d09f2 | |||
| 4ce2699a48 | |||
| 7c5cdb9161 | |||
| 64a0aa6157 | |||
| 34d04e57dd | |||
| 1317c5bddc | |||
| 74b6f565e9 | |||
| 08f49d4d0b | |||
| 99605b6a55 | |||
| beeeabc377 | |||
| ff2e40d49a | |||
| 31c5eeb6c3 | |||
| 8004ee48c9 | |||
| a1d48a28e9 | |||
| 0f81f45c5f | |||
| 05f7957557 | |||
| 1ed8f5d124 | |||
| 2ee5be7402 | |||
| 1226e7bee1 | |||
| dcbc52efc6 | |||
| 92b0a1478a | |||
| f27c7fdf31 | |||
| 18a427b501 | |||
| b7951b730d | |||
| 342203bb81 | |||
| e4bc526a09 | |||
| f75e30afd0 | |||
| 9f11238d43 | |||
| 070a1b47e5 | |||
| 3e8661f5ca | |||
| 9f8c27ddc1 | |||
| bafaaf9c47 | |||
| 1ee5863da7 | |||
| ace4d83789 | |||
| 1da1c178d0 | |||
| c429cb2ed1 | |||
| 40c40f81fc | |||
| 6647a3b083 | |||
| 782eaef440 | |||
| 6003310a39 | |||
| 229ac5006b | |||
| 322687c658 | |||
| fe3963dfe2 | |||
| e4a57b97b7 | |||
| 9b48c498f5 | |||
| 4228177920 | |||
| e98637321d | |||
| 5941bd4b68 | |||
| a686360c1f | |||
| 20ee9da1ec | |||
| c89baf34a8 | |||
| 00230d1b8f | |||
| 4396d57e3d | |||
| 86789f677a | |||
| 8fb2deeab0 | |||
| 2099bbe58f | |||
| 1c95319608 | |||
| eeea948844 | |||
| c4b1820d08 | |||
| 59cc2741b8 | |||
| cc34d33090 | |||
| 59bb0070e9 | |||
| ec2206ade0 | |||
| 06a3e6b472 | |||
| 9108882921 | |||
| 7796f7d3bc | |||
| 00a0ae6561 | |||
| 6310293190 | |||
| 809930df9a | |||
| f1874d4ab1 | |||
| cc0f401855 | |||
| 42626f3bce | |||
| 22d570b024 | |||
| 6d0a07f212 | |||
| a512b5a110 | |||
| bde3dade14 | |||
| de2058d966 | |||
| 7f191764be | |||
| 7f9da757aa | |||
| f07e8cfe14 | |||
| b806bf80b1 | |||
| 173ea58701 | |||
| 3ad5b72ebf | |||
| 567e2e5d6d | |||
| 616bd0ac91 | |||
| 108a169e7c | |||
| eab902d68e | |||
| 985f6e89ec | |||
| 0480989fd2 | |||
| 72ffe420b7 | |||
| 775b6ff4fd | |||
| b0f18461b3 | |||
| b8ccbfd222 | |||
| c2fa497137 | |||
| bdcfa6929c | |||
| 8470b58b60 | |||
| 002413c067 | |||
| ecce59e734 | |||
| f5d169eaa2 | |||
| b3b921e1ae | |||
| 91f15b723e | |||
| 303dcb1eb6 | |||
| 4eaeb1b020 | |||
| f13427ca27 | |||
| 458f2cdf16 | |||
| df588f25bf | |||
| bd0fdff29c | |||
| 774da61da1 | |||
| 42e67e01aa | |||
| 36e201e824 | |||
| 497233c9f1 | |||
| 4b7c9a1bd3 | |||
| 7c2d6d6618 | |||
| c44e0afb81 | |||
| d3ef3c7452 | |||
| 1935c76f30 | |||
| 71056d8f15 | |||
| 8d34119e7a | |||
| f159ee77cd | |||
| d336c4f5b7 | |||
| 81b7a3e665 | |||
| 1870f74f0c | |||
| d19f9c6888 | |||
| a68bf6fc8f | |||
| c2d2745777 | |||
| fc8bf841bf | |||
| 08f435597a | |||
| 58a4e475ad | |||
| 5bfc911e1b | |||
| d2c7362736 | |||
| 82cac690fa | |||
| 3c3c902087 | |||
| 964538eb43 | |||
| 5e8b2bdb50 | |||
| e3d10495f3 | |||
| 6c3886ad24 | |||
| ba727f53c4 | |||
| 35a4737e43 | |||
| abde8652b2 | |||
| cadef0bf81 | |||
| caac696244 | |||
| 6910a0b4bd | |||
| 74e2584e4d | |||
| 459dd2d9c7 | |||
| 0f5c83c1c2 | |||
| fed4cc2a97 | |||
| c238711b3e | |||
| f42334917e | |||
| 09004d4c09 | |||
| 68da9b2f69 | |||
| 454ff37a72 | |||
| ca13d18d7d | |||
| 1657a7dbe3 | |||
| 61e925eaab | |||
| 09d3313e15 | |||
| a20d61037e | |||
| 7eaa692712 | |||
| 691bae9a96 | |||
| d5a8c9b7d1 | |||
| 8c20e7c661 | |||
| 47a2d28c6a | |||
| 31f8961e27 | |||
| 424bd0bc28 | |||
| 9c078583dd | |||
| ca27048679 | |||
| 4e65663748 | |||
| c7c5cbde83 | |||
| a4905ad207 | |||
| bebf0e692a | |||
| 8ff9a87dfe | |||
| 62f2d8ac16 | |||
| 8fef2a6232 | |||
| 94064fe78c | |||
| 2ffcc43adc | |||
| 3846fce73a | |||
| ea950e9dbc | |||
| f2639c4ff1 | |||
| 32c1798eb8 | |||
| 75e3167b65 | |||
| ad07a61aa7 | |||
| c91b6329f3 | |||
| 9cc60efd5a | |||
| 08eeea6b9c | |||
| 8dea7335de | |||
| 2ad6d43422 | |||
| 12c2e7aefb | |||
| 6b62e46950 | |||
| 853c58e0a0 | |||
| eb0abc425a | |||
| c808e40bf6 | |||
| f0bbb14f3f | |||
| 95dd0ea6fb | |||
| 7f34102ae6 | |||
| 7623962da5 | |||
| cfb34b59df | |||
| e5004bb55e | |||
| c0193fdf73 | |||
| 6cbafd557c | |||
| ee8ab75907 | |||
| f2e93ad69e | |||
| 5faf3fd61c | |||
| 956a8f4864 | |||
| d26bc56b5c | |||
| 7457770ef8 | |||
| 54af9073cb | |||
| a8dcf5e8f5 | |||
| 9e3334d75f | |||
| cca6e71911 | |||
| 7fbd377ab2 | |||
| 24417feba3 | |||
| f8c24964e3 | |||
| 1ae2ebfaf0 | |||
| 4feea6d153 | |||
| ec6b658685 | |||
| fb0f05a08d | |||
| 11bc477f1f | |||
| 9760375855 | |||
| a6e20bd9f0 | |||
| 90fedbf9a2 | |||
| eb03262abc | |||
| 59eb6e5f1b | |||
| edf513aca9 | |||
| efed63519a | |||
| d78f781506 | |||
| 93fe269b09 | |||
| 8cad6c4e56 | |||
| f92049dc71 | |||
| a3497a9d39 | |||
| bfc0a2ed57 | |||
| c49b45d262 | |||
| 15678cf96a | |||
| feeaaa7f2b | |||
| 50df1a2212 | |||
| ac9254d049 | |||
| e15eeb36a5 | |||
| e275e03d4e | |||
| 41c8826ca8 | |||
| d8af31ba5b | |||
| 2eb7cb1687 | |||
| 207e75f5b9 | |||
| 7b9e1a71a3 | |||
| 345838c6ce | |||
| b02a60f4b3 | |||
| ecd3a4e490 | |||
| 8c0c9bd60a | |||
| 943a8bf02d | |||
| d3beb72652 | |||
| c62dd2014e | |||
| 62fee7827b | |||
| 80b9d16494 | |||
| cb5581c49f | |||
| 0098000ae0 | |||
| ddc8429499 | |||
| 0424961d46 | |||
| cbf510cfd1 | |||
| cbb44ae253 | |||
| 4dd4f045aa | |||
| ab0d7f8dc6 | |||
| 69f93fcb59 | |||
| de68e0d7c2 | |||
| cdbcb451e1 | |||
| 105c543a98 | |||
| ab421e3184 | |||
| d76b7a99b8 | |||
| e8dae63e05 | |||
| ea58b70435 | |||
| f90f6f364a | |||
| 7fc967c64c | |||
| 8969a229d1 | |||
| 9601e0428e | |||
| 94fd91ce4a | |||
| 310f972c7f | |||
| 4378a5843c | |||
| 9bd403ec51 | |||
| 2f53786ca9 | |||
| 07ed213c94 | |||
| 05a2eca9a7 | |||
| d30c836d04 | |||
| 8c623adad8 | |||
| 5191edfc0c | |||
| ff99663d5c | |||
| 360335a608 | |||
| 1c83e5eeab | |||
| 122ebb12f4 | |||
| fed242315d | |||
| 84e8e18ef8 | |||
| 36a1916b5f | |||
| a1089460d7 | |||
| c62f0dea6f | |||
| a6c121dc33 | |||
| c627c65a7d | |||
| 72006aff21 | |||
| 68338ebeff | |||
| 49b8503b64 | |||
| 0fc41df7e7 | |||
| bb82c52747 | |||
| a79367fb1c | |||
| 4dbc6db6f0 | |||
| a50cee62be | |||
| 382aa5cb16 | |||
| d1c2ff277b | |||
| 92b08b5550 | |||
| da85470fef | |||
| 9d1e7d94cc | |||
| 65438286ec | |||
| ffa7d27148 | |||
| 4a7d951d0d | |||
| 89f1911a6e | |||
| b990bd1792 | |||
| 88667416d8 | |||
| 216491012e | |||
| c88f3dcf75 | |||
| 6c3e21339d | |||
| e7f9f9f13d | |||
| 6b8d6da5be | |||
| 8c73c5d662 | |||
| f7dc2c9a9e | |||
| eadf825b67 | |||
| 150999d71b | |||
| 7cd89a594e | |||
| b67f1cb4b8 | |||
| 4678f8c7da | |||
| 0577f48437 | |||
| 0c079482f0 | |||
| 684fe3945d | |||
| d91d325744 | |||
| 040d7564ed | |||
| d1db34445e | |||
| 9639dd422a | |||
| f60bfe8c54 | |||
| fe53c11447 | |||
| 9bd17bdf6f | |||
| 4b64308951 | |||
| bb7dacea91 | |||
| 0a369621a3 | |||
| e0ee1a50ae | |||
| 6b49fc4294 | |||
| ed20ea6af4 | |||
| 73fe4dc7a0 | |||
| c4967de530 | |||
| bcf3d36ba1 | |||
| d52bd7f012 | |||
| e6232be244 | |||
| b33f313e2e | |||
| 0b4372fe88 | |||
| 4e07c7f2dc | |||
| 941e194df3 | |||
| 2b8f94f457 | |||
| 7ec8c0cea5 | |||
| c69384dabd | |||
| 8c92216a1d | |||
| 41537c0bad | |||
| c112f56b37 | |||
| f22de50527 | |||
| a22e08f39d | |||
| 210d470473 | |||
| 0eebb77438 | |||
| f819cb9c5f | |||
| 240963f1f3 | |||
| 16819d98fa | |||
| 8be7e0f0cb | |||
| 3a51daf51b | |||
| 7622e72b70 | |||
| b59173cac4 | |||
| 18411ee5bd | |||
| 6e1c6fab2d | |||
| 98eb2d8836 | |||
| 504e32f922 | |||
| c096054b1f | |||
| ac2f198851 | |||
| 9aed659f17 | |||
| 0b8f5d3b22 | |||
| 55c74e8891 | |||
| 3a49aa6a67 | |||
| 10770b6fe1 | |||
| c81ea08f42 | |||
| 73b6ab4a18 | |||
| 7497235d7b | |||
| 27191e4234 | |||
| 7b0110ce42 | |||
| 117a635a1e | |||
| 98c922fb3e | |||
| bf84d04f1f | |||
| f4e358b509 | |||
| 060ad7966e | |||
| f0301fd1a4 | |||
| ae8212a51d | |||
| 393a0d5cdc | |||
| 4cf43a8d74 | |||
| 74b2f47e3a | |||
| 1e727db09a | |||
| 1daa120d06 | |||
| a1d2445ae6 | |||
| 4d4e35e24b | |||
| 400cc599e3 | |||
| e55352346b | |||
| cca226dec0 | |||
| fec95c91f8 | |||
| 9955418a8e | |||
| 90c7539956 | |||
| a751e45602 | |||
| b50d388f9e | |||
| fd60292b5d | |||
| 4ebb0c432e | |||
| 897b2478e8 | |||
| b8ebb7f6c4 | |||
| f32dba72b4 | |||
| 498ad280e0 | |||
| 32358de718 | |||
| 2474a6ce01 | |||
| 1ba45200ee | |||
| da793856ce | |||
| d950588c36 | |||
| 2b4a5d2ce7 | |||
| 86daedc802 | |||
| 3788487196 | |||
| 25559b7e3e | |||
| 246db33ee6 | |||
| d435e9b58b | |||
| 09ecc79050 | |||
| 1914435707 | |||
| f6c237afc5 | |||
| a1f2579047 | |||
| 1ea6617a5d | |||
| 489175aa45 | |||
| cb72f43b03 | |||
| 4bbbcc7c39 | |||
| af1e4884b7 | |||
| 5213d6255a | |||
| a9af689aa5 | |||
| 407a9f7780 | |||
| a0ca667ca7 | |||
| c2f6f97c34 | |||
| 2daefbe2f4 | |||
| 84b0c9d4b7 | |||
| 0d848569f0 | |||
| 611f8397ca | |||
| 11ed0a1367 | |||
| ff51966fbb | |||
| 5491d51eba | |||
| 61a5a7e929 | |||
| 3de000bc94 | |||
| ef456e6ea0 | |||
| 2a8b67e22a | |||
| c35b66f6e1 | |||
| c8348dcaaa | |||
| e38174110e | |||
| a95130c01f | |||
| 0e93417090 | |||
| 07054bf55a | |||
| 368eab476a | |||
| 996679a2d2 | |||
| 85a6943cd5 | |||
| 0b96893f3b | |||
| 846e2e27ba | |||
| 43ea9b7696 | |||
| 9dd4df2ca9 | |||
| 2b4fb55526 | |||
| 72cf16301f | |||
| c512dde028 | |||
| 1e13c7ab31 | |||
| cdbab86dee | |||
| fec03d1fd4 | |||
| 6aa24e23c0 | |||
| 78770d1da5 | |||
| 6f72447e2e | |||
| cb75a15a6f | |||
| c3555237b3 | |||
| e4a2cc7ac8 | |||
| 3900d305b9 | |||
| cb3d501649 | |||
| 28323a486a | |||
| dfcad4b9fd | |||
| 6fb2869cd8 | |||
| e764e39ba9 | |||
| 128077dcbc | |||
| 1c51107f1e | |||
| d154cab054 | |||
| 7ed4368d5b | |||
| ee64df2376 | |||
| b13f03eb97 | |||
| 8d20829428 | |||
| 97401f609e | |||
| fe074729ea | |||
| db5141e010 | |||
| 4564fdc6aa | |||
| a477b36a57 | |||
| 3b8ae2c879 | |||
| ebe3a51398 | |||
| 76d22f0cb5 | |||
| c61d676dfb | |||
| b1913e7204 | |||
| b6609e0a14 | |||
| 55fa759344 | |||
| 8992a713cc | |||
| c55dcec252 | |||
| e3dd6cbef5 | |||
| dd3e5ea368 | |||
| ac2e77e0d6 | |||
| 9f57622f54 | |||
| cfed460eba | |||
| 06f97b671f | |||
| aebf83d735 | |||
| 31894dd117 | |||
| e041d802ec | |||
| 82ea15388c | |||
| bf9ed8ff00 | |||
| c02606df6a | |||
| 7372e2e385 | |||
| ba86fa6d3e | |||
| 0e434cbd1c | |||
| c89300022a | |||
| 1300756d6f | |||
| c4ad02ff92 | |||
| b3f47f140a | |||
| 2206b3d5b5 | |||
| b08f8a450d | |||
| 37c8be8a6e | |||
| ae58c265a0 | |||
| 54e6d1aa16 | |||
| 4ddb5f14d9 | |||
| 623aec495b | |||
| f6d2b9bad0 | |||
| 08b5a278f3 | |||
| f62b30b50d | |||
| 50e3b8e7d4 | |||
| e26956dbe8 | |||
| cff2c12d70 | |||
| 5781d532a4 | |||
| f161a593f8 | |||
| 5725d5a2fe | |||
| 23280fd97b | |||
| fe6679f16a | |||
| 19a95a3670 | |||
| 90cffb3791 | |||
| 31168fbeca | |||
| c4cce5d184 | |||
| 08b59dd082 | |||
| 4aaf1a5868 | |||
| 6e78fa0b1f | |||
| e1a42189a6 | |||
| 386e0c9b6b | |||
| 3b1b423936 | |||
| 8e8e8161bb | |||
| b368fde82d | |||
| 7267111083 | |||
| d05dab6633 | |||
| e1409a8045 | |||
| ae69fec7ce | |||
| a2862f22f6 | |||
| 7db8e18bcc | |||
| 0ffe1272fe | |||
| 92b54075c4 | |||
| ce5c679d6b | |||
| 4f61386b21 | |||
| 2738ae1abc | |||
| f5e43ff7b4 | |||
| 63c499bf2c | |||
| 9e72720bda | |||
| bbe10b2dab | |||
| f3b0784651 | |||
| 9c0ea9b1c7 | |||
| 620a088c6c | |||
| 867a74cffb | |||
| f2316fdd3a | |||
| 7d49d4f948 | |||
| f85b2b889c | |||
| 9471ac4a52 | |||
| db520c39e3 | |||
| cc59fbe2ba | |||
| e260af58f2 | |||
| 166fc6dad9 | |||
| 959433d737 | |||
| f9fa9ce6d8 | |||
| 6b3a41dfe0 | |||
| 37428ecca4 | |||
| 6934df253f | |||
| 00782598a4 | |||
| 565c500810 | |||
| e3c16166e6 | |||
| cfa8d1b689 | |||
| a19397f9b5 | |||
| ddfc80b45f | |||
| 8591f9b2a1 | |||
| cb26a55e65 | |||
| ef92394685 | |||
| d588ef438e | |||
| 09cd363b11 | |||
| 2d5c7fdbb5 | |||
| 2f0e28368d | |||
| f7f1a2a3b3 | |||
| 30afb85260 | |||
| 78d883a1b4 | |||
| 7913b673a3 | |||
| 5edc27297f | |||
| ebc24c2476 | |||
| ed7dd037e5 | |||
| 277924c04d | |||
| 26ea0feddb | |||
| 63c1eab930 | |||
| 813e7711df | |||
| 6c1f50a230 | |||
| 470b6359ba | |||
| 2f45233748 | |||
| 82fd52f572 | |||
| ed6331e6a4 | |||
| 2ae9188535 | |||
| 1a55a5394a | |||
| 99d2f37cfc | |||
| 09b531e0c1 | |||
| 232e872c0d | |||
| acdb0d2838 | |||
| bd0ea1379f | |||
| 5461ea1a3a | |||
| 200ee075b5 | |||
| 79e9e5fcf1 | |||
| a2df23d562 | |||
| 55af3d7f65 | |||
| ef54f3fe59 | |||
| 9d84ff6aa7 | |||
| ee26006f3c |
@@ -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
|
||||
@@ -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,146 +34,232 @@ 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: list[dict[str, Any]]) -> list[str]:
|
||||
os_values: list[str] = []
|
||||
for item in install_methods:
|
||||
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 split_notes(notes_raw: list[dict[str, Any]]) -> tuple[list[str], list[str]]:
|
||||
"""Split PocketBase notes into (info_notes, warnings).
|
||||
|
||||
Each entry has shape ``{"text": str, "type": "warning"|...}``. Anything
|
||||
flagged ``type == "warning"`` lands in the warnings list so the bash
|
||||
menu can render those in red with a dedicated WARNINGS header. Other
|
||||
notes go to the regular notes list.
|
||||
"""
|
||||
info: list[str] = []
|
||||
warns: list[str] = []
|
||||
for note in notes_raw or []:
|
||||
if not isinstance(note, dict):
|
||||
continue
|
||||
text = note.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
continue
|
||||
text = text.strip()
|
||||
ntype = (note.get("type") or "").strip().lower()
|
||||
if ntype == "warning":
|
||||
warns.append(text)
|
||||
else:
|
||||
info.append(text)
|
||||
return info, warns
|
||||
|
||||
|
||||
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)
|
||||
# Sprint 11.7: PocketBase exposes these as `install_methods` and
|
||||
# `notes`, not `install_methods_json` / `notes_json`. The legacy field
|
||||
# names silently returned [] for every entry, which is why the cache
|
||||
# had empty notes and missing OS variants for every script.
|
||||
install_methods = raw.get("install_methods", [])
|
||||
if not isinstance(install_methods, list):
|
||||
install_methods = []
|
||||
|
||||
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_raw = raw.get("notes", [])
|
||||
if not isinstance(notes_raw, list):
|
||||
notes_raw = []
|
||||
|
||||
notes, warnings = split_notes(notes_raw)
|
||||
|
||||
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,
|
||||
"warnings": warnings,
|
||||
"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)
|
||||
|
||||
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)
|
||||
|
||||
total_notes = sum(len(e.get("notes", [])) for e in cache)
|
||||
total_warns = sum(len(e.get("warnings", [])) for e in cache)
|
||||
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)} entries, {total_notes} notes, {total_warns} warnings")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
# ---------- Config ----------
|
||||
# API_URL = "https://api.github.com/repos/community-scripts/ProxmoxVE/contents/frontend/public/json"
|
||||
API_URL = "https://api.github.com/repos/community-scripts/ProxmoxVE-Frontend-Archive/contents/public/json"
|
||||
SCRIPT_BASE = "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main"
|
||||
|
||||
# Escribimos siempre en <raiz_repo>/json/helpers_cache.json, independientemente del cwd
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
OUTPUT_FILE = REPO_ROOT / "json" / "helpers_cache.json"
|
||||
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
# ----------------------------
|
||||
|
||||
|
||||
def to_mirror_url(raw_url: str) -> str:
|
||||
"""
|
||||
Convierte una URL raw de GitHub al raw del mirror.
|
||||
GH : https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/docker.sh
|
||||
MIR: https://git.community-scripts.org/community-scripts/ProxmoxVE/raw/branch/main/ct/docker.sh
|
||||
"""
|
||||
m = re.match(r"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)$", raw_url or "")
|
||||
if not m:
|
||||
return ""
|
||||
org, repo, branch, path = m.groups()
|
||||
if org.lower() != "community-scripts" or repo != "ProxmoxVE":
|
||||
return ""
|
||||
return f"https://git.community-scripts.org/community-scripts/ProxmoxVE/raw/branch/{branch}/{path}"
|
||||
|
||||
|
||||
def guess_os_from_script_path(script_path: str) -> str | None:
|
||||
"""
|
||||
Heurística suave cuando el JSON no publica resources.os:
|
||||
- tools/pve/* -> proxmox
|
||||
- ct/alpine-* -> alpine
|
||||
- tools/addon/* -> generic (suele ejecutarse sobre LXC existente)
|
||||
- ct/* -> debian (por defecto para CTs)
|
||||
"""
|
||||
if not script_path:
|
||||
return None
|
||||
if script_path.startswith("tools/pve/") or script_path == "tools/pve/host-backup.sh" or script_path.startswith("vm/"):
|
||||
return "proxmox"
|
||||
if "/alpine-" in script_path or script_path.startswith("ct/alpine-"):
|
||||
return "alpine"
|
||||
if script_path.startswith("tools/addon/"):
|
||||
return "generic"
|
||||
if script_path.startswith("ct/"):
|
||||
return "debian"
|
||||
return None
|
||||
|
||||
|
||||
def fetch_directory_json(api_url: str) -> list[dict]:
|
||||
r = requests.get(api_url, timeout=30)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not isinstance(data, list):
|
||||
raise RuntimeError("GitHub API no devolvió una lista.")
|
||||
return data
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
directory = fetch_directory_json(API_URL)
|
||||
except Exception as e:
|
||||
print(f"ERROR: No se pudo leer el índice de JSONs: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
cache: list[dict] = []
|
||||
seen: set[tuple[str, str]] = set() # (slug, script) para evitar duplicados
|
||||
|
||||
total_items = len(directory)
|
||||
processed = 0
|
||||
kept = 0
|
||||
|
||||
for item in directory:
|
||||
url = item.get("download_url")
|
||||
name_in_dir = item.get("name", "")
|
||||
if not url or not url.endswith(".json"):
|
||||
continue
|
||||
|
||||
try:
|
||||
raw = requests.get(url, timeout=30).json()
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
except Exception:
|
||||
print(f"❌ Error al obtener/parsing {name_in_dir}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
processed += 1
|
||||
|
||||
name = raw.get("name", "")
|
||||
slug = raw.get("slug")
|
||||
type_ = raw.get("type", "")
|
||||
desc = raw.get("description", "")
|
||||
categories = raw.get("categories", [])
|
||||
notes = [n.get("text", "") for n in raw.get("notes", []) if isinstance(n, dict)]
|
||||
|
||||
# Credenciales (si existen, se copian tal cual)
|
||||
credentials = raw.get("default_credentials", {})
|
||||
cred_username = credentials.get("username") if isinstance(credentials, dict) else None
|
||||
cred_password = credentials.get("password") if isinstance(credentials, dict) else None
|
||||
add_credentials = any([
|
||||
cred_username not in (None, ""),
|
||||
cred_password not in (None, "")
|
||||
])
|
||||
|
||||
install_methods = raw.get("install_methods", [])
|
||||
if not isinstance(install_methods, list) or not install_methods:
|
||||
# Sin install_methods válidos -> continuamos
|
||||
continue
|
||||
|
||||
for im in install_methods:
|
||||
if not isinstance(im, dict):
|
||||
continue
|
||||
script = im.get("script", "")
|
||||
if not script:
|
||||
continue
|
||||
|
||||
# OS desde resources u heurística
|
||||
resources = im.get("resources", {}) if isinstance(im, dict) else {}
|
||||
os_name = resources.get("os") if isinstance(resources, dict) else None
|
||||
if not os_name:
|
||||
os_name = guess_os_from_script_path(script)
|
||||
if isinstance(os_name, str):
|
||||
os_name = os_name.strip().lower()
|
||||
|
||||
full_script_url = f"{SCRIPT_BASE}/{script}"
|
||||
script_url_mirror = to_mirror_url(full_script_url)
|
||||
|
||||
key = (slug or "", script)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
entry = {
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"desc": desc,
|
||||
"script": script,
|
||||
"script_url": full_script_url,
|
||||
"script_url_mirror": script_url_mirror, # nuevo
|
||||
"os": os_name, # nuevo
|
||||
"categories": categories,
|
||||
"notes": notes,
|
||||
"type": type_,
|
||||
}
|
||||
if add_credentials:
|
||||
entry["default_credentials"] = {
|
||||
"username": cred_username,
|
||||
"password": cred_password,
|
||||
}
|
||||
|
||||
cache.append(entry)
|
||||
kept += 1
|
||||
|
||||
# Progreso ligero
|
||||
print(f"[{kept:03d}] {slug or name:<24} → {script:<28} os={os_name or 'n/a'} src={'GH+MR' if script_url_mirror else 'GH'}")
|
||||
|
||||
# Orden estable para commits reproducibles
|
||||
cache.sort(key=lambda x: (x.get("slug") or "", x.get("script") or ""))
|
||||
|
||||
with OUTPUT_FILE.open("w", encoding="utf-8") as f:
|
||||
json.dump(cache, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\n✅ helpers_cache.json → {OUTPUT_FILE}")
|
||||
print(f" Total JSON en índice: {total_items}")
|
||||
print(f" Procesados: {processed} | Guardados: {kept} | Únicos (slug,script): {len(seen)}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Build AppImage Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: AppImage
|
||||
run: npm install --legacy-peer-deps
|
||||
|
||||
- name: Build Next.js app
|
||||
working-directory: AppImage
|
||||
run: npm run build
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3 python3-pip python3-venv
|
||||
|
||||
- name: Make build script executable
|
||||
working-directory: AppImage
|
||||
run: chmod +x scripts/build_appimage.sh
|
||||
|
||||
- name: Build AppImage
|
||||
working-directory: AppImage
|
||||
run: ./scripts/build_appimage.sh
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
working-directory: AppImage
|
||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate SHA256 checksum
|
||||
run: |
|
||||
cd AppImage/dist
|
||||
sha256sum *.AppImage > ProxMenux-Monitor.AppImage.sha256
|
||||
echo "Generated SHA256:"
|
||||
cat ProxMenux-Monitor.AppImage.sha256
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
|
||||
path: |
|
||||
AppImage/dist/*.AppImage
|
||||
AppImage/dist/*.sha256
|
||||
retention-days: 30
|
||||
|
||||
- name: Commit AppImage to main
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
rm -f AppImage/*.AppImage AppImage/*.sha256 || true
|
||||
cp AppImage/dist/*.AppImage AppImage/
|
||||
cp AppImage/dist/ProxMenux-Monitor.AppImage.sha256 AppImage/
|
||||
|
||||
git add AppImage/*.AppImage AppImage/*.sha256
|
||||
git commit -m "Update AppImage release build ($(date +'%Y-%m-%d %H:%M:%S'))" || echo "No changes to commit"
|
||||
git push origin main
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
f35de512c1a19843d15a9a3263a5104759d041ffc9d01249450babe0b0c3f889 ProxMenux-1.0.1.AppImage
|
||||
db5bc199adba9c231f344428ac902a0cbf7473778e8a79a4535263599d975449 ProxMenux-1.2.0.AppImage
|
||||
|
||||
@@ -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:
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,395 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SriovInfo {
|
||||
role: "vf" | "pf-active" | "pf-idle"
|
||||
physfn?: string // VF only: parent PF BDF
|
||||
vfCount?: number // PF only: active VF count
|
||||
totalvfs?: number // PF only: maximum VFs
|
||||
}
|
||||
|
||||
interface GpuSwitchModeIndicatorProps {
|
||||
mode: "lxc" | "vm" | "sriov" | "unknown"
|
||||
isEditing?: boolean
|
||||
pendingMode?: "lxc" | "vm" | null
|
||||
onToggle?: (e: React.MouseEvent) => void
|
||||
className?: string
|
||||
sriovInfo?: SriovInfo
|
||||
}
|
||||
|
||||
export function GpuSwitchModeIndicator({
|
||||
mode,
|
||||
isEditing = false,
|
||||
pendingMode = null,
|
||||
onToggle,
|
||||
className,
|
||||
sriovInfo,
|
||||
}: GpuSwitchModeIndicatorProps) {
|
||||
// SR-IOV is a non-editable hardware state. Pending toggles don't apply here.
|
||||
const displayMode = mode === "sriov" ? "sriov" : (pendingMode ?? mode)
|
||||
const isLxcActive = displayMode === "lxc"
|
||||
const isVmActive = displayMode === "vm"
|
||||
const isSriovActive = displayMode === "sriov"
|
||||
const hasChanged =
|
||||
mode !== "sriov" && pendingMode !== null && pendingMode !== mode
|
||||
|
||||
// Colors
|
||||
const sriovColor = "#14b8a6" // teal-500
|
||||
const activeColor = isSriovActive
|
||||
? sriovColor
|
||||
: isLxcActive
|
||||
? "#3b82f6"
|
||||
: isVmActive
|
||||
? "#a855f7"
|
||||
: "#6b7280"
|
||||
const inactiveColor = "#374151" // gray-700 for dark theme
|
||||
const dimmedColor = "#4b5563" // gray-600 for dashed SR-IOV branches
|
||||
const lxcColor = isLxcActive ? "#3b82f6" : inactiveColor
|
||||
const vmColor = isVmActive ? "#a855f7" : inactiveColor
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// SR-IOV state can't be toggled — swallow the click so it doesn't reach
|
||||
// the card (which would open the detail modal unexpectedly from this
|
||||
// area). For lxc/vm, preserve the original behavior.
|
||||
if (isSriovActive) {
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (isEditing) {
|
||||
e.stopPropagation()
|
||||
if (onToggle) {
|
||||
onToggle(e)
|
||||
}
|
||||
}
|
||||
// When not editing, let the click propagate to the card to open the modal
|
||||
}
|
||||
|
||||
// Build the VF count label shown in the SR-IOV badge. For PFs we know
|
||||
// exactly how many VFs are active; for a VF we show its parent PF.
|
||||
const sriovBadgeText = (() => {
|
||||
if (!isSriovActive) return ""
|
||||
if (sriovInfo?.role === "vf") return "SR-IOV VF"
|
||||
if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) return `SR-IOV ×${sriovInfo.vfCount}`
|
||||
return "SR-IOV"
|
||||
})()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-6",
|
||||
isEditing && !isSriovActive && "cursor-pointer",
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Large SVG Diagram */}
|
||||
<svg
|
||||
viewBox="0 0 220 100"
|
||||
className="h-24 w-56 flex-shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{/* GPU Chip - Large with "GPU" text */}
|
||||
<g transform="translate(0, 22)">
|
||||
{/* Main chip body */}
|
||||
<rect
|
||||
x="4"
|
||||
y="8"
|
||||
width="44"
|
||||
height="36"
|
||||
rx="6"
|
||||
fill={`${activeColor}20`}
|
||||
stroke={activeColor}
|
||||
strokeWidth="2.5"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
{/* Chip pins - top */}
|
||||
<line x1="14" y1="2" x2="14" y2="8" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
<line x1="26" y1="2" x2="26" y2="8" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
<line x1="38" y1="2" x2="38" y2="8" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
{/* Chip pins - bottom */}
|
||||
<line x1="14" y1="44" x2="14" y2="50" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
<line x1="26" y1="44" x2="26" y2="50" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
<line x1="38" y1="44" x2="38" y2="50" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
{/* GPU text */}
|
||||
<text
|
||||
x="26"
|
||||
y="32"
|
||||
textAnchor="middle"
|
||||
fill={activeColor}
|
||||
className="text-[14px] font-bold transition-all duration-300"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
GPU
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* Connection line from GPU to switch */}
|
||||
<line
|
||||
x1="52"
|
||||
y1="50"
|
||||
x2="78"
|
||||
y2="50"
|
||||
stroke={activeColor}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* Central Switch Node - Large circle with inner dot */}
|
||||
<circle
|
||||
cx="95"
|
||||
cy="50"
|
||||
r="14"
|
||||
fill={isEditing && !isSriovActive ? "#f59e0b20" : `${activeColor}20`}
|
||||
stroke={isEditing && !isSriovActive ? "#f59e0b" : activeColor}
|
||||
strokeWidth="3"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
<circle
|
||||
cx="95"
|
||||
cy="50"
|
||||
r="6"
|
||||
fill={isEditing && !isSriovActive ? "#f59e0b" : activeColor}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* LXC Branch Line - going up-right.
|
||||
In SR-IOV mode the branch is dashed + dimmed to show that the
|
||||
target is theoretically reachable via a VF but not controlled
|
||||
by ProxMenux. */}
|
||||
<path
|
||||
d="M 109 42 L 135 20"
|
||||
fill="none"
|
||||
stroke={isSriovActive ? dimmedColor : lxcColor}
|
||||
strokeWidth={isLxcActive ? "3.5" : "2"}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={isSriovActive ? "3 3" : undefined}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* VM Branch Line - going down-right (dashed/dimmed in SR-IOV). */}
|
||||
<path
|
||||
d="M 109 58 L 135 80"
|
||||
fill="none"
|
||||
stroke={isSriovActive ? dimmedColor : vmColor}
|
||||
strokeWidth={isVmActive ? "3.5" : "2"}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={isSriovActive ? "3 3" : undefined}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* SR-IOV in-line connector + badge (only when mode === 'sriov').
|
||||
A horizontal line from the switch node leads to a pill-shaped
|
||||
badge carrying the "SR-IOV ×N" label. Placed on the GPU's
|
||||
baseline to visually read as an in-line extension, not as a
|
||||
third branch. */}
|
||||
{isSriovActive && (
|
||||
<>
|
||||
<line
|
||||
x1="109"
|
||||
y1="50"
|
||||
x2="130"
|
||||
y2="50"
|
||||
stroke={sriovColor}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
<rect
|
||||
x="132"
|
||||
y="40"
|
||||
width="60"
|
||||
height="20"
|
||||
rx="10"
|
||||
fill={`${sriovColor}25`}
|
||||
stroke={sriovColor}
|
||||
strokeWidth="2"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
<text
|
||||
x="162"
|
||||
y="54"
|
||||
textAnchor="middle"
|
||||
fill={sriovColor}
|
||||
className="text-[11px] font-bold transition-all duration-300"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
{sriovBadgeText}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* LXC Container Icon - dimmed/smaller in SR-IOV mode. */}
|
||||
{!isSriovActive && (
|
||||
<g transform="translate(138, 2)">
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="32"
|
||||
height="28"
|
||||
rx="4"
|
||||
fill={isLxcActive ? `${lxcColor}25` : "transparent"}
|
||||
stroke={lxcColor}
|
||||
strokeWidth={isLxcActive ? "2.5" : "1.5"}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
<line x1="0" y1="10" x2="32" y2="10" stroke={lxcColor} strokeWidth={isLxcActive ? "1.5" : "1"} className="transition-all duration-300" />
|
||||
<line x1="0" y1="19" x2="32" y2="19" stroke={lxcColor} strokeWidth={isLxcActive ? "1.5" : "1"} className="transition-all duration-300" />
|
||||
<circle cx="7" cy="5" r="2" fill={lxcColor} className="transition-all duration-300" />
|
||||
<circle cx="7" cy="14.5" r="2" fill={lxcColor} className="transition-all duration-300" />
|
||||
<circle cx="7" cy="23.5" r="2" fill={lxcColor} className="transition-all duration-300" />
|
||||
</g>
|
||||
)}
|
||||
{/* SR-IOV: compact dimmed LXC glyph so the geometry stays recognizable
|
||||
but it's clearly not the active target. */}
|
||||
{isSriovActive && (
|
||||
<g transform="translate(138, 6)" opacity="0.35">
|
||||
<rect x="0" y="0" width="20" height="18" rx="3" fill="transparent" stroke={dimmedColor} strokeWidth="1.5" />
|
||||
<line x1="0" y1="6" x2="20" y2="6" stroke={dimmedColor} strokeWidth="1" />
|
||||
<line x1="0" y1="12" x2="20" y2="12" stroke={dimmedColor} strokeWidth="1" />
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* LXC Label */}
|
||||
{!isSriovActive && (
|
||||
<text
|
||||
x="188"
|
||||
y="22"
|
||||
textAnchor="start"
|
||||
fill={lxcColor}
|
||||
className={cn(
|
||||
"transition-all duration-300",
|
||||
isLxcActive ? "text-[14px] font-bold" : "text-[12px] font-medium"
|
||||
)}
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
LXC
|
||||
</text>
|
||||
)}
|
||||
{isSriovActive && (
|
||||
<text
|
||||
x="162"
|
||||
y="16"
|
||||
fill={dimmedColor}
|
||||
className="text-[9px] font-medium"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
LXC
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* VM Monitor Icon - active view */}
|
||||
{!isSriovActive && (
|
||||
<g transform="translate(138, 65)">
|
||||
<rect
|
||||
x="2"
|
||||
y="0"
|
||||
width="28"
|
||||
height="18"
|
||||
rx="3"
|
||||
fill={isVmActive ? `${vmColor}25` : "transparent"}
|
||||
stroke={vmColor}
|
||||
strokeWidth={isVmActive ? "2.5" : "1.5"}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
<rect
|
||||
x="5"
|
||||
y="3"
|
||||
width="22"
|
||||
height="12"
|
||||
rx="1"
|
||||
fill={isVmActive ? `${vmColor}30` : `${vmColor}10`}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
<line x1="16" y1="18" x2="16" y2="24" stroke={vmColor} strokeWidth={isVmActive ? "2.5" : "1.5"} strokeLinecap="round" className="transition-all duration-300" />
|
||||
<line x1="8" y1="24" x2="24" y2="24" stroke={vmColor} strokeWidth={isVmActive ? "2.5" : "1.5"} strokeLinecap="round" className="transition-all duration-300" />
|
||||
</g>
|
||||
)}
|
||||
{/* SR-IOV: compact dimmed VM monitor glyph, mirror of the LXC glyph. */}
|
||||
{isSriovActive && (
|
||||
<g transform="translate(138, 72)" opacity="0.35">
|
||||
<rect x="0" y="0" width="20" height="13" rx="2" fill="transparent" stroke={dimmedColor} strokeWidth="1.5" />
|
||||
<line x1="10" y1="13" x2="10" y2="17" stroke={dimmedColor} strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="5" y1="17" x2="15" y2="17" stroke={dimmedColor} strokeWidth="1.5" strokeLinecap="round" />
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* VM Label */}
|
||||
{!isSriovActive && (
|
||||
<text
|
||||
x="188"
|
||||
y="84"
|
||||
textAnchor="start"
|
||||
fill={vmColor}
|
||||
className={cn(
|
||||
"transition-all duration-300",
|
||||
isVmActive ? "text-[14px] font-bold" : "text-[12px] font-medium"
|
||||
)}
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
VM
|
||||
</text>
|
||||
)}
|
||||
{isSriovActive && (
|
||||
<text
|
||||
x="162"
|
||||
y="82"
|
||||
fill={dimmedColor}
|
||||
className="text-[9px] font-medium"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
VM
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Status Text - Large like GPU name */}
|
||||
<div className="flex flex-col gap-1 min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"text-base font-semibold transition-all duration-300",
|
||||
isSriovActive
|
||||
? "text-teal-500"
|
||||
: isLxcActive
|
||||
? "text-blue-500"
|
||||
: isVmActive
|
||||
? "text-purple-500"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isSriovActive
|
||||
? "SR-IOV active"
|
||||
: isLxcActive
|
||||
? "Ready for LXC containers"
|
||||
: isVmActive
|
||||
? "Ready for VM passthrough"
|
||||
: "Mode unknown"}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{isSriovActive
|
||||
? "Virtual Functions managed externally"
|
||||
: isLxcActive
|
||||
? "Native driver active"
|
||||
: isVmActive
|
||||
? "VFIO-PCI driver active"
|
||||
: "No driver detected"}
|
||||
</span>
|
||||
{isSriovActive && sriovInfo && (
|
||||
<span className="text-xs font-mono text-teal-600/80 dark:text-teal-400/80">
|
||||
{sriovInfo.role === "vf"
|
||||
? `Virtual Function${sriovInfo.physfn ? ` · parent PF ${sriovInfo.physfn}` : ""}`
|
||||
: sriovInfo.vfCount !== undefined
|
||||
? `1 PF + ${sriovInfo.vfCount} VF${sriovInfo.vfCount === 1 ? "" : "s"}${sriovInfo.totalvfs ? ` / ${sriovInfo.totalvfs} max` : ""}`
|
||||
: null}
|
||||
</span>
|
||||
)}
|
||||
{hasChanged && (
|
||||
<span className="text-sm text-amber-500 font-medium animate-pulse">
|
||||
Change pending...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1124
-204
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
+1046
-800
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"_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-04-19",
|
||||
"_verifier": "Refreshed with tools/ai-models-verifier (private). Re-run before each ProxMenux release to keep the list current. The verifier and ProxMenux share the same reasoning/thinking-model handlers so their verdicts stay aligned with runtime behaviour.",
|
||||
|
||||
"groq": {
|
||||
"models": [
|
||||
"llama-3.3-70b-versatile",
|
||||
"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",
|
||||
"_note": "Not yet re-verified in 2026-04 refresh — kept from previous curation. Run the verifier with a Groq key to prune deprecated entries."
|
||||
},
|
||||
|
||||
"gemini": {
|
||||
"models": [
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-3-flash-preview"
|
||||
],
|
||||
"recommended": "gemini-2.5-flash-lite",
|
||||
"_note": "flash-lite / flash pass the verifier consistently; pro variants reject thinkingBudget=0 and are overkill for notification translation anyway. 'latest' aliases (gemini-flash-latest, gemini-flash-lite-latest) are intentionally omitted because they resolved to different models across runs and produced timeouts in some regions.",
|
||||
"_deprecated": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-flash", "gemini-1.0-pro", "gemini-pro"]
|
||||
},
|
||||
|
||||
"openai": {
|
||||
"models": [
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4.1",
|
||||
"gpt-4o",
|
||||
"gpt-5-chat-latest",
|
||||
"gpt-5.4-nano",
|
||||
"gpt-5.4-mini"
|
||||
],
|
||||
"recommended": "gpt-4.1-nano",
|
||||
"_note": "Reasoning models (o-series, gpt-5/5.1/5.2 non-chat variants) are supported by openai_provider.py via max_completion_tokens + reasoning_effort=minimal, but not listed here by default: their latency is higher than the chat models and they do not improve translation quality for notifications. Add specific reasoning IDs to this list only if a user explicitly wants them."
|
||||
},
|
||||
|
||||
"anthropic": {
|
||||
"models": [
|
||||
"claude-3-5-haiku-latest",
|
||||
"claude-3-5-sonnet-latest",
|
||||
"claude-3-opus-latest"
|
||||
],
|
||||
"recommended": "claude-3-5-haiku-latest",
|
||||
"_note": "Not re-verified in 2026-04 refresh — kept from previous curation. Add claude-4.x / claude-4.5 / claude-4.6 / claude-4.7 variants after running the verifier with an Anthropic key."
|
||||
},
|
||||
|
||||
"openrouter": {
|
||||
"models": [
|
||||
"meta-llama/llama-3.3-70b-instruct",
|
||||
"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-1.5",
|
||||
"openai/gpt-4o-mini",
|
||||
"mistralai/mistral-7b-instruct",
|
||||
"mistralai/mixtral-8x7b-instruct"
|
||||
],
|
||||
"recommended": "meta-llama/llama-3.3-70b-instruct",
|
||||
"_note": "Not re-verified in 2026-04 refresh. google/gemini-flash-2.5-flash-lite was malformed in the previous entry and has been replaced with google/gemini-flash-1.5."
|
||||
},
|
||||
|
||||
"ollama": {
|
||||
"_note": "Ollama models are local, we don't filter them. User manages their own models.",
|
||||
"models": [],
|
||||
"recommended": ""
|
||||
}
|
||||
}
|
||||
+21
-33
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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
|
||||
)
|
||||
@@ -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}")
|
||||
@@ -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,207 @@
|
||||
"""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',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _has_thinking_mode(model: str) -> bool:
|
||||
"""True for Gemini variants that enable "thinking" by default.
|
||||
|
||||
Gemini 2.5+ and 3.x Pro/Flash models spend output tokens on
|
||||
internal reasoning before emitting the final answer. With a small
|
||||
max_tokens budget (≤250) that consumes the whole allowance and
|
||||
leaves an empty reply. For the short translate/explain use case
|
||||
in ProxMenux we want direct output, so we disable thinking for
|
||||
these. Lite variants (flash-lite) do NOT have thinking enabled
|
||||
and are safe to leave alone.
|
||||
"""
|
||||
m = model.lower()
|
||||
if 'lite' in m:
|
||||
return False
|
||||
return m.startswith('gemini-2.5') or m.startswith('gemini-3')
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""List available Gemini models that support generateContent.
|
||||
|
||||
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
|
||||
gen_config = {
|
||||
'maxOutputTokens': max_tokens,
|
||||
'temperature': 0.3,
|
||||
}
|
||||
|
||||
# Disable thinking on 2.5+ / 3.x pro & flash models so the limited
|
||||
# output budget actually produces visible text. thinkingBudget=0
|
||||
# is the official switch for this; lite variants and legacy
|
||||
# models don't need (and ignore) the field.
|
||||
if self._has_thinking_mode(self.model):
|
||||
gen_config['thinkingConfig'] = {'thinkingBudget': 0}
|
||||
|
||||
payload = {
|
||||
'systemInstruction': {
|
||||
'parts': [{'text': system_prompt}]
|
||||
},
|
||||
'contents': [
|
||||
{
|
||||
'role': 'user',
|
||||
'parts': [{'text': user_message}]
|
||||
}
|
||||
],
|
||||
'generationConfig': gen_config,
|
||||
}
|
||||
|
||||
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,206 @@
|
||||
"""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']
|
||||
|
||||
@staticmethod
|
||||
def _is_reasoning_model(model: str) -> bool:
|
||||
"""True for OpenAI reasoning models (o-series + non-chat gpt-5+).
|
||||
|
||||
These use a stricter API contract than chat models:
|
||||
- Must use ``max_completion_tokens`` instead of ``max_tokens``
|
||||
- ``temperature`` is not accepted (only the default is supported)
|
||||
|
||||
Chat-optimized variants (``gpt-5-chat-latest``,
|
||||
``gpt-5.1-chat-latest``, etc.) keep the classic contract and are
|
||||
NOT flagged here.
|
||||
"""
|
||||
m = model.lower()
|
||||
# o1, o3, o4, o5 ... (o<digit>...)
|
||||
if len(m) >= 2 and m[0] == 'o' and m[1].isdigit():
|
||||
return True
|
||||
# gpt-5, gpt-5-mini, gpt-5.1, gpt-5.2-pro ... EXCEPT *-chat-latest
|
||||
if m.startswith('gpt-5') and '-chat' not in m:
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""List available models for chat completions.
|
||||
|
||||
Two modes:
|
||||
- Official OpenAI (no custom base_url): restrict to GPT chat models,
|
||||
excluding embedding/whisper/tts/dall-e/instruct/legacy variants.
|
||||
- OpenAI-compatible endpoint (LiteLLM, MLX, LM Studio, vLLM,
|
||||
LocalAI, Ollama-proxy, etc.): the "gpt" substring check is
|
||||
dropped so user-served models (e.g. ``mlx-community/Llama-3.1-8B``,
|
||||
``Qwen3-32B``, ``mistralai/...``) show up. EXCLUDED_PATTERNS
|
||||
still applies — embeddings/whisper/tts aren't chat-capable on
|
||||
any backend.
|
||||
|
||||
Returns:
|
||||
List of model IDs suitable for chat completions.
|
||||
"""
|
||||
if not self.api_key:
|
||||
return []
|
||||
|
||||
is_custom_endpoint = bool(self.base_url)
|
||||
|
||||
try:
|
||||
# Determine models URL from base_url if set
|
||||
if self.base_url:
|
||||
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()
|
||||
|
||||
# Official OpenAI: restrict to GPT chat models. Custom
|
||||
# endpoints serve arbitrarily named models, so this
|
||||
# substring check would drop every valid result there.
|
||||
if not is_custom_endpoint and 'gpt' not in model_lower:
|
||||
continue
|
||||
|
||||
# Exclude non-chat models on every backend.
|
||||
if any(pattern in model_lower for pattern in self.EXCLUDED_PATTERNS):
|
||||
continue
|
||||
|
||||
models.append(model_id)
|
||||
|
||||
# Sort with recommended models first (only meaningful for OpenAI
|
||||
# official; on custom endpoints the prefixes rarely match, so
|
||||
# entries fall through to alphabetical order, which is fine).
|
||||
def sort_key(m):
|
||||
m_lower = m.lower()
|
||||
for i, prefix in enumerate(self.RECOMMENDED_PREFIXES):
|
||||
if m_lower.startswith(prefix):
|
||||
return (i, m)
|
||||
return (len(self.RECOMMENDED_PREFIXES), m)
|
||||
|
||||
return sorted(models, key=sort_key)
|
||||
except Exception as e:
|
||||
print(f"[OpenAIProvider] Failed to list models: {e}")
|
||||
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},
|
||||
],
|
||||
}
|
||||
|
||||
# Reasoning models (o1/o3/o4/gpt-5*, excluding *-chat-latest) use a
|
||||
# different parameter contract: max_completion_tokens instead of
|
||||
# max_tokens, and no temperature field. Sending the classic chat
|
||||
# parameters to them produces HTTP 400 Bad Request.
|
||||
#
|
||||
# They also spend output budget on internal reasoning by default,
|
||||
# which empties the user-visible reply when max_tokens is small
|
||||
# (like the ~200 we use for notifications). reasoning_effort
|
||||
# 'minimal' keeps that internal reasoning to a minimum so the
|
||||
# entire budget is available for the translation, which is
|
||||
# exactly what this pipeline wants. OpenAI documents 'minimal',
|
||||
# 'low', 'medium', 'high' — 'minimal' is the right setting for a
|
||||
# straightforward translate+explain task.
|
||||
if self._is_reasoning_model(self.model):
|
||||
payload['max_completion_tokens'] = max_tokens
|
||||
payload['reasoning_effort'] = 'minimal'
|
||||
else:
|
||||
payload['max_tokens'] = max_tokens
|
||||
payload['temperature'] = 0.3
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
}
|
||||
|
||||
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}")
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
+5062
-404
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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
|
||||
|
||||
+3927
-590
File diff suppressed because it is too large
Load Diff
+2657
-183
File diff suppressed because it is too large
Load Diff
@@ -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('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>'))
|
||||
|
||||
|
||||
# ─── 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
@@ -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]
|
||||
@@ -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
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -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()
|
||||
@@ -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}"
|
||||
@@ -112,6 +112,50 @@ export interface UPS {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface CoralTPU {
|
||||
type: "pcie" | "usb"
|
||||
name: string
|
||||
vendor: string
|
||||
vendor_id: string
|
||||
device_id: string
|
||||
slot?: string // PCIe only, e.g. "0000:0c:00.0"
|
||||
bus_device?: string // USB only, e.g. "002:007"
|
||||
form_factor?: string // "M.2 / Mini PCIe (x1)" | "USB Accelerator" | ...
|
||||
interface_speed?: string // "PCIe 2.5GT/s x1" | "USB 3.0" | ...
|
||||
kernel_driver?: string | null
|
||||
usb_driver?: string | null
|
||||
kernel_modules?: {
|
||||
gasket: boolean
|
||||
apex: boolean
|
||||
}
|
||||
device_nodes?: string[]
|
||||
edgetpu_runtime?: string
|
||||
programmed?: boolean // USB only: runtime has interacted with the device
|
||||
drivers_ready: boolean
|
||||
// Thermal data — PCIe/M.2 only (apex driver). Always null for USB Coral.
|
||||
temperature?: number | null // °C current die temperature
|
||||
temperature_trips?: number[] | null // trip_point0/1/2_temp, ordered warn→critical
|
||||
thermal_warnings?: Array<{
|
||||
name: string // e.g. "hw_temp_warn1"
|
||||
threshold_c: number | null
|
||||
enabled: boolean
|
||||
}> | null
|
||||
}
|
||||
|
||||
export interface UsbDevice {
|
||||
bus_device: string // "002:007"
|
||||
vendor_id: string // "18d1"
|
||||
product_id: string // "9302"
|
||||
vendor: string
|
||||
name: string
|
||||
class_code: string // "ff"
|
||||
class_label: string // "Vendor Specific", "HID", "Mass Storage", ...
|
||||
speed_mbps: number
|
||||
speed_label: string // "USB 3.0" | "USB 2.0" | ...
|
||||
serial?: string
|
||||
driver?: string
|
||||
}
|
||||
|
||||
export interface GPU {
|
||||
slot: string
|
||||
name: string
|
||||
@@ -146,6 +190,34 @@ export interface GPU {
|
||||
}>
|
||||
has_monitoring_tool?: boolean
|
||||
note?: string
|
||||
// SR-IOV state — populated from sysfs (physfn symlink + sriov_{num,total}vfs).
|
||||
// "vf" — this slot is a Virtual Function; sriov_physfn is its PF.
|
||||
// "pf-active" — this slot is a Physical Function with sriov_vf_count > 0.
|
||||
// "pf-idle" — SR-IOV capable PF but no VFs currently active.
|
||||
// "none" — not involved in SR-IOV.
|
||||
sriov_role?: "vf" | "pf-active" | "pf-idle" | "none"
|
||||
sriov_physfn?: string
|
||||
sriov_vf_count?: number
|
||||
sriov_totalvfs?: number
|
||||
// SR-IOV detail — only populated by the /api/gpu/<slot>/realtime endpoint
|
||||
// when the modal is open (scanning guest configs is too expensive for the
|
||||
// hardware snapshot path).
|
||||
sriov_vfs?: SriovVfDetail[] // filled when role === "pf-active"
|
||||
sriov_consumer?: SriovConsumer | null // filled when role === "vf"
|
||||
}
|
||||
|
||||
export interface SriovVfDetail {
|
||||
bdf: string // e.g. "0000:00:02.1"
|
||||
driver: string // current kernel driver (i915, vfio-pci, ...)
|
||||
render_node: string // "" when the VF does not expose a DRM node
|
||||
consumer: SriovConsumer | null // which guest is using this VF, if any
|
||||
}
|
||||
|
||||
export interface SriovConsumer {
|
||||
type: "vm" | "lxc"
|
||||
id: string // VMID or CTID
|
||||
name: string // VM name / LXC hostname
|
||||
running: boolean
|
||||
}
|
||||
|
||||
export interface DiskHardwareInfo {
|
||||
@@ -208,6 +280,8 @@ export interface HardwareData {
|
||||
fans?: Fan[]
|
||||
power_supplies?: PowerSupply[]
|
||||
ups?: UPS | UPS[]
|
||||
coral_tpus?: CoralTPU[]
|
||||
usb_devices?: UsbDevice[]
|
||||
}
|
||||
|
||||
export const fetcher = async (url: string) => {
|
||||
|
||||
+487
@@ -1,3 +1,490 @@
|
||||
|
||||
## 2026-04-20
|
||||
|
||||
### New version ProxMenux v1.2.1 — *SR-IOV Awareness & GPU Passthrough Hardening*
|
||||
|
||||
Targeted release on top of **v1.2.0** addressing three community-reported areas: complete SR-IOV awareness across the GPU/PCI subsystem, robust handling of GPU + audio companions during passthrough attach and detach (Intel iGPU with chipset audio, discrete cards with HDMI audio, mixed-GPU VMs), and compatibility fixes for AI notification providers (OpenAI-compatible custom endpoints such as LiteLLM/MLX/LM Studio, OpenAI reasoning models, and Gemini 2.5+/3.x thinking models). Also includes quality-of-life improvements in the NVIDIA installer, the disk health monitor, and the LXC lifecycle helpers used by the passthrough wizards.
|
||||
|
||||
---
|
||||
|
||||
## 🎛️ SR-IOV Awareness Across the GPU Subsystem
|
||||
|
||||
Intel `i915-sriov-dkms` and AMD MxGPU split a GPU's Physical Function (PF) into Virtual Functions (VFs) that can be assigned independently to LXCs and VMs. Previously ProxMenux had zero SR-IOV awareness: it treated VFs and PFs identically, which could rewrite `vfio.conf` with the PF's vendor:device ID, collapse the VF tree on the next boot, and leave users unable to start their guests. Every path that could disrupt an active VF tree has been audited and hardened.
|
||||
|
||||
### Detection helpers
|
||||
- New `_pci_is_vf`, `_pci_has_active_vfs`, `_pci_sriov_role`, `_pci_sriov_filter_array` in `scripts/global/pci_passthrough_helpers.sh`
|
||||
- HTTP/JSON equivalents in the Flask GPU route — the Monitor UI reads VF/PF state directly from sysfs (`physfn`, `sriov_totalvfs`, `sriov_numvfs`, `virtfn*`)
|
||||
|
||||
### Pre-start hook (`gpu_hook_guard_helpers.sh`)
|
||||
The VM pre-start guard now recognises Virtual Functions. Both the slot-only syntax branch (which used to iterate every function of the slot and demand `vfio-pci` everywhere) and the full-BDF branch skip VFs, so Proxmox can perform its per-VF vfio-pci rebind as usual. The false "GPU passthrough device is not ready" block on SR-IOV VMs is gone.
|
||||
|
||||
### Mode-switch scripts refuse SR-IOV operations
|
||||
`switch_gpu_mode.sh`, `switch_gpu_mode_direct.sh`, `add_gpu_vm.sh`, `add_gpu_lxc.sh`, `vm_creator.sh`, `synology.sh`, `zimaos.sh` and `add_controller_nvme_vm.sh` all reject VFs and PFs with active VFs before touching host configuration. A clear "SR-IOV Configuration Detected" dialog explains the situation. For wizards invoked mid-flow (VM creators) the message is delivered through `whiptail` so it interrupts cleanly, followed by a per-device `msg_warn` line for the log trail.
|
||||
|
||||
### New "SR-IOV active" state in the Monitor UI
|
||||
The GPU card in the Hardware page gains a third visual state with a dedicated teal colour, an in-line `SR-IOV ×N` pill (or `SR-IOV VF` for a Virtual Function), and dashed/faded LXC and VM branches. The Edit button is hidden because the state is hardware-managed.
|
||||
|
||||

|
||||
|
||||
### Modal dashboard for SR-IOV GPUs
|
||||
Opening the modal for a Physical Function with active VFs now shows:
|
||||
- Aggregate-metrics banner ("Metrics below reflect the Physical Function, aggregate across N VFs")
|
||||
- Normal GPU real-time telemetry for the PF
|
||||
- A **Virtual Functions** table, one row per VF, with the current driver (`i915`, `vfio-pci`, unbound) and the specific VM or LXC that consumes it, including running/stopped state — consumers are discovered by cross-referencing `hostpci` entries and `/dev/dri/renderDN` mount lines against the VF's BDF and DRM render node
|
||||
|
||||
Opening the modal for a Virtual Function shows its parent PF (clickable to navigate back to the PF's modal), current driver, and consumer.
|
||||
|
||||
### VM Conflict Policy popup no longer fires for SR-IOV VFs
|
||||
The regex in `detect_affected_vms_for_selected` matched the slot (`00:02`) against VMs that had a VF (`00:02.1`) assigned, producing a confusing "Keep GPU in VM config" dialog. With the SR-IOV gate upstream, the flow never reaches that code path for SR-IOV slots.
|
||||
|
||||
---
|
||||
|
||||
## 🔊 GPU + Audio Passthrough — Full Lifecycle Hardening
|
||||
|
||||
A round of fixes around how GPU passthrough handles its audio companion device. Previously, only the `.1` sibling of a discrete GPU was picked up automatically; Intel iGPU passthrough to a VM — where the audio lives separately on the chipset at `00:1f.3` and not at `00:02.1` — was silently skipped. On detach, the old `sed` that wiped hostpci lines by slot substring could also remove an unrelated GPU whose BDF happened to contain the search slot as a substring (e.g. slot `00:02` matching inside `0000:02:00.0`). Both paths are now robust.
|
||||
|
||||
### iGPU audio-companion checklist on attach
|
||||
`add_gpu_vm.sh::detect_optional_gpu_audio` keeps the auto-include fast path for the classic `.1` sibling (discrete NVIDIA / AMD with HDMI audio on the card). When no `.1` audio exists, the script now:
|
||||
- Scans sysfs for every PCI audio controller on the host
|
||||
- Skips anything already covered by the GPU's IOMMU group
|
||||
- Asks the user via a `_pmx_checklist` (`dialog` in standalone mode, `whiptail` in wizard mode called from `vm_creator`/`synology`/`zimaos`) which audio controllers to pass through alongside the GPU
|
||||
- Displays each entry with its current host driver (`snd_hda_intel`, `snd_hda_codec_*`, etc.) so the decision is informed
|
||||
- Defaults to **none** — the user actively opts in
|
||||
|
||||
### Orphan audio cascade on detach
|
||||
When the user picks "Remove GPU from VM config" during a mode switch, the scripts now follow up with a targeted cleanup:
|
||||
- `switch_gpu_mode.sh`, `switch_gpu_mode_direct.sh` and `add_gpu_vm.sh::cleanup_vm_config` (source-VM cleanup on the "move GPU" flow) all call the shared helper `_vm_list_orphan_audio_hostpci`
|
||||
- The helper uses a two-pass scan of the VM config: pass 1 records slot bases of display/3D hostpci entries; pass 2 classifies audio entries and **skips any audio whose slot still has a display sibling in the same VM** — protecting the HDMI audio of other dGPUs left in the VM
|
||||
- Previously the bare substring match would have flagged NVIDIA's `02:00.1` as orphan when detaching an Intel iGPU at `00:02.0`
|
||||
- The interactive switch flow confirms removals with a `dialog` checklist (default ON). The web variant auto-removes without prompting — the runner has no good way to render a checklist — and logs every BDF it touched
|
||||
|
||||
### vfio.conf cascade extension
|
||||
For each audio removed by the cascade, the switch-mode scripts now check whether its BDF is still referenced by any other VM via `_pci_bdf_in_any_vm`. If nothing else uses it, the `vendor:device` is appended to `SELECTED_IOMMU_IDS` before the `/etc/modprobe.d/vfio.conf` update runs. That closes the loop for the Intel iGPU case: `8086:51c8` (PCH HD Audio) is now pulled from `vfio.conf` alongside `8086:46a3` (iGPU) when both leave VM mode and no other VM references them. If another VM still uses the audio, the ID is deliberately kept — no breaking side effects on other VMs. `add_gpu_vm.sh` does NOT extend the cleanup in the *move* flow, because the GPU is still in use elsewhere and its IDs must remain.
|
||||
|
||||
### Precise hostpci removal regex
|
||||
Every inline `sed` used to detach a GPU from a VM config previously matched the slot as a free substring:
|
||||
```
|
||||
/^hostpci[0-9]+:.*${slot}/d
|
||||
```
|
||||
For `slot=00:02` that pattern matches the substring inside `0000:02:00.0` (an unrelated NVIDIA dGPU at slot `02:00`) and would wipe both cards. The fix anchors the match to the real BDF shape:
|
||||
```
|
||||
/^hostpci[0-9]+:[[:space:]]*(0000:)?${slot}\.[0-7]([,[:space:]]|$)/d
|
||||
```
|
||||
Applied in `switch_gpu_mode.sh`, `switch_gpu_mode_direct.sh` and `add_gpu_vm.sh::cleanup_vm_config`. The awk-based helper in `vm_storage_helpers.sh::_remove_pci_slot_from_vm_config` (used by the NVMe wizards) already used the correct pattern and did not need changes.
|
||||
|
||||
---
|
||||
|
||||
## 🤖 AI Provider Compatibility — OpenAI-Compatible, Reasoning & Thinking Models
|
||||
|
||||
Three coordinated fixes that unblock model categories previously rejected by the notification enhancement pipeline.
|
||||
|
||||
### OpenAI-compatible endpoints
|
||||
LiteLLM, MLX, LM Studio, vLLM, LocalAI, Ollama-proxy — the provider's `list_models()` used to require `"gpt"` in every model name, so local setups serving `mlx-community/...`, `Qwen3-...`, `mistralai/...` saw an empty model list. When a Custom Base URL is set, the `"gpt"` substring check is now skipped and `EXCLUDED_PATTERNS` (embeddings, whisper, tts, dall-e) is the only filter. The Flask route layer also stops intersecting the result against `verified_ai_models.json` for custom endpoints — the verified list only describes OpenAI's official model IDs and was erasing every local model the user actually served.
|
||||
|
||||
### OpenAI reasoning models
|
||||
`o1`, `o3`, `o3-mini`, `o4-mini`, `gpt-5`, `gpt-5-mini`, `gpt-5.1`, `gpt-5.2-pro`, `gpt-5.4-nano`, etc. (excluding the `*-chat-latest` variants) use a stricter API contract: `max_completion_tokens` instead of `max_tokens`, no `temperature`. Sending the classic chat parameters produced HTTP 400 Bad Request for every one of them. A detector in `openai_provider.py` now branches the payload accordingly and sets `reasoning_effort: "minimal"` — by default these models spend their output budget on internal reasoning and return an empty reply for the short notification-translation request.
|
||||
|
||||
### Gemini 2.5+ / 3.x thinking models
|
||||
`gemini-2.5-flash`, `2.5-pro`, `gemini-3-pro-preview`, `gemini-3.1-pro-preview`, etc. have internal "thinking" enabled by default. With the small token budget used for notification enrichment (≤250 tokens), the thinking budget consumed the entire allowance and the model returned empty output with `finishReason: MAX_TOKENS`. `gemini_provider.py` now sets `thinkingConfig.thinkingBudget: 0` for non-`lite` variants of 2.5+ and 3.x, so the available tokens go to the user-visible response. Lite variants (no thinking enabled) are untouched.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Verified AI Models Refresh
|
||||
|
||||
`AppImage/config/verified_ai_models.json` refreshed for the providers re-tested against live APIs. The new private maintenance tool (kept out of the AppImage) re-runs a standardised translate+explain test against every model each provider advertises, classifies pass / warn / fail, and prints a ready-to-paste JSON snippet. Re-run before each ProxMenux release to keep the list current.
|
||||
|
||||
| Provider | New recommended | Notes |
|
||||
|----------|-----------------|-------|
|
||||
| **OpenAI** | `gpt-4.1-nano` | `gpt-4.1-nano`, `gpt-4.1-mini`, `gpt-4o-mini`, `gpt-4.1`, `gpt-4o`, `gpt-5-chat-latest`, plus `gpt-5.4-nano` / `gpt-5.4-mini` from 2026-03. Dated snapshots and legacy models excluded. Reasoning models supported by code but not listed by default — slower / costlier without improving notification quality |
|
||||
| **Gemini** | `gemini-2.5-flash-lite` | `gemini-2.5-flash-lite`, `gemini-2.5-flash` (works now), `gemini-3-flash-preview`. `latest` aliases intentionally omitted — resolved to different models across runs and produced timeouts in some regions. Pro variants reject `thinkingBudget=0` and are overkill for notification translation |
|
||||
| Groq / Anthropic / OpenRouter | *unchanged* | Marked with a `_note` — will be re-verified as soon as keys are available |
|
||||
|
||||
---
|
||||
|
||||
## 🩺 Disk Health Monitor — Observation Persistence in the Journal Watcher
|
||||
|
||||
A latent bug in `notification_events.py::_check_disk_io` meant real-time kernel I/O errors caught by the journal watcher were surfaced as notifications but never written to the permanent per-disk observations table. In practice the parallel periodic dmesg scan usually recorded the observation shortly after, but under timing edge cases (stale dmesg window, service restart right after the error, buffer rotation) the observation could go missing.
|
||||
|
||||
The journal watcher now records the observation before the 24h notification cooldown gate, using the same family-based signature classification (`io_<disk>_ata_connection_error`, `io_<disk>_block_io_error`, `io_<disk>_ata_failed_command`) as the periodic scan. Both paths now deduplicate into the same row via the UPSERT in `record_disk_observation`, so occurrence counts are accurate regardless of which detector fired first.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 NVIDIA Installer Polish
|
||||
|
||||
### `lsmod` race condition silenced
|
||||
During reinstall, the module-unload verification in `unload_nvidia_modules` produced spurious `lsmod: ERROR: could not open '/sys/module/nvidia_uvm/holders'` errors because `lsmod` reads `/proc/modules` and then opens each module's `holders/` directory, which disappears transiently while the module is being removed. The check now reads `/proc/modules` directly and inserts short sleeps to let the kernel finalise the unload before re-verifying. Applied in the same spirit to the four other `lsmod` call sites in the script.
|
||||
|
||||
### Dialog → whiptail in the LXC update flow
|
||||
The "Insufficient Disk Space" message in `update_lxc_nvidia` and the "Update NVIDIA in LXC Containers" confirmation now use `whiptail`-style dialogs consistent with the rest of the in-flow messaging, avoiding the visual break that `dialog --msgbox` caused when rendered mid-sequence in the container-update phase.
|
||||
|
||||
---
|
||||
|
||||
## 🧵 LXC Lifecycle Helper — Timeout-Safe Stop
|
||||
|
||||
A plain `pct stop` can hang indefinitely when the container has a stale lock from a previous aborted operation, when processes inside (Plex, Jellyfin, databases) ignore TERM and fall into uninterruptible-sleep while the GPU they were using is yanked out, or when `pct shutdown --timeout` is not enforced by pct itself. Field reports of 5+ min waits during GPU mode switches made this a real UX hazard.
|
||||
|
||||
New shared helper `_pmx_stop_lxc <ctid> [log_file]` in `pci_passthrough_helpers.sh`:
|
||||
1. Returns 0 immediately if the container is not running
|
||||
2. Best-effort `pct unlock` (silent on failure) — most containers aren't actually locked; we only care about the cases where they are
|
||||
3. `pct shutdown --forceStop 1 --timeout 30` wrapped in an external `timeout 45` so we never wait longer than that for the graceful phase, even if pct stalls on backend I/O
|
||||
4. Verifies actual status via `pct status` — pct can return non-zero while the container is in fact stopped
|
||||
5. If still running, `pct stop` wrapped in `timeout 60`. Verify again
|
||||
6. Returns 1 only if the container is truly stuck after ~107 s total — the wizard moves on instead of hanging
|
||||
|
||||
Wired into the three GPU-mode paths that stop LXCs during a switch: `switch_gpu_mode.sh`, `switch_gpu_mode_direct.sh`, and `add_gpu_vm.sh::cleanup_lxc_configs`.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ `add_gpu_vm.sh` Reboot Prompt Stability
|
||||
|
||||
The final "Reboot Required" prompt of the GPU-to-VM assignment wizard was triggering spurious reboots in certain menu-chain invocations (`menu` → `main_menu` → `hw_grafics_menu` → `add_gpu_vm`). With the `_pmx_yesno` helper it sometimes returned exit 0 without the user having actually confirmed, calling `reboot` immediately. With a bare `read` in its place the process would get SIGTTIN-suspended when the menu chain detached the script from the terminal's foreground process group, leaving `[N]+ Stopped menu` on the parent shell with no chance to answer.
|
||||
|
||||
The prompt now uses `whiptail --yesno` invoked directly (the pattern verified to work reliably in that menu chain) and inserts a `Press Enter to continue ... read -r` pause between the "Yes" answer and the actual `reboot` call — so an accidental Enter on the confirm button cannot trigger an immediate reboot without a visible confirmation step first.
|
||||
|
||||
---
|
||||
|
||||
### 🙏 Thanks
|
||||
|
||||
Thank you to the users who reported the SR-IOV, LiteLLM/MLX and GPU + audio cases — these improvements exist because of detailed, reproducible reports. Feel free to keep reporting issues or suggesting improvements 🙌.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 2026-04-17
|
||||
|
||||
### New version ProxMenux v1.2.0 — *AI-Enhanced Monitoring*
|
||||
|
||||
|
||||

|
||||
|
||||
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*
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
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)
|
||||
and to all contributors involved in the development of the project.
|
||||
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.
|
||||
|
||||
@@ -57,13 +57,97 @@ 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)* |
|
||||
|
||||
<br>
|
||||
|
||||
> **🛡️ Security Note / VirusTotal False Positive**
|
||||
> If you scan the raw installation URL on VirusTotal, you might see a 1/95 detection by heuristic engines like *Chong Lua Dao*. This is a **known false positive**. Because this script uses the standard `curl | bash` installation pattern and downloads legitimate binaries (like `jq` from its official GitHub release), overly aggressive scanners flag the *behavior*. The script is 100% open source and safe to review. You can read more about this in [Issue #162](https://github.com/MacRimi/ProxMenux/issues/162).
|
||||
|
||||
---
|
||||
|
||||
@@ -71,11 +155,25 @@ Then, follow the on-screen options to manage your Proxmox server efficiently.
|
||||
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
|
||||
|
||||
[](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;"/>
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
1.1.9.5
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 334 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
+60
-7
@@ -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"
|
||||
|
||||
@@ -912,13 +963,13 @@ install_translation_version() {
|
||||
cp "./menu" "$INSTALL_DIR/$MENU_SCRIPT"
|
||||
cp "./version.txt" "$LOCAL_VERSION_FILE"
|
||||
cp "./install_proxmenux.sh" "$BASE_DIR/install_proxmenux.sh"
|
||||
|
||||
|
||||
mkdir -p "$BASE_DIR/scripts"
|
||||
cp -r "./scripts/"* "$BASE_DIR/scripts/"
|
||||
chmod -R +x "$BASE_DIR/scripts/"
|
||||
chmod +x "$BASE_DIR/install_proxmenux.sh"
|
||||
msg_ok "Necessary files created."
|
||||
|
||||
|
||||
chmod +x "$INSTALL_DIR/$MENU_SCRIPT"
|
||||
|
||||
((current_step++))
|
||||
@@ -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
|
||||
|
||||
+15979
-2006
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,92 +4,128 @@
|
||||
# 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
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
msg_warn "$(translate "Update postponed. You can update later from the menu.")"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Beta-mode update check (main + develop) ───────────────
|
||||
# When the beta program is active, check BOTH channels. The stable check
|
||||
# is delegated to check_updates_stable (same prompt, same installer). After
|
||||
# that we only need the beta-specific part: develop vs beta_version.txt.
|
||||
check_updates_beta() {
|
||||
# 1. Stable release on main — reuse the non-beta path.
|
||||
check_updates_stable
|
||||
|
||||
main_menu() {
|
||||
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
|
||||
# 2. Beta build on develop.
|
||||
[[ ! -f "$BETA_VERSION_FILE" ]] && return 0
|
||||
|
||||
local REMOTE_BETA LOCAL_BETA
|
||||
REMOTE_BETA="$(curl -fsSL "$REPO_DEVELOP/beta_version.txt" 2>/dev/null | head -n 1)"
|
||||
LOCAL_BETA="$(head -n 1 "$BETA_VERSION_FILE" 2>/dev/null)"
|
||||
[[ -z "$REMOTE_BETA" || -z "$LOCAL_BETA" || "$LOCAL_BETA" = "$REMOTE_BETA" ]] && return 0
|
||||
[[ "$(printf '%s\n%s\n' "$LOCAL_BETA" "$REMOTE_BETA" | sort -V | tail -1)" = "$REMOTE_BETA" ]] || return 0
|
||||
|
||||
if whiptail --title "Beta Update Available" \
|
||||
--yesno "A new beta build is available!\n\nInstalled beta : $LOCAL_BETA\nNew beta build : $REMOTE_BETA\n\nDo you want to update now?" \
|
||||
12 64 --defaultno; then
|
||||
|
||||
msg_warn "Updating to beta build $REMOTE_BETA ..."
|
||||
|
||||
local INSTALL_BETA_SCRIPT="$BASE_DIR/install_proxmenux_beta.sh"
|
||||
if curl -fsSL "$REPO_DEVELOP/install_proxmenux_beta.sh" -o "$INSTALL_BETA_SCRIPT"; then
|
||||
chmod +x "$INSTALL_BETA_SCRIPT"
|
||||
bash "$INSTALL_BETA_SCRIPT" --update
|
||||
return 0
|
||||
else
|
||||
msg_error "Could not download the beta installer from the develop branch."
|
||||
fi
|
||||
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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user