Compare commits
1806 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| be03035574 | |||
| 619f3ca700 | |||
| 8553e63338 | |||
| be4d9fe24b | |||
| 497f727b08 | |||
| 74a7569f4c | |||
| 66185e3b91 | |||
| 1b2beda695 | |||
| feb3b5ef5f | |||
| b2439331b3 | |||
| f1000afc27 | |||
| 5fc2a82423 | |||
| a27f884418 | |||
| cae4b73226 | |||
| 50ed293de2 | |||
| 616b772a45 | |||
| c1d00e21db | |||
| 2e8e2b61d3 | |||
| ba595c9719 | |||
| e392f6a2b7 | |||
| 7457e71776 | |||
| 982d0dd72e | |||
| d345f96518 | |||
| 469874e975 | |||
| 6ba817cd43 | |||
| 42f2e69e3a | |||
| 12442b4bd3 | |||
| 305d37a13b | |||
| 4baf60174f | |||
| 8cd1ac6a4b | |||
| c65fef638e | |||
| a030cd7e28 | |||
| 59a3b7eac5 | |||
| 2faac48adf | |||
| 3883039764 | |||
| 307ed0c637 | |||
| 96ffdb65d0 | |||
| 5ca55798b2 | |||
| cd32e11c6d | |||
| 774cbe4c9d | |||
| 1d0bb20506 | |||
| 8064e107f4 | |||
| c1d1121ed1 | |||
| 07603f11db | |||
| ec22c857d5 | |||
| 364e808261 | |||
| 1d47ad0c4b | |||
| c9d0eac6cc | |||
| 97fc72b78a | |||
| 7b1111430b | |||
| 4f3306cd0f | |||
| d3f7056ece | |||
| 9f3286c570 | |||
| 16fc737b2d | |||
| 3e0ae709d9 | |||
| 39ddb7c8f9 | |||
| 3c509ce0e4 | |||
| 048cf2fb8f | |||
| 0a20821c41 | |||
| e0eaf6267f | |||
| 3ddf98277f | |||
| 85294bcd33 | |||
| acff4523f3 | |||
| bf71e1f9b8 | |||
| f0bcdc1c25 | |||
| 43526c58bd | |||
| ce3c7a545e | |||
| 9498e4e7eb | |||
| 4ec7c207f4 | |||
| 000479463f | |||
| 6b2065e43c | |||
| e97e1363ae | |||
| 697a1f8e31 | |||
| 035f43311a | |||
| c597f1252e | |||
| cc1e7a715c | |||
| 80057e3014 | |||
| 79ffba873f | |||
| 673e1cf212 | |||
| 7e878ecff2 | |||
| 88cf51a602 | |||
| 1860fffe07 | |||
| fa925543db | |||
| 825e99c59b | |||
| 955bed80fb | |||
| 03b9ac3ec4 | |||
| c255d9a5d8 | |||
| 401d973a51 | |||
| a507d559e1 | |||
| 9225982ca5 | |||
| 6f831530cc | |||
| e6b4443074 | |||
| 1c800cbd8f | |||
| a65924799e | |||
| adbfa1e73e | |||
| 44a4226ad2 | |||
| 07ca3f13a0 | |||
| 87a052b89c | |||
| 2216543ac3 | |||
| 4254d57d12 | |||
| 30d93898d8 | |||
| 4c7ed2c2c5 | |||
| 4fb327cef8 | |||
| 588af3613b | |||
| 5b5f325a4e | |||
| ae62196dff | |||
| 27e66ee770 | |||
| 8fb8134898 | |||
| a59489f804 | |||
| cbf3938784 | |||
| c45ebfe598 | |||
| a75aad1fdc | |||
| a0635a1026 | |||
| 27353e160f | |||
| b9619efbbf | |||
| 1712d32ef7 | |||
| 014deb2118 | |||
| 6077cf81f2 | |||
| 0422c38096 | |||
| 8c902ae04d | |||
| 0a0b916067 | |||
| 6822635a0b | |||
| f9b15fd110 | |||
| 131a458e69 | |||
| 7260807d78 | |||
| df83d8a3e5 | |||
| 0f45424458 | |||
| 60f92d019b | |||
| 2189487982 | |||
| 3a44997795 | |||
| 1f04134aac | |||
| ce44538240 | |||
| 5fd53883be | |||
| f064cc89ba | |||
| 5dd8b3ee36 | |||
| f2f9c37ee2 | |||
| 6836777629 | |||
| beefdd280f | |||
| 4b9ad0da7a | |||
| f9fdd1686c | |||
| 25fc3d931e | |||
| fc7d0f2cd5 | |||
| 60c91d9fe4 | |||
| cc2d6849a8 | |||
| 4a5379ea42 | |||
| ba84c644df | |||
| 37217b4219 | |||
| 41dab03a5f | |||
| 6e48bf2a71 | |||
| 1c1c6f513c | |||
| 49c54f5593 | |||
| d083e49d0b | |||
| 8dc2b833f4 | |||
| 7d5726be50 | |||
| 246c1674d1 | |||
| 06b81f2b64 | |||
| ee57797890 | |||
| a94000e114 | |||
| e6655b35f3 | |||
| 696ffde184 | |||
| 9e74e99923 | |||
| bc5c6dadfb | |||
| 0d173a0bfe | |||
| cd78920edd | |||
| 9a7ec62cf9 | |||
| 2b4580cfe8 | |||
| b790c06294 | |||
| 61d87b46d9 | |||
| 143cb4cbab | |||
| 22709dac36 | |||
| d97be93449 | |||
| 5864de7dea | |||
| 4ea5890e92 | |||
| 876d51b009 | |||
| 5b0d55c1a2 | |||
| 3ddb1421c3 | |||
| 58f9a7bc02 | |||
| e8e4b728ce | |||
| 0a4868192d | |||
| 9d81ffffe8 | |||
| e6fe4a09e5 | |||
| 77c5ad7b09 | |||
| b850e9615a | |||
| c2ea307821 | |||
| fb588c0d60 | |||
| fecbdf6190 | |||
| bbbbf6892f | |||
| e1a11053a6 | |||
| f0a62191ea | |||
| a8311923fb | |||
| cd1d88760d | |||
| 004949d3a0 | |||
| f6d26042da | |||
| 270a73a470 | |||
| 018e80e59d | |||
| cb5cb1e594 | |||
| 6c5eb156a1 | |||
| 8abef33840 | |||
| 1d6b8951e8 | |||
| 711d57d91f | |||
| 65fd847251 | |||
| 73a170a5f1 | |||
| 9a32d1c0f7 | |||
| 59918032c6 | |||
| 55394cbf09 | |||
| 83dcc0c4f2 | |||
| b4b93f0572 | |||
| ab0e59215c | |||
| 5669ce207c | |||
| 37f6cd96a4 | |||
| c0ec74fb12 | |||
| 226dc45190 | |||
| 11e3f53a2f | |||
| 31d7f7e3e9 | |||
| 128edc08e2 | |||
| 5158c5f359 | |||
| a70b33ce13 | |||
| d787c3caa0 | |||
| a554af939e | |||
| 06604ff0d1 | |||
| 9490f79c6d | |||
| 311a624698 | |||
| 3e2e77f9fb | |||
| b2e02cd0e7 | |||
| 87ead71766 | |||
| b8517a5b3e | |||
| c29cdf44fb | |||
| 4b2ab2894a | |||
| c9a01ab5ad | |||
| 90d1046312 | |||
| 14e749a18d | |||
| a4be1af0ef | |||
| f4185d0a2a | |||
| ffb8324b5a | |||
| 6df44f1632 | |||
| 9570819f59 | |||
| f2afc94ed2 | |||
| 050b95946c | |||
| e33ef92334 | |||
| 0d7ff46aec | |||
| 042913e080 | |||
| 98bc8be642 | |||
| 2c6d2f4255 | |||
| 6293556837 | |||
| 641721d199 | |||
| 036a2b9014 | |||
| 9ad092d340 | |||
| d24884f651 | |||
| fa1c498716 | |||
| 25635239d4 | |||
| c816688de3 | |||
| 43d79bd1e9 | |||
| ba88c7b0f6 | |||
| 4359d92ffe | |||
| 16c7513e82 | |||
| 4572478ad8 | |||
| 02b5cd61bd | |||
| bff07311b2 | |||
| 44cc89b9d5 | |||
| fa1e6c6c64 | |||
| 4ebbdb284b | |||
| 51302a7c5a | |||
| ba984592ed | |||
| 60a97e5815 | |||
| 3275a1ecb4 | |||
| af72c7a2d3 | |||
| c07ada1fc4 | |||
| c19c8f9c5d | |||
| 43fe7ae7db | |||
| 22916868df | |||
| 7d00ff8869 | |||
| 4ea2088485 | |||
| e421b40093 | |||
| a9dd7562ac | |||
| 8f62ed67d3 | |||
| cfd89a14f7 | |||
| 55011842f5 | |||
| 3079a3f51c | |||
| c4ec390ca0 | |||
| f99b7f3589 | |||
| 887b170c0e | |||
| c696cfd8d8 | |||
| 25966973a2 | |||
| d0a57d4b7c | |||
| 9341b49fd1 | |||
| bbc3c922a6 | |||
| 17b8d63e6c | |||
| c751a8168a | |||
| f2509dbe5d | |||
| 6d44c22982 | |||
| 4bed489610 | |||
| 8edf488636 | |||
| 8fe7d249f8 | |||
| 6ed14e1d3c | |||
| a5459acdaf | |||
| 61cd198d35 | |||
| 49ea2b304d | |||
| 27231d1764 | |||
| 8744620220 | |||
| 4590be6d42 | |||
| fa93b43c32 | |||
| 3c47f84a24 | |||
| 8a371c26de | |||
| 088a594468 | |||
| c551913551 | |||
| 05e81053e0 | |||
| 981c0ab980 | |||
| 1f083b335f | |||
| 10603900df | |||
| c22e36d219 | |||
| 26fc2ae9db | |||
| 2a0b298ae5 | |||
| 96f0a9bc5d | |||
| 5054e78864 | |||
| f1fa6b03d5 | |||
| 8f15bf9668 | |||
| 4b8e7b19a3 | |||
| 67bba1dd09 | |||
| b826dec79d |
@@ -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
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a problem in the project
|
||||
title: "[BUG] Describe the issue"
|
||||
labels: bug
|
||||
assignees: 'MacRimi'
|
||||
---
|
||||
|
||||
## Description
|
||||
Describe the bug clearly and concisely.
|
||||
|
||||
## Steps to Reproduce
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
## Expected Behavior
|
||||
What should happen?
|
||||
|
||||
## Screenshots (Required)
|
||||
Add images to help illustrate the issue.
|
||||
|
||||
## Environment
|
||||
- Operating system:
|
||||
- Software version:
|
||||
- Other relevant details:
|
||||
|
||||
## Additional Information
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Soporte General
|
||||
url: https://github.com/MacRimi/ProxMenux/discussions
|
||||
about: If your request is neither a bug nor a feature, please use Discussions.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or improvement
|
||||
title: "[FEATURE] Describe your proposal"
|
||||
labels: enhancement
|
||||
assignees: 'MacRimi'
|
||||
---
|
||||
|
||||
## Description
|
||||
Explain the feature you are proposing.
|
||||
|
||||
## Motivation
|
||||
Why is this improvement important? What problem does it solve?
|
||||
|
||||
## Alternatives Considered
|
||||
Are there other solutions you have thought about?
|
||||
|
||||
## Additional Information
|
||||
Add any extra details that help understand your proposal.
|
||||
@@ -1,76 +1,238 @@
|
||||
import requests, json
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# GitHub API URL to fetch all .json files describing scripts
|
||||
API_URL = "https://api.github.com/repos/community-scripts/ProxmoxVE/contents/frontend/public/json"
|
||||
import requests
|
||||
|
||||
# Base path to build the full URL for the installable scripts
|
||||
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"
|
||||
|
||||
# Output file where the consolidated helper scripts cache will be stored
|
||||
OUTPUT_FILE = Path("json/helpers_cache.json")
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
OUTPUT_FILE = REPO_ROOT / "json" / "helpers_cache.json"
|
||||
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
res = requests.get(API_URL)
|
||||
data = res.json()
|
||||
cache = []
|
||||
TYPE_TO_PATH_PREFIX = {
|
||||
"lxc": "ct",
|
||||
"vm": "vm",
|
||||
"addon": "tools/addon",
|
||||
"pve": "tools/pve",
|
||||
}
|
||||
|
||||
# Loop over each file in the JSON directory
|
||||
for item in data:
|
||||
url = item.get("download_url")
|
||||
if not url or not url.endswith(".json"):
|
||||
continue
|
||||
|
||||
def to_mirror_url(raw_url: str) -> str:
|
||||
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 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, dict):
|
||||
raise RuntimeError(f"Unexpected response from {url}: expected object")
|
||||
return data
|
||||
|
||||
|
||||
def fetch_all_records(url: str, *, expand: str | None = None, per_page: int = 500) -> list[dict[str, Any]]:
|
||||
page = 1
|
||||
items: list[dict[str, Any]] = []
|
||||
|
||||
while True:
|
||||
params: dict[str, Any] = {"page": page, "perPage": per_page}
|
||||
if expand:
|
||||
params["expand"] = expand
|
||||
|
||||
data = fetch_json(url, params=params)
|
||||
page_items = data.get("items", [])
|
||||
if not isinstance(page_items, list):
|
||||
raise RuntimeError(f"Unexpected items list from {url}")
|
||||
|
||||
items.extend(page_items)
|
||||
|
||||
total_pages = data.get("totalPages", page)
|
||||
if not isinstance(total_pages, int) or page >= total_pages:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def normalize_os_variants(install_methods_json: list[dict[str, Any]]) -> list[str]:
|
||||
os_values: list[str] = []
|
||||
for item in install_methods_json:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
resources = item.get("resources", {})
|
||||
if not isinstance(resources, dict):
|
||||
continue
|
||||
os_name = resources.get("os")
|
||||
if isinstance(os_name, str) and os_name.strip():
|
||||
normalized = os_name.strip().lower()
|
||||
if normalized not in os_values:
|
||||
os_values.append(normalized)
|
||||
return os_values
|
||||
|
||||
|
||||
def build_script_path(type_name: str, slug: str) -> str:
|
||||
type_name = (type_name or "").strip().lower()
|
||||
slug = (slug or "").strip()
|
||||
|
||||
if type_name == "turnkey":
|
||||
return "turnkey/turnkey.sh"
|
||||
|
||||
prefix = TYPE_TO_PATH_PREFIX.get(type_name)
|
||||
if not prefix or not slug:
|
||||
return ""
|
||||
|
||||
return f"{prefix}/{slug}.sh"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
raw = requests.get(url).json()
|
||||
scripts = fetch_all_records(SCRIPT_COLLECTION_URL, expand="type,categories")
|
||||
categories = fetch_all_records(CATEGORY_COLLECTION_URL)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Unable to fetch PocketBase data: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
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
|
||||
|
||||
cache: list[dict[str, Any]] = []
|
||||
|
||||
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
|
||||
except:
|
||||
continue
|
||||
|
||||
# Extract fields required to identify a valid helper script
|
||||
name = raw.get("name", "")
|
||||
slug = raw.get("slug")
|
||||
type_ = raw.get("type", "")
|
||||
script = raw.get("install_methods", [{}])[0].get("script", "")
|
||||
if not slug or not script:
|
||||
continue # Skip if it's not a valid script
|
||||
slug = raw.get("slug")
|
||||
name = raw.get("name", "")
|
||||
desc = raw.get("description", "")
|
||||
|
||||
desc = raw.get("description", "")
|
||||
categories = raw.get("categories", [])
|
||||
notes = [note.get("text", "") for note in raw.get("notes", []) if isinstance(note, dict)]
|
||||
full_script_url = f"{SCRIPT_BASE}/{script}"
|
||||
if not isinstance(slug, str) or not slug.strip():
|
||||
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 ""
|
||||
|
||||
credentials = raw.get("default_credentials", {})
|
||||
cred_username = credentials.get("username")
|
||||
cred_password = credentials.get("password")
|
||||
|
||||
add_credentials = (
|
||||
(cred_username is not None and str(cred_username).strip() != "") or
|
||||
(cred_password is not None and str(cred_password).strip() != "")
|
||||
)
|
||||
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
|
||||
|
||||
entry = {
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"desc": desc,
|
||||
"script": script,
|
||||
"script_url": full_script_url,
|
||||
"categories": categories,
|
||||
"notes": notes,
|
||||
"type": type_
|
||||
}
|
||||
if add_credentials:
|
||||
entry["default_credentials"] = {
|
||||
"username": cred_username,
|
||||
"password": cred_password
|
||||
full_script_url = f"{SCRIPT_BASE}/{script_path}"
|
||||
script_url_mirror = to_mirror_url(full_script_url)
|
||||
|
||||
install_methods_json = raw.get("install_methods_json", [])
|
||||
if not isinstance(install_methods_json, list):
|
||||
install_methods_json = []
|
||||
|
||||
notes_json = raw.get("notes_json", [])
|
||||
if not isinstance(notes_json, list):
|
||||
notes_json = []
|
||||
|
||||
notes = [
|
||||
note.get("text", "")
|
||||
for note in notes_json
|
||||
if isinstance(note, dict) and isinstance(note.get("text"), str) and note.get("text", "").strip()
|
||||
]
|
||||
|
||||
category_ids = raw.get("categories", [])
|
||||
if not isinstance(category_ids, list):
|
||||
category_ids = []
|
||||
|
||||
expanded_categories = expand.get("categories", []) if isinstance(expand.get("categories"), list) else []
|
||||
category_names: list[str] = []
|
||||
for cat in expanded_categories:
|
||||
if isinstance(cat, dict):
|
||||
cat_name = cat.get("name")
|
||||
if isinstance(cat_name, str) and cat_name.strip():
|
||||
category_names.append(cat_name.strip())
|
||||
|
||||
if not category_names:
|
||||
for cat_id in category_ids:
|
||||
cat = category_map.get(cat_id, {})
|
||||
cat_name = cat.get("name")
|
||||
if isinstance(cat_name, str) and cat_name.strip():
|
||||
category_names.append(cat_name.strip())
|
||||
|
||||
# Shared fields across all install method entries
|
||||
default_user = raw.get("default_user")
|
||||
default_passwd = raw.get("default_passwd")
|
||||
default_credentials: dict[str, str] | None = None
|
||||
if (isinstance(default_user, str) and default_user.strip()) or (isinstance(default_passwd, str) and default_passwd.strip()):
|
||||
default_credentials = {
|
||||
"username": default_user if isinstance(default_user, str) else "",
|
||||
"password": default_passwd if isinstance(default_passwd, str) else "",
|
||||
}
|
||||
|
||||
base_entry: dict[str, Any] = {
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"desc": desc,
|
||||
"script": script_path,
|
||||
"script_url": full_script_url,
|
||||
"script_url_mirror": script_url_mirror,
|
||||
"type": type_name,
|
||||
"type_id": raw.get("type", ""),
|
||||
"categories": category_ids,
|
||||
"category_names": category_names,
|
||||
"notes": notes,
|
||||
"port": raw.get("port", 0),
|
||||
"website": raw.get("website", ""),
|
||||
"documentation": raw.get("documentation", ""),
|
||||
"logo": raw.get("logo", ""),
|
||||
"updateable": bool(raw.get("updateable", False)),
|
||||
"privileged": bool(raw.get("privileged", False)),
|
||||
"has_arm": bool(raw.get("has_arm", False)),
|
||||
"is_dev": bool(raw.get("is_dev", False)),
|
||||
"execute_in": raw.get("execute_in", []),
|
||||
"config_path": raw.get("config_path", ""),
|
||||
}
|
||||
if default_credentials:
|
||||
base_entry["default_credentials"] = default_credentials
|
||||
|
||||
cache.append(entry)
|
||||
# Emit one entry per install method so the menu shell can offer an
|
||||
# explicit OS choice. When there is only one method (or none), a
|
||||
# single entry is emitted with os="" (script decides at runtime).
|
||||
os_variants = normalize_os_variants(install_methods_json)
|
||||
|
||||
if len(os_variants) > 1:
|
||||
for os_name in os_variants:
|
||||
entry = {**base_entry, "os": os_name}
|
||||
cache.append(entry)
|
||||
print(f"[{len(cache):03d}] {slug:<24} → {script_path:<28} type={type_name:<7} os={os_name}")
|
||||
else:
|
||||
os_name = os_variants[0] if os_variants else ""
|
||||
entry = {**base_entry, "os": os_name}
|
||||
cache.append(entry)
|
||||
print(f"[{len(cache):03d}] {slug:<24} → {script_path:<28} type={type_name:<7} os={os_name or 'n/a'}")
|
||||
|
||||
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" Guardados: {len(cache)}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# Write the JSON cache to disk
|
||||
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(cache, f, indent=2)
|
||||
|
||||
print(f"✅ helpers_cache.json created at {OUTPUT_FILE} with {len(cache)} valid scripts.")
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import requests, json
|
||||
from pathlib import Path
|
||||
|
||||
# GitHub API URL to fetch all .json files describing scripts
|
||||
API_URL = "https://api.github.com/repos/community-scripts/ProxmoxVE/contents/frontend/public/json"
|
||||
|
||||
# Base path to build the full URL for the installable scripts
|
||||
SCRIPT_BASE = "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main"
|
||||
|
||||
# Output file where the consolidated helper scripts cache will be stored
|
||||
OUTPUT_FILE = Path("json/helpers_cache.json")
|
||||
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
res = requests.get(API_URL)
|
||||
data = res.json()
|
||||
cache = []
|
||||
|
||||
# Loop over each file in the JSON directory
|
||||
for item in data:
|
||||
url = item.get("download_url")
|
||||
if not url or not url.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
raw = requests.get(url).json()
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
except:
|
||||
continue
|
||||
|
||||
# Extract fields required to identify a valid helper script
|
||||
name = raw.get("name", "")
|
||||
slug = raw.get("slug")
|
||||
type_ = raw.get("type", "")
|
||||
script = raw.get("install_methods", [{}])[0].get("script", "")
|
||||
if not slug or not script:
|
||||
continue # Skip if it's not a valid script
|
||||
|
||||
desc = raw.get("description", "")
|
||||
categories = raw.get("categories", [])
|
||||
notes = [note.get("text", "") for note in raw.get("notes", []) if isinstance(note, dict)]
|
||||
full_script_url = f"{SCRIPT_BASE}/{script}"
|
||||
|
||||
|
||||
credentials = raw.get("default_credentials", {})
|
||||
cred_username = credentials.get("username")
|
||||
cred_password = credentials.get("password")
|
||||
|
||||
add_credentials = (
|
||||
(cred_username is not None and str(cred_username).strip() != "") or
|
||||
(cred_password is not None and str(cred_password).strip() != "")
|
||||
)
|
||||
|
||||
entry = {
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"desc": desc,
|
||||
"script": script,
|
||||
"script_url": full_script_url,
|
||||
"categories": categories,
|
||||
"notes": notes,
|
||||
"type": type_
|
||||
}
|
||||
if add_credentials:
|
||||
entry["default_credentials"] = {
|
||||
"username": cred_username,
|
||||
"password": cred_password
|
||||
}
|
||||
|
||||
cache.append(entry)
|
||||
|
||||
|
||||
# Write the JSON cache to disk
|
||||
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(cache, f, indent=2)
|
||||
|
||||
print(f"✅ helpers_cache.json created at {OUTPUT_FILE} with {len(cache)} valid scripts.")
|
||||
@@ -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
|
||||
@@ -0,0 +1,81 @@
|
||||
name: Build ProxMenux Monitor AppImage
|
||||
|
||||
on:
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- 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: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
|
||||
path: AppImage/dist/*.AppImage
|
||||
retention-days: 30
|
||||
|
||||
- name: Generate SHA256 checksum
|
||||
run: |
|
||||
cd AppImage/dist
|
||||
sha256sum *.AppImage > ProxMenux-Monitor.AppImage.sha256
|
||||
echo "Generated SHA256:"
|
||||
cat ProxMenux-Monitor.AppImage.sha256
|
||||
|
||||
- name: Upload AppImage and checksum to /AppImage folder in main
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git fetch origin main
|
||||
git checkout main
|
||||
|
||||
rm -f AppImage/*.AppImage AppImage/*.sha256 || true
|
||||
|
||||
# Copy new files
|
||||
cp AppImage/dist/*.AppImage AppImage/
|
||||
cp AppImage/dist/ProxMenux-Monitor.AppImage.sha256 AppImage/
|
||||
|
||||
git add AppImage/*.AppImage AppImage/*.sha256
|
||||
git commit -m "Update AppImage build ($(date +'%Y-%m-%d %H:%M:%S'))" || echo "No changes to commit"
|
||||
git push origin main
|
||||
@@ -8,22 +8,22 @@ on:
|
||||
branches: [ main ]
|
||||
paths: [ 'AppImage/**' ]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
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
|
||||
@@ -52,35 +52,8 @@ 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
|
||||
retention-days: 30
|
||||
|
||||
- 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 and checksum to /AppImage folder in main
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git fetch origin main
|
||||
git checkout main
|
||||
|
||||
rm -f AppImage/*.AppImage AppImage/*.sha256 || true
|
||||
|
||||
# Copy new files
|
||||
cp AppImage/dist/*.AppImage AppImage/
|
||||
cp AppImage/dist/ProxMenux-Monitor.AppImage.sha256 AppImage/
|
||||
|
||||
git add AppImage/*.AppImage AppImage/*.sha256
|
||||
git commit -m "Update AppImage build ($(date +'%Y-%m-%d %H:%M:%S'))" || echo "No changes to commit"
|
||||
git push origin main
|
||||
|
||||
@@ -51,3 +51,5 @@ Thumbs.db
|
||||
!guides/
|
||||
!web/
|
||||
|
||||
# GitHub authentication
|
||||
.github/auth.sh
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
e896eb10de4bf990d31c1d8357289f64cbce481921647f2be53efb850d0b73b2 ProxMenux-1.0.0.AppImage
|
||||
0024ebd5201dc3b504aaa760b18ff6651338e3cae21e26e3cb8f4cc8b613b04a ProxMenux-1.2.0.AppImage
|
||||
|
||||
+735
-23
@@ -2,40 +2,752 @@
|
||||
|
||||
A modern, responsive dashboard for monitoring Proxmox VE systems built with Next.js and React.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Features](#features)
|
||||
- [Technology Stack](#technology-stack)
|
||||
- [Installation](#installation)
|
||||
- [Authentication & Security](#authentication--security)
|
||||
- [Setup Authentication](#setup-authentication)
|
||||
- [Two-Factor Authentication (2FA)](#two-factor-authentication-2fa)
|
||||
- [Security Best Practices for API Tokens](#security-best-practices-for-api-tokens)
|
||||
- [API Documentation](#api-documentation)
|
||||
- [API Authentication](#api-authentication)
|
||||
- [Generating API Tokens](#generating-api-tokens)
|
||||
- [Available Endpoints](#available-endpoints)
|
||||
- [Integration Examples](#integration-examples)
|
||||
- [Homepage Integration](#homepage-integration)
|
||||
- [Home Assistant Integration](#home-assistant-integration)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
**ProxMenux Monitor** is a comprehensive, real-time monitoring dashboard for Proxmox VE environments. Built with modern web technologies, it provides an intuitive interface to monitor system resources, virtual machines, containers, storage, network traffic, and system logs.
|
||||
|
||||
The application runs as a standalone AppImage on your Proxmox server and serves a web interface accessible from any device on your network.
|
||||
|
||||
|
||||
## Screenshots
|
||||
|
||||
Get a quick overview of ProxMenux Monitor's main features:
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/onboarding/imagen1.png" alt="Overview Dashboard" width="800"/>
|
||||
<br/>
|
||||
<em>System Overview - Monitor CPU, memory, temperature, and uptime in real-time</em>
|
||||
</p>
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **System Overview**: Real-time monitoring of CPU, memory, temperature, and active VMs/LXC containers
|
||||
- **Storage Management**: Visual representation of storage distribution and disk performance metrics
|
||||
- **Network Monitoring**: Network interface statistics and performance graphs
|
||||
- **Virtual Machines**: Comprehensive view of VMs and LXC containers with resource usage
|
||||
- **System Logs**: Real-time system log monitoring and filtering
|
||||
- **System Overview**: Real-time monitoring of CPU, memory, temperature, and system uptime
|
||||
- **Storage Management**: Visual representation of storage distribution, disk health, and SMART data
|
||||
- **Network Monitoring**: Network interface statistics, real-time traffic graphs, and bandwidth usage
|
||||
- **Virtual Machines & LXC**: Comprehensive view of all VMs and containers with resource usage and controls
|
||||
- **Hardware Information**: Detailed hardware specifications including CPU, GPU, PCIe devices, and disks
|
||||
- **System Logs**: Real-time system log monitoring with filtering and search capabilities
|
||||
- **Health Monitoring**: Proactive system health checks with persistent error tracking
|
||||
- **Authentication & 2FA**: Optional password protection with TOTP-based two-factor authentication
|
||||
- **RESTful API**: Complete API access for integrations with Homepage, Home Assistant, and custom dashboards
|
||||
- **Dark/Light Theme**: Toggle between themes with Proxmox-inspired design
|
||||
- **Responsive Design**: Works seamlessly on desktop and mobile devices
|
||||
- **Onboarding Experience**: Interactive welcome carousel for first-time users
|
||||
- **Responsive Design**: Works seamlessly on desktop, tablet, and mobile devices
|
||||
- **Release Notes**: Automatic notifications of new features and improvements
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Frontend**: Next.js 15, React 19, TypeScript
|
||||
- **Styling**: Tailwind CSS with custom Proxmox-inspired theme
|
||||
- **Styling**: Tailwind CSS v4 with custom Proxmox-inspired theme
|
||||
- **Charts**: Recharts for data visualization
|
||||
- **UI Components**: Radix UI primitives with shadcn/ui
|
||||
- **Backend**: Flask server for system data collection
|
||||
- **Packaging**: AppImage for easy distribution
|
||||
- **Backend**: Flask (Python) server for system data collection
|
||||
- **Packaging**: AppImage for easy distribution and deployment
|
||||
|
||||
## Onboarding Images
|
||||
## Installation
|
||||
|
||||
To customize the onboarding experience, place your screenshot images in `public/images/onboarding/`:
|
||||
**ProxMenux Monitor is integrated into [ProxMenux](https://proxmenux.com) and comes enabled by default.** No manual installation is required if you're using ProxMenux.
|
||||
|
||||
- `imagen1.png` - Overview section screenshot
|
||||
- `imagen2.png` - Storage section screenshot
|
||||
- `imagen3.png` - Network section screenshot
|
||||
- `imagen4.png` - VMs & LXCs section screenshot
|
||||
- `imagen5.png` - Hardware section screenshot
|
||||
- `imagen6.png` - System Logs section screenshot
|
||||
The monitor automatically starts when ProxMenux is installed and runs as a systemd service on your Proxmox server.
|
||||
|
||||
**Recommended image specifications:**
|
||||
- Format: PNG or JPG
|
||||
- Size: 1200x800px or similar 3:2 aspect ratio
|
||||
- Quality: High-quality screenshots with representative data
|
||||
### Accessing the Dashboard
|
||||
|
||||
The onboarding carousel will automatically show on first visit and can be dismissed or marked as "Don't show again".
|
||||
You can access ProxMenux Monitor in two ways:
|
||||
|
||||
1. **Direct Access**: `http://your-proxmox-ip:8008`
|
||||
2. **Via Proxy** (Recommended): `https://your-domain.com/proxmenux-monitor/`
|
||||
|
||||
**Note**: All API endpoints work seamlessly with both direct access and proxy configurations. When using a reverse proxy, the application automatically detects and adapts to the proxied environment.
|
||||
|
||||
### Proxy Configuration
|
||||
|
||||
ProxMenux Monitor includes built-in support for reverse proxy configurations. If you're using Nginx, Caddy, or Traefik, the application will automatically:
|
||||
|
||||
- Detect the proxy headers (`X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`)
|
||||
- Adjust API endpoints to work correctly through the proxy
|
||||
- Maintain full functionality for all features including authentication and API access
|
||||
|
||||
|
||||
|
||||
## Authentication & Security
|
||||
|
||||
ProxMenux Monitor includes an optional authentication system to protect your dashboard with a password and two-factor authentication.
|
||||
|
||||
### Setup Authentication
|
||||
|
||||
On first launch, you'll be presented with three options:
|
||||
|
||||
1. **Set up authentication** - Create a username and password to protect your dashboard
|
||||
2. **Enable 2FA** - Add TOTP-based two-factor authentication for enhanced security
|
||||
3. **Skip** - Continue without authentication (not recommended for production environments)
|
||||
|
||||

|
||||
|
||||
### Two-Factor Authentication (2FA)
|
||||
|
||||
After setting up your password, you can enable 2FA using any TOTP authenticator app (Google Authenticator, Authy, 1Password, etc.):
|
||||
|
||||
1. Navigate to **Settings > Authentication**
|
||||
2. Click **Enable 2FA**
|
||||
3. Scan the QR code with your authenticator app
|
||||
4. Enter the 6-digit code to verify
|
||||
5. Save your backup codes in a secure location
|
||||
|
||||

|
||||
|
||||
### Security Best Practices for API Tokens
|
||||
|
||||
**IMPORTANT**: Never hardcode your API tokens directly in configuration files or scripts. Instead, use environment variables or secrets management.
|
||||
|
||||
**Option 1: Environment Variables**
|
||||
|
||||
Store your token in an environment variable:
|
||||
|
||||
```bash
|
||||
# Linux/macOS - Add to ~/.bashrc or ~/.zshrc
|
||||
export PROXMENUX_API_TOKEN="your_actual_token_here"
|
||||
|
||||
# Windows PowerShell - Add to profile
|
||||
$env:PROXMENUX_API_TOKEN = "your_actual_token_here"
|
||||
```
|
||||
|
||||
Then reference it in your scripts:
|
||||
|
||||
```bash
|
||||
# Linux/macOS
|
||||
curl -H "Authorization: Bearer $PROXMENUX_API_TOKEN" \
|
||||
http://your-proxmox-ip:8008/api/system
|
||||
|
||||
# Windows PowerShell
|
||||
curl -H "Authorization: Bearer $env:PROXMENUX_API_TOKEN" `
|
||||
http://your-proxmox-ip:8008/api/system
|
||||
```
|
||||
|
||||
**Option 2: Secrets File**
|
||||
|
||||
Create a dedicated secrets file (make sure to add it to `.gitignore`):
|
||||
|
||||
```bash
|
||||
# Create secrets file
|
||||
echo "PROXMENUX_API_TOKEN=your_actual_token_here" > ~/.proxmenux_secrets
|
||||
|
||||
# Secure the file (Linux/macOS only)
|
||||
chmod 600 ~/.proxmenux_secrets
|
||||
|
||||
# Load in your script
|
||||
source ~/.proxmenux_secrets
|
||||
```
|
||||
|
||||
**Option 3: Homepage Secrets (Recommended)**
|
||||
|
||||
Homepage supports secrets management. Create a `secrets.yaml` file:
|
||||
|
||||
```yaml
|
||||
# secrets.yaml (add to .gitignore!)
|
||||
proxmenux_token: "your_actual_token_here"
|
||||
```
|
||||
|
||||
Then reference it in your `services.yaml`:
|
||||
|
||||
```yaml
|
||||
- ProxMenux Monitor:
|
||||
widget:
|
||||
type: customapi
|
||||
url: http://proxmox.example.tld:8008/api/system
|
||||
headers:
|
||||
Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}}
|
||||
```
|
||||
|
||||
**Option 4: Home Assistant Secrets**
|
||||
|
||||
Home Assistant has built-in secrets support. Edit `secrets.yaml`:
|
||||
|
||||
```yaml
|
||||
# secrets.yaml
|
||||
proxmenux_api_token: "your_actual_token_here"
|
||||
```
|
||||
|
||||
Then reference it in `configuration.yaml`:
|
||||
|
||||
```yaml
|
||||
sensor:
|
||||
- platform: rest
|
||||
name: ProxMenux CPU
|
||||
resource: http://proxmox.example.tld:8008/api/system
|
||||
headers:
|
||||
Authorization: !secret proxmenux_api_token
|
||||
```
|
||||
|
||||
**Token Security Checklist:**
|
||||
- ✅ Store tokens in environment variables or secrets files
|
||||
- ✅ Add secrets files to `.gitignore`
|
||||
- ✅ Set proper file permissions (chmod 600 on Linux/macOS)
|
||||
- ✅ Rotate tokens periodically (every 3-6 months)
|
||||
- ✅ Use different tokens for different integrations
|
||||
- ✅ Delete tokens you no longer use
|
||||
- ❌ Never commit tokens to version control
|
||||
- ❌ Never share tokens in screenshots or logs
|
||||
- ❌ Never hardcode tokens in configuration files
|
||||
|
||||
---
|
||||
|
||||
## API Documentation
|
||||
|
||||
ProxMenux Monitor provides a comprehensive RESTful API for integrating with external services like Homepage, Home Assistant, or custom dashboards.
|
||||
|
||||
### API Authentication
|
||||
|
||||
When authentication is enabled on ProxMenux Monitor, all API endpoints (except `/api/health` and `/api/auth/*`) require a valid JWT token in the `Authorization` header.
|
||||
|
||||
### API Endpoint Base URL
|
||||
|
||||
**Direct Access:**
|
||||
```
|
||||
http://your-proxmox-ip:8008/api/
|
||||
```
|
||||
|
||||
**Via Proxy:**
|
||||
```
|
||||
https://your-domain.com/proxmenux-monitor/api/
|
||||
```
|
||||
|
||||
**Note**: All API examples in this documentation work with both direct and proxied URLs. Simply replace the base URL with your preferred access method.
|
||||
|
||||
### Generating API Tokens
|
||||
|
||||
To use the API with authentication enabled, you need to generate a long-lived API token.
|
||||
|
||||
#### Option 1: Generate via Web Panel (Recommended)
|
||||
|
||||
The easiest way to generate an API token is through the ProxMenux Monitor web interface:
|
||||
|
||||
1. Navigate to **Settings** tab in the dashboard
|
||||
2. Scroll to the **API Access Tokens** section
|
||||
3. Enter your password
|
||||
4. If 2FA is enabled, enter your 6-digit code
|
||||
5. Provide a name for the token (e.g., "Homepage Integration")
|
||||
6. Click **Generate Token**
|
||||
7. Copy the token immediately - it will not be shown again
|
||||
|
||||

|
||||
|
||||
The token will be valid for **365 days** (1 year) and can be used for integrations with Homepage, Home Assistant, or any custom application.
|
||||
|
||||
#### Option 2: Generate via API Call
|
||||
|
||||
For advanced users or automation, you can generate tokens programmatically:
|
||||
|
||||
```bash
|
||||
curl -X POST http://your-proxmox-ip:8008/api/auth/generate-api-token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "your-username",
|
||||
"password": "your-password",
|
||||
"totp_token": "123456",
|
||||
"token_name": "Homepage Integration"
|
||||
}'
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"token_name": "Homepage Integration",
|
||||
"expires_in": "365 days",
|
||||
"message": "API token generated successfully. Store this token securely, it will not be shown again."
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- If 2FA is enabled, include the `totp_token` field with your 6-digit code
|
||||
- If 2FA is not enabled, omit the `totp_token` field
|
||||
- The token is valid for **365 days** (1 year)
|
||||
- Store the token securely - it cannot be retrieved again
|
||||
|
||||
#### Option 3: Generate via cURL (without 2FA)
|
||||
|
||||
```bash
|
||||
# Without 2FA
|
||||
curl -X POST http://your-proxmox-ip:8008/api/auth/generate-api-token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"pedro","password":"your-password","token_name":"Homepage"}'
|
||||
```
|
||||
|
||||
### Using API Tokens
|
||||
|
||||
Once you have your API token, include it in the `Authorization` header of all API requests:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_API_TOKEN_HERE" \
|
||||
http://your-proxmox-ip:8008/api/system
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Available Endpoints
|
||||
|
||||
Below is a complete list of all API endpoints with descriptions and example responses.
|
||||
|
||||
#### System & Metrics
|
||||
|
||||
| Endpoint | Method | Auth Required | Description |
|
||||
|----------|--------|---------------|-------------|
|
||||
| `/api/system` | GET | Yes | Complete system information (CPU, memory, temperature, uptime) |
|
||||
| `/api/system-info` | GET | No | Lightweight system info for header (hostname, uptime, health) |
|
||||
| `/api/node/metrics` | GET | Yes | Historical metrics data (RRD) for CPU, memory, disk I/O |
|
||||
| `/api/prometheus` | GET | Yes | Export metrics in Prometheus format |
|
||||
|
||||
**Example `/api/system` Response:**
|
||||
```json
|
||||
{
|
||||
"hostname": "pve",
|
||||
"cpu_usage": 15.2,
|
||||
"memory_usage": 45.8,
|
||||
"temperature": 42.5,
|
||||
"uptime": 345600,
|
||||
"kernel": "6.2.16-3-pve",
|
||||
"pve_version": "8.0.3"
|
||||
}
|
||||
```
|
||||
|
||||
#### Storage
|
||||
|
||||
| Endpoint | Method | Auth Required | Description |
|
||||
|----------|--------|---------------|-------------|
|
||||
| `/api/storage` | GET | Yes | Complete storage information with SMART data |
|
||||
| `/api/storage/summary` | GET | Yes | Optimized storage summary (without SMART) |
|
||||
| `/api/proxmox-storage` | GET | Yes | Proxmox storage pools information |
|
||||
| `/api/backups` | GET | Yes | List of all backup files |
|
||||
|
||||
**Example `/api/storage/summary` Response:**
|
||||
```json
|
||||
{
|
||||
"total_capacity": 1431894917120,
|
||||
"used_space": 197414092800,
|
||||
"free_space": 1234480824320,
|
||||
"usage_percentage": 13.8,
|
||||
"disks": [
|
||||
{
|
||||
"device": "/dev/sda",
|
||||
"model": "Samsung SSD 970",
|
||||
"size": "476.94 GB",
|
||||
"type": "SSD"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Network
|
||||
|
||||
| Endpoint | Method | Auth Required | Description |
|
||||
|----------|--------|---------------|-------------|
|
||||
| `/api/network` | GET | Yes | Complete network information for all interfaces |
|
||||
| `/api/network/summary` | GET | Yes | Optimized network summary |
|
||||
| `/api/network/<interface>/metrics` | GET | Yes | Historical metrics (RRD) for specific interface |
|
||||
|
||||
**Example `/api/network/summary` Response:**
|
||||
```json
|
||||
{
|
||||
"interfaces": [
|
||||
{
|
||||
"name": "vmbr0",
|
||||
"ip": "192.168.1.100",
|
||||
"state": "up",
|
||||
"rx_bytes": 1234567890,
|
||||
"tx_bytes": 987654321
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Virtual Machines & Containers
|
||||
|
||||
| Endpoint | Method | Auth Required | Description |
|
||||
|----------|--------|---------------|-------------|
|
||||
| `/api/vms` | GET | Yes | List of all VMs and LXC containers |
|
||||
| `/api/vms/<vmid>` | GET | Yes | Detailed configuration for specific VM/LXC |
|
||||
| `/api/vms/<vmid>/metrics` | GET | Yes | Historical metrics (RRD) for specific VM/LXC |
|
||||
| `/api/vms/<vmid>/logs` | GET | Yes | Download real logs for specific VM/LXC |
|
||||
| `/api/vms/<vmid>/control` | POST | Yes | Control VM/LXC (start, stop, shutdown, reboot) |
|
||||
| `/api/vms/<vmid>/config` | PUT | Yes | Update VM/LXC configuration (description/notes) |
|
||||
|
||||
**Example `/api/vms` Response:**
|
||||
```json
|
||||
{
|
||||
"vms": [
|
||||
{
|
||||
"vmid": "100",
|
||||
"name": "ubuntu-server",
|
||||
"type": "qemu",
|
||||
"status": "running",
|
||||
"cpu": 2,
|
||||
"maxcpu": 4,
|
||||
"mem": 2147483648,
|
||||
"maxmem": 4294967296,
|
||||
"uptime": 86400
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Hardware
|
||||
|
||||
| Endpoint | Method | Auth Required | Description |
|
||||
|----------|--------|---------------|-------------|
|
||||
| `/api/hardware` | GET | Yes | Complete hardware information (CPU, GPU, PCIe, disks) |
|
||||
| `/api/gpu/<slot>/realtime` | GET | Yes | Real-time monitoring for specific GPU |
|
||||
|
||||
**Example `/api/hardware` Response:**
|
||||
```json
|
||||
{
|
||||
"cpu": {
|
||||
"model": "AMD Ryzen 9 5950X",
|
||||
"cores": 16,
|
||||
"threads": 32,
|
||||
"frequency": "3.4 GHz"
|
||||
},
|
||||
"gpus": [
|
||||
{
|
||||
"slot": "0000:01:00.0",
|
||||
"vendor": "NVIDIA",
|
||||
"model": "GeForce RTX 3080",
|
||||
"driver": "nvidia"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Logs, Events & Notifications
|
||||
|
||||
| Endpoint | Method | Auth Required | Description |
|
||||
|----------|--------|---------------|-------------|
|
||||
| `/api/logs` | GET | Yes | System logs (journalctl) with filters |
|
||||
| `/api/logs/download` | GET | Yes | Download logs as text file |
|
||||
| `/api/notifications` | GET | Yes | Proxmox notification history |
|
||||
| `/api/notifications/download` | GET | Yes | Download full notification log |
|
||||
| `/api/events` | GET | Yes | Recent Proxmox tasks and events |
|
||||
| `/api/task-log/<upid>` | GET | Yes | Full log for specific task using UPID |
|
||||
|
||||
**Example `/api/logs` Query Parameters:**
|
||||
```
|
||||
/api/logs?severity=error&since=1h&search=failed
|
||||
```
|
||||
|
||||
#### Health Monitoring
|
||||
|
||||
| Endpoint | Method | Auth Required | Description |
|
||||
|----------|--------|---------------|-------------|
|
||||
| `/api/health` | GET | No | Basic health check (for external monitoring) |
|
||||
| `/api/health/status` | GET | Yes | Summary of system health status |
|
||||
| `/api/health/details` | GET | Yes | Detailed health check results |
|
||||
| `/api/health/acknowledge` | POST | Yes | Dismiss/acknowledge health warnings |
|
||||
| `/api/health/active-errors` | GET | Yes | Get active persistent errors |
|
||||
|
||||
#### ProxMenux Optimizations
|
||||
|
||||
| Endpoint | Method | Auth Required | Description |
|
||||
|----------|--------|---------------|-------------|
|
||||
| `/api/proxmenux/installed-tools` | GET | Yes | List of installed ProxMenux optimizations |
|
||||
|
||||
#### Authentication
|
||||
|
||||
| Endpoint | Method | Auth Required | Description |
|
||||
|----------|--------|---------------|-------------|
|
||||
| `/api/auth/status` | GET | No | Current authentication status |
|
||||
| `/api/auth/login` | POST | No | Authenticate and receive JWT token |
|
||||
| `/api/auth/generate-api-token` | POST | No | Generate long-lived API token (365 days) |
|
||||
| `/api/auth/setup` | POST | No | Initial setup of username/password |
|
||||
| `/api/auth/enable` | POST | No | Enable authentication |
|
||||
| `/api/auth/disable` | POST | Yes | Disable authentication |
|
||||
| `/api/auth/change-password` | POST | No | Change password |
|
||||
| `/api/auth/totp/setup` | POST | Yes | Initialize 2FA setup |
|
||||
| `/api/auth/totp/enable` | POST | Yes | Enable 2FA after verification |
|
||||
| `/api/auth/totp/disable` | POST | Yes | Disable 2FA |
|
||||
|
||||
---
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### Homepage Integration
|
||||
|
||||
[Homepage](https://gethomepage.dev/) is a modern, fully static, fast, secure fully proxied, highly customizable application dashboard.
|
||||
|
||||
#### Basic Configuration (No Authentication)
|
||||
|
||||
```yaml
|
||||
- ProxMenux Monitor:
|
||||
href: http://proxmox.example.tld:8008/
|
||||
icon: lucide:flask-round
|
||||
widget:
|
||||
type: customapi
|
||||
url: http://proxmox.example.tld:8008/api/system
|
||||
refreshInterval: 10000
|
||||
mappings:
|
||||
- field: uptime
|
||||
label: Uptime
|
||||
icon: lucide:clock-4
|
||||
format: text
|
||||
- field: cpu_usage
|
||||
label: CPU
|
||||
icon: lucide:cpu
|
||||
format: percent
|
||||
- field: memory_usage
|
||||
label: RAM
|
||||
icon: lucide:memory-stick
|
||||
format: percent
|
||||
- field: temperature
|
||||
label: Temp
|
||||
icon: lucide:thermometer-sun
|
||||
format: number
|
||||
suffix: °C
|
||||
```
|
||||
|
||||
#### With Authentication Enabled (Using Secrets)
|
||||
|
||||
First, generate an API token via the web interface (Settings > API Access Tokens) or via API.
|
||||
|
||||
Then, store your token securely in Homepage's `secrets.yaml`:
|
||||
|
||||
```yaml
|
||||
# secrets.yaml (add to .gitignore!)
|
||||
proxmenux_token: "your_actual_api_token_here"
|
||||
```
|
||||
|
||||
Finally, reference the secret in your `services.yaml`:
|
||||
|
||||
```yaml
|
||||
- ProxMenux Monitor:
|
||||
href: http://proxmox.example.tld:8008/
|
||||
icon: lucide:flask-round
|
||||
widget:
|
||||
type: customapi
|
||||
url: http://proxmox.example.tld:8008/api/system
|
||||
headers:
|
||||
Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}}
|
||||
refreshInterval: 10000
|
||||
mappings:
|
||||
- field: uptime
|
||||
label: Uptime
|
||||
icon: lucide:clock-4
|
||||
format: text
|
||||
- field: cpu_usage
|
||||
label: CPU
|
||||
icon: lucide:cpu
|
||||
format: percent
|
||||
- field: memory_usage
|
||||
label: RAM
|
||||
icon: lucide:memory-stick
|
||||
format: percent
|
||||
- field: temperature
|
||||
label: Temp
|
||||
icon: lucide:thermometer-sun
|
||||
format: number
|
||||
suffix: °C
|
||||
```
|
||||
|
||||
#### Advanced Multi-Widget Configuration
|
||||
|
||||
```yaml
|
||||
# Store token in secrets.yaml
|
||||
# proxmenux_token: "your_actual_api_token_here"
|
||||
|
||||
- ProxMenux System:
|
||||
href: http://proxmox.example.tld:8008/
|
||||
icon: lucide:server
|
||||
description: Proxmox VE Host
|
||||
widget:
|
||||
type: customapi
|
||||
url: http://proxmox.example.tld:8008/api/system
|
||||
headers:
|
||||
Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}}
|
||||
refreshInterval: 5000
|
||||
mappings:
|
||||
- field: cpu_usage
|
||||
label: CPU
|
||||
icon: lucide:cpu
|
||||
format: percent
|
||||
- field: memory_usage
|
||||
label: RAM
|
||||
icon: lucide:memory-stick
|
||||
format: percent
|
||||
- field: temperature
|
||||
label: Temp
|
||||
icon: lucide:thermometer-sun
|
||||
format: number
|
||||
suffix: °C
|
||||
|
||||
- ProxMenux Storage:
|
||||
href: http://proxmox.example.tld:8008/#/storage
|
||||
icon: lucide:hard-drive
|
||||
description: Storage Overview
|
||||
widget:
|
||||
type: customapi
|
||||
url: http://proxmox.example.tld:8008/api/storage/summary
|
||||
headers:
|
||||
Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}}
|
||||
refreshInterval: 30000
|
||||
mappings:
|
||||
- field: usage_percentage
|
||||
label: Used
|
||||
icon: lucide:database
|
||||
format: percent
|
||||
- field: used_space
|
||||
label: Space
|
||||
icon: lucide:folder
|
||||
format: bytes
|
||||
|
||||
- ProxMenux Network:
|
||||
href: http://proxmox.example.tld:8008/#/network
|
||||
icon: lucide:network
|
||||
description: Network Stats
|
||||
widget:
|
||||
type: customapi
|
||||
url: http://proxmox.example.tld:8008/api/network/summary
|
||||
headers:
|
||||
Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}}
|
||||
refreshInterval: 5000
|
||||
mappings:
|
||||
- field: interfaces[0].rx_bytes
|
||||
label: Received
|
||||
icon: lucide:download
|
||||
format: bytes
|
||||
- field: interfaces[0].tx_bytes
|
||||
label: Sent
|
||||
icon: lucide:upload
|
||||
format: bytes
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Home Assistant Integration
|
||||
|
||||
[Home Assistant](https://www.home-assistant.io/) is an open-source home automation platform.
|
||||
|
||||
#### Store Token Securely
|
||||
|
||||
First, add your API token to Home Assistant's `secrets.yaml`:
|
||||
|
||||
```yaml
|
||||
# secrets.yaml
|
||||
proxmenux_api_token: "Bearer your_actual_api_token_here"
|
||||
```
|
||||
|
||||
**Note**: Include "Bearer " prefix in the secrets file for Home Assistant.
|
||||
|
||||
#### Configuration.yaml
|
||||
|
||||
```yaml
|
||||
# ProxMenux Monitor Sensors
|
||||
sensor:
|
||||
- platform: rest
|
||||
name: ProxMenux CPU
|
||||
resource: http://proxmox.example.tld:8008/api/system
|
||||
headers:
|
||||
Authorization: !secret proxmenux_api_token
|
||||
value_template: "{{ value_json.cpu_usage }}"
|
||||
unit_of_measurement: "%"
|
||||
scan_interval: 30
|
||||
|
||||
- platform: rest
|
||||
name: ProxMenux Memory
|
||||
resource: http://proxmox.example.tld:8008/api/system
|
||||
headers:
|
||||
Authorization: !secret proxmenux_api_token
|
||||
value_template: "{{ value_json.memory_usage }}"
|
||||
unit_of_measurement: "%"
|
||||
scan_interval: 30
|
||||
|
||||
- platform: rest
|
||||
name: ProxMenux Temperature
|
||||
resource: http://proxmox.example.tld:8008/api/system
|
||||
headers:
|
||||
Authorization: !secret proxmenux_api_token
|
||||
value_template: "{{ value_json.temperature }}"
|
||||
unit_of_measurement: "°C"
|
||||
device_class: temperature
|
||||
scan_interval: 30
|
||||
|
||||
- platform: rest
|
||||
name: ProxMenux Uptime
|
||||
resource: http://proxmox.example.tld:8008/api/system
|
||||
headers:
|
||||
Authorization: !secret proxmenux_api_token
|
||||
value_template: >
|
||||
{% set uptime_seconds = value_json.uptime | int %}
|
||||
{% set days = (uptime_seconds / 86400) | int %}
|
||||
{% set hours = ((uptime_seconds % 86400) / 3600) | int %}
|
||||
{% set minutes = ((uptime_seconds % 3600) / 60) | int %}
|
||||
{{ days }}d {{ hours }}h {{ minutes }}m
|
||||
scan_interval: 60
|
||||
```
|
||||
|
||||
#### Lovelace Card Example
|
||||
|
||||
```yaml
|
||||
type: entities
|
||||
title: Proxmox Monitor
|
||||
entities:
|
||||
- entity: sensor.proxmenux_cpu
|
||||
name: CPU Usage
|
||||
icon: mdi:cpu-64-bit
|
||||
- entity: sensor.proxmenux_memory
|
||||
name: Memory Usage
|
||||
icon: mdi:memory
|
||||
- entity: sensor.proxmenux_temperature
|
||||
name: Temperature
|
||||
icon: mdi:thermometer
|
||||
- entity: sensor.proxmenux_uptime
|
||||
name: Uptime
|
||||
icon: mdi:clock-outline
|
||||
```
|
||||
|
||||

|
||||
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **Creative Commons Attribution-NonCommercial 4.0 International License (CC 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
|
||||
|
||||
For more details, see the [full license](https://creativecommons.org/licenses/by-nc/4.0/).
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**ProxMenux Monitor** - Made with ❤️ for the Proxmox community
|
||||
|
||||
@@ -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" },
|
||||
|
||||
+92
-1
@@ -1,7 +1,98 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { ProxmoxDashboard } from "../components/proxmox-dashboard"
|
||||
import { Login } from "../components/login"
|
||||
import { AuthSetup } from "../components/auth-setup"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
|
||||
export default function Home() {
|
||||
return <ProxmoxDashboard />
|
||||
const [authStatus, setAuthStatus] = useState<{
|
||||
loading: boolean
|
||||
authEnabled: boolean
|
||||
authConfigured: boolean
|
||||
authenticated: boolean
|
||||
}>({
|
||||
loading: true,
|
||||
authEnabled: false,
|
||||
authConfigured: false,
|
||||
authenticated: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
checkAuthStatus()
|
||||
}, [])
|
||||
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem("proxmenux-auth-token")
|
||||
const response = await fetch(getApiUrl("/api/auth/status"), {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
|
||||
// Check if response is valid JSON before parsing
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type")
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
throw new Error("Response is not JSON")
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const authenticated = data.auth_enabled ? data.authenticated : true
|
||||
|
||||
setAuthStatus({
|
||||
loading: false,
|
||||
authEnabled: data.auth_enabled,
|
||||
authConfigured: data.auth_configured,
|
||||
authenticated,
|
||||
})
|
||||
} catch {
|
||||
// API not available - assume no auth configured (silent fail, no console error)
|
||||
setAuthStatus({
|
||||
loading: false,
|
||||
authEnabled: false,
|
||||
authConfigured: false,
|
||||
authenticated: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleAuthComplete = () => {
|
||||
checkAuthStatus()
|
||||
}
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
checkAuthStatus()
|
||||
}
|
||||
|
||||
if (authStatus.loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
if (authStatus.authEnabled && !authStatus.authenticated) {
|
||||
return <Login onLogin={handleLoginSuccess} />
|
||||
}
|
||||
|
||||
// Show dashboard in all other cases
|
||||
return (
|
||||
<>
|
||||
{!authStatus.authConfigured && <AuthSetup onComplete={handleAuthComplete} />}
|
||||
<ProxmoxDashboard />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Shield, Lock, User, AlertCircle, Eye, EyeOff } from "lucide-react"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
|
||||
interface AuthSetupProps {
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState<"choice" | "setup">("choice")
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
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()
|
||||
|
||||
// Show modal if auth is not configured and not declined
|
||||
if (!data.auth_configured) {
|
||||
setTimeout(() => setOpen(true), 500)
|
||||
}
|
||||
} catch {
|
||||
// API not available (preview environment) - don't show modal
|
||||
}
|
||||
}
|
||||
|
||||
checkOnboardingStatus()
|
||||
}, [])
|
||||
|
||||
const handleSkipAuth = async () => {
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
try {
|
||||
console.log("[v0] Skipping authentication setup...")
|
||||
const response = await fetch(getApiUrl("/api/auth/skip"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
console.log("[v0] Auth skip response:", data)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Failed to skip authentication")
|
||||
}
|
||||
|
||||
if (data.auth_declined) {
|
||||
console.log("[v0] Authentication skipped successfully - APIs should be accessible without token")
|
||||
}
|
||||
|
||||
console.log("[v0] Authentication skipped successfully")
|
||||
localStorage.setItem("proxmenux-auth-declined", "true")
|
||||
localStorage.removeItem("proxmenux-auth-token") // Remove any old token
|
||||
setOpen(false)
|
||||
onComplete()
|
||||
} catch (err) {
|
||||
console.error("[v0] Auth skip error:", err)
|
||||
setError(err instanceof Error ? err.message : "Failed to save preference")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetupAuth = async () => {
|
||||
setError("")
|
||||
|
||||
if (!username || !password) {
|
||||
setError("Please fill in all fields")
|
||||
return
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match")
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("Password must be at least 6 characters")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
console.log("[v0] Setting up authentication...")
|
||||
const response = await fetch(getApiUrl("/api/auth/setup"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
console.log("[v0] Auth setup response:", data)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Failed to setup authentication")
|
||||
}
|
||||
|
||||
if (data.token) {
|
||||
localStorage.setItem("proxmenux-auth-token", data.token)
|
||||
localStorage.removeItem("proxmenux-auth-declined")
|
||||
console.log("[v0] Authentication setup successful")
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
onComplete()
|
||||
} catch (err) {
|
||||
console.error("[v0] Auth setup error:", err)
|
||||
setError(err instanceof Error ? err.message : "Failed to setup authentication")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
|
||||
<Shield className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold">Protect Your Dashboard?</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Add an extra layer of security to protect your Proxmox data when accessing from non-private networks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button onClick={() => setStep("setup")} className="w-full bg-blue-500 hover:bg-blue-600" size="lg">
|
||||
<Lock className="h-4 w-4 mr-2" />
|
||||
Yes, Setup Password
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSkipAuth}
|
||||
variant="outline"
|
||||
className="w-full bg-transparent"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
>
|
||||
No, Continue Without Protection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">You can always enable this later in Settings</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6 py-2">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
|
||||
<Lock className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold">Setup Authentication</h2>
|
||||
<p className="text-muted-foreground text-sm">Create a username and password to protect your dashboard</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="text-sm">
|
||||
Username
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="Enter username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
disabled={loading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm">
|
||||
Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
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>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password" className="text-sm">
|
||||
Confirm Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="Confirm password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
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>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button onClick={handleSetupAuth} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||
{loading ? "Setting up..." : "Setup Authentication"}
|
||||
</Button>
|
||||
<Button onClick={() => setStep("choice")} variant="ghost" className="w-full" disabled={loading}>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+1280
-164
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,842 @@
|
||||
"use client"
|
||||
|
||||
import type React 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"
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Info,
|
||||
Activity,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
Disc,
|
||||
Network,
|
||||
Box,
|
||||
Settings,
|
||||
FileText,
|
||||
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
|
||||
details: {
|
||||
cpu: CategoryCheck
|
||||
memory: CategoryCheck
|
||||
storage: CategoryCheck
|
||||
disks: CategoryCheck
|
||||
network: CategoryCheck
|
||||
vms: CategoryCheck
|
||||
services: CategoryCheck
|
||||
logs: CategoryCheck
|
||||
updates: CategoryCheck
|
||||
security: CategoryCheck
|
||||
}
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface FullHealthData {
|
||||
health: HealthDetails
|
||||
active_errors: any[]
|
||||
dismissed: DismissedError[]
|
||||
custom_suppressions: CustomSuppression[]
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface HealthStatusModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
getApiUrl: (path: string) => string
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ 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())
|
||||
|
||||
const fetchHealthDetails = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
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 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: newOverallStatus, infoCount: totalInfoCount },
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
} catch (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, 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={`${cls} text-green-500`} />
|
||||
case "INFO":
|
||||
return <Info className={`${cls} text-blue-500`} />
|
||||
case "WARNING":
|
||||
return <AlertTriangle className={`${cls} text-yellow-500`} />
|
||||
case "CRITICAL":
|
||||
return <XCircle className={`${cls} text-red-500`} />
|
||||
case "UNKNOWN":
|
||||
return <HelpCircle className={`${cls} text-amber-400`} />
|
||||
default:
|
||||
return <Activity className={`${cls} text-muted-foreground`} />
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusUpper = status?.toUpperCase()
|
||||
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>
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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, info, warnings, critical, unknown }
|
||||
}
|
||||
|
||||
const stats = getHealthStats()
|
||||
|
||||
const handleCategoryClick = (categoryKey: string, status: string) => {
|
||||
if (status === "OK" || status === "INFO") return
|
||||
|
||||
onOpenChange(false)
|
||||
|
||||
const categoryToTab: Record<string, string> = {
|
||||
storage: "storage",
|
||||
disks: "storage",
|
||||
network: "network",
|
||||
vms: "vms",
|
||||
logs: "logs",
|
||||
hardware: "hardware",
|
||||
services: "hardware",
|
||||
}
|
||||
|
||||
const targetTab = categoryToTab[categoryKey]
|
||||
if (targetTab) {
|
||||
const event = new CustomEvent("changeTab", { detail: { tab: targetTab } })
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAcknowledge = async (errorKey: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setDismissingKey(errorKey)
|
||||
|
||||
try {
|
||||
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,
|
||||
body: JSON.stringify({ error_key: errorKey }),
|
||||
})
|
||||
|
||||
const responseData = await response.json().catch(() => ({}))
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(responseData.error || `Failed to dismiss error (${response.status})`)
|
||||
}
|
||||
|
||||
// Optimistically update local state to avoid slow re-fetch
|
||||
// Add the dismissed item to the local list immediately
|
||||
if (responseData.result || responseData.success) {
|
||||
const dismissedItem = {
|
||||
error_key: errorKey,
|
||||
category: responseData.result?.category || responseData.category || '',
|
||||
severity: responseData.result?.original_severity || 'WARNING',
|
||||
reason: 'Dismissed by user',
|
||||
dismissed: true,
|
||||
acknowledged_at: new Date().toISOString()
|
||||
}
|
||||
setDismissedItems(prev => [...prev, dismissedItem])
|
||||
}
|
||||
|
||||
// Fetch fresh data in background (non-blocking)
|
||||
fetchHealthDetails().catch(() => {})
|
||||
} catch (err) {
|
||||
console.error("Error dismissing:", err)
|
||||
} finally {
|
||||
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 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 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 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 && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800 dark:bg-red-950 dark:border-red-800 dark:text-red-200">
|
||||
<p className="font-medium">Error loading health status</p>
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{healthData && !loading && (
|
||||
<div className="space-y-4">
|
||||
{/* Overall Stats Summary */}
|
||||
<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-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-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-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-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-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 originalStatus = categoryData?.status || "UNKNOWN"
|
||||
const status = getEffectiveStatus(key, originalStatus)
|
||||
const reason = categoryData?.reason
|
||||
const checks = categoryData?.checks
|
||||
const isExpanded = expandedCategories.has(key)
|
||||
const hasChecks = checks && Object.keys(checks).length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`rounded-lg border transition-colors overflow-hidden ${getCategoryRowStyle(status)}`}
|
||||
>
|
||||
{/* 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>
|
||||
</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()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,258 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
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, Eye, EyeOff } from "lucide-react"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
import Image from "next/image"
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: () => void
|
||||
}
|
||||
|
||||
export function Login({ onLogin }: LoginProps) {
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
const savedUsername = localStorage.getItem("proxmenux-saved-username")
|
||||
const savedPassword = localStorage.getItem("proxmenux-saved-password")
|
||||
|
||||
if (savedUsername && savedPassword) {
|
||||
setUsername(savedUsername)
|
||||
setPassword(savedPassword)
|
||||
setRememberMe(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
|
||||
if (!username || !password) {
|
||||
setError("Please enter username and password")
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresTotp && !totpCode) {
|
||||
setError("Please enter your 2FA code")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/auth/login"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
totp_token: totpCode || undefined, // Include 2FA code if provided
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.requires_totp) {
|
||||
setRequiresTotp(true)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || "Login failed")
|
||||
}
|
||||
|
||||
localStorage.setItem("proxmenux-auth-token", data.token)
|
||||
|
||||
if (rememberMe) {
|
||||
localStorage.setItem("proxmenux-saved-username", username)
|
||||
localStorage.setItem("proxmenux-saved-password", password)
|
||||
} else {
|
||||
localStorage.removeItem("proxmenux-saved-username")
|
||||
localStorage.removeItem("proxmenux-saved-password")
|
||||
}
|
||||
|
||||
onLogin()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Login failed")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-20 h-20 relative flex items-center justify-center bg-primary/10 rounded-lg">
|
||||
<Image
|
||||
src="/images/proxmenux-logo.png"
|
||||
alt="ProxMenux Logo"
|
||||
width={80}
|
||||
height={80}
|
||||
className="object-contain"
|
||||
priority
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = "none"
|
||||
const fallback = target.parentElement?.querySelector(".fallback-icon")
|
||||
if (fallback) {
|
||||
fallback.classList.remove("hidden")
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Server className="h-12 w-12 text-primary absolute fallback-icon hidden" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">ProxMenux Monitor</h1>
|
||||
<p className="text-muted-foreground mt-2">Sign in to access your dashboard</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-border rounded-lg p-6 shadow-lg">
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!requiresTotp ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-username" className="text-sm">
|
||||
Username
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="login-username"
|
||||
type="text"
|
||||
placeholder="Enter your username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
disabled={loading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-password" className="text-sm">
|
||||
Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="login-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
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>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="remember-me"
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(checked) => setRememberMe(checked as boolean)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Label htmlFor="remember-me" className="text-sm font-normal cursor-pointer select-none">
|
||||
Remember me
|
||||
</Label>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 flex items-start gap-2">
|
||||
<Shield className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-500">Two-Factor Authentication</p>
|
||||
<p className="text-xs text-blue-500 mt-1">Enter the 6-digit code from your authentication app</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="totp-code" className="text-sm">
|
||||
Authentication Code
|
||||
</Label>
|
||||
<Input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
className="text-center text-lg tracking-widest font-mono text-base"
|
||||
maxLength={6}
|
||||
disabled={loading}
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
You can also use a backup code (format: XXXX-XXXX)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRequiresTotp(false)
|
||||
setTotpCode("")
|
||||
setError("")
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||
{loading ? "Signing in..." : requiresTotp ? "Verify Code" : "Sign In"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { ArrowLeft, Loader2 } from "lucide-react"
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
|
||||
interface MetricsViewProps {
|
||||
vmid: number
|
||||
@@ -118,18 +119,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const baseUrl =
|
||||
typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
const apiUrl = `${baseUrl}/api/vms/${vmid}/metrics?timeframe=${timeframe}`
|
||||
|
||||
const response = await fetch(apiUrl)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || "Failed to fetch metrics")
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const result = await fetchApi<any>(`/api/vms/${vmid}/metrics?timeframe=${timeframe}`)
|
||||
|
||||
const transformedData = result.data.map((item: any) => {
|
||||
const date = new Date(item.time * 1000)
|
||||
|
||||
@@ -2,8 +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_: {
|
||||
@@ -58,62 +60,46 @@ 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 {
|
||||
const response = await fetch(`/api/network/${interface_.name}/metrics?timeframe=${timeframe}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
const data = await fetchApi(`/api/network/${interface_.name}/metrics?timeframe=${timeframe}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch traffic data: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Calculate totals from the data points
|
||||
if (data.data && data.data.length > 0) {
|
||||
const lastPoint = data.data[data.data.length - 1]
|
||||
const firstPoint = data.data[0]
|
||||
|
||||
// Calculate the difference between last and first data points
|
||||
const receivedGB = Math.max(0, (lastPoint.netin || 0) - (firstPoint.netin || 0))
|
||||
const sentGB = Math.max(0, (lastPoint.netout || 0) - (firstPoint.netout || 0))
|
||||
|
||||
@@ -124,16 +110,13 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to fetch traffic data for card:", error)
|
||||
// Keep showing 0 values on error
|
||||
setTrafficData({ received: 0, sent: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// Only fetch if interface is up and not a VM
|
||||
if (interface_.status.toLowerCase() === "up" && interface_.vm_type !== "vm") {
|
||||
fetchTrafficData()
|
||||
|
||||
// Refresh every 60 seconds
|
||||
const interval = setInterval(fetchTrafficData, 60000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
@@ -223,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,13 +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 } from "./ui/dialog"
|
||||
import { Wifi, Activity, Network, Router, AlertCircle, Zap } from "lucide-react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
||||
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[]
|
||||
@@ -128,29 +132,18 @@ const formatSpeed = (speed: number): string => {
|
||||
}
|
||||
|
||||
const fetcher = async (url: string): Promise<NetworkData> => {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Flask server responded with status: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
return fetchApi<NetworkData>(url)
|
||||
}
|
||||
|
||||
|
||||
export function NetworkMetrics() {
|
||||
const {
|
||||
data: networkData,
|
||||
error,
|
||||
isLoading,
|
||||
} = useSWR<NetworkData>("/api/network", fetcher, {
|
||||
refreshInterval: 60000, // Refresh every 60 seconds
|
||||
revalidateOnFocus: false,
|
||||
refreshInterval: 15000,
|
||||
revalidateOnFocus: true,
|
||||
revalidateOnReconnect: true,
|
||||
})
|
||||
|
||||
@@ -159,24 +152,51 @@ 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: 15000, // Refresh every 15 seconds when modal is open
|
||||
refreshInterval: 17000,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: true,
|
||||
})
|
||||
|
||||
const { data: interfaceHistoricalData } = useSWR<any>(`/api/node/metrics?timeframe=${timeframe}`, fetcher, {
|
||||
refreshInterval: 30000,
|
||||
refreshInterval: 29000,
|
||||
revalidateOnFocus: false,
|
||||
})
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -202,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)
|
||||
@@ -315,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>
|
||||
@@ -386,7 +461,7 @@ export function NetworkMetrics() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<NetworkTrafficChart timeframe={timeframe} onTotalsCalculated={setNetworkTotals} />
|
||||
<NetworkTrafficChart timeframe={timeframe} onTotalsCalculated={setNetworkTotals} networkUnit={networkUnit} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -688,6 +763,9 @@ export function NetworkMetrics() {
|
||||
<Router className="h-5 w-5" />
|
||||
{selectedInterface?.name} - Interface Details
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
View detailed information and network traffic statistics for this interface
|
||||
</DialogDescription>
|
||||
{selectedInterface?.status.toLowerCase() === "up" && selectedInterface?.vm_type !== "vm" && (
|
||||
<div className="flex justify-end pt-2">
|
||||
<Select value={modalTimeframe} onValueChange={(value: any) => setModalTimeframe(value)}>
|
||||
@@ -720,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 */}
|
||||
@@ -877,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>
|
||||
|
||||
@@ -940,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>
|
||||
@@ -1065,6 +1151,12 @@ export function NetworkMetrics() {
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Latency Detail Modal */}
|
||||
<LatencyDetailModal
|
||||
open={latencyModalOpen}
|
||||
onOpenChange={setLatencyModalOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { getNetworkUnit } from "../lib/format-network"
|
||||
|
||||
interface NetworkMetricsData {
|
||||
time: string
|
||||
@@ -16,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">
|
||||
@@ -28,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>
|
||||
@@ -43,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)
|
||||
@@ -52,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) {
|
||||
@@ -66,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) {
|
||||
@@ -75,22 +106,13 @@ export function NetworkTrafficChart({
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const baseUrl =
|
||||
typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
const apiPath = interfaceName
|
||||
? `/api/network/${interfaceName}/metrics?timeframe=${timeframe}`
|
||||
: `/api/node/metrics?timeframe=${timeframe}`
|
||||
|
||||
const apiUrl = interfaceName
|
||||
? `${baseUrl}/api/network/${interfaceName}/metrics?timeframe=${timeframe}`
|
||||
: `${baseUrl}/api/node/metrics?timeframe=${timeframe}`
|
||||
console.log("[v0] Fetching network metrics from:", apiPath)
|
||||
|
||||
console.log("[v0] Fetching network metrics from:", apiUrl)
|
||||
|
||||
const response = await fetch(apiUrl)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch network metrics: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const result = await fetchApi<any>(apiPath)
|
||||
|
||||
if (!result.data || !Array.isArray(result.data)) {
|
||||
throw new Error("Invalid data format received from server")
|
||||
@@ -146,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,
|
||||
@@ -156,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) {
|
||||
@@ -248,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"
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
||||
import { Loader2, TrendingUp, MemoryStick } from "lucide-react"
|
||||
import { useIsMobile } from "../hooks/use-mobile"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
|
||||
const TIMEFRAME_OPTIONS = [
|
||||
{ value: "hour", label: "1 Hour" },
|
||||
@@ -69,12 +71,17 @@ export function NodeMetricsCharts() {
|
||||
const [data, setData] = useState<NodeMetricsData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const [visibleLines, setVisibleLines] = useState({
|
||||
cpu: { cpu: true, load: true },
|
||||
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()
|
||||
@@ -86,24 +93,8 @@ export function NodeMetricsCharts() {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const baseUrl =
|
||||
typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
const apiUrl = `${baseUrl}/api/node/metrics?timeframe=${timeframe}`
|
||||
const result = await fetchApi<any>(`/api/node/metrics?timeframe=${timeframe}`)
|
||||
|
||||
console.log("[v0] Fetching node metrics from:", apiUrl)
|
||||
|
||||
const response = await fetch(apiUrl)
|
||||
|
||||
console.log("[v0] Response status:", response.status)
|
||||
console.log("[v0] Response ok:", response.ok)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.log("[v0] Error response text:", errorText)
|
||||
throw new Error(`Failed to fetch node metrics: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log("[v0] Node metrics result:", result)
|
||||
console.log("[v0] Result keys:", Object.keys(result))
|
||||
console.log("[v0] Data array length:", result.data?.length || 0)
|
||||
@@ -207,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
|
||||
@@ -318,15 +314,15 @@ export function NodeMetricsCharts() {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* CPU Usage + Load Average Chart */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardHeader className="px-4 md:px-6">
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<TrendingUp className="h-5 w-5 mr-2" />
|
||||
CPU Usage & Load Average
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="px-0 md:px-6">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 60, left: 30, right: 10 }}>
|
||||
<AreaChart data={data} margin={{ bottom: 60, left: 0, right: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
@@ -343,7 +339,9 @@ export function NodeMetricsCharts() {
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
label={{ value: "CPU %", angle: -90, position: "insideLeft", fill: "currentColor" }}
|
||||
label={
|
||||
isMobile ? undefined : { value: "CPU %", angle: -90, position: "insideLeft", fill: "currentColor" }
|
||||
}
|
||||
domain={[0, "dataMax"]}
|
||||
/>
|
||||
<YAxis
|
||||
@@ -352,7 +350,9 @@ export function NodeMetricsCharts() {
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
label={{ value: "Load", angle: 90, position: "insideRight", fill: "currentColor" }}
|
||||
label={
|
||||
isMobile ? undefined : { value: "Load", angle: 90, position: "insideRight", fill: "currentColor" }
|
||||
}
|
||||
domain={[0, "dataMax"]}
|
||||
/>
|
||||
<Tooltip content={<CustomCpuTooltip />} />
|
||||
@@ -386,15 +386,15 @@ export function NodeMetricsCharts() {
|
||||
|
||||
{/* Memory Usage Chart */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardHeader className="px-4 md:px-6">
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<MemoryStick className="h-5 w-5 mr-2" />
|
||||
Memory Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="px-0 pr-2 md:px-6">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 60, left: 30, right: 10 }}>
|
||||
<AreaChart data={data} margin={{ bottom: 60, left: 0, right: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
@@ -410,7 +410,9 @@ export function NodeMetricsCharts() {
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
label={{ value: "GB", angle: -90, position: "insideLeft", fill: "currentColor" }}
|
||||
label={
|
||||
isMobile ? undefined : { value: "GB", angle: -90, position: "insideLeft", fill: "currentColor" }
|
||||
}
|
||||
domain={[0, "dataMax"]}
|
||||
/>
|
||||
<Tooltip content={<CustomMemoryTooltip />} />
|
||||
@@ -435,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,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Rocket,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
|
||||
interface OnboardingSlide {
|
||||
id: number
|
||||
@@ -106,6 +107,7 @@ export function OnboardingCarousel() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [currentSlide, setCurrentSlide] = useState(0)
|
||||
const [direction, setDirection] = useState<"next" | "prev">("next")
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const hasSeenOnboarding = localStorage.getItem("proxmenux-onboarding-seen")
|
||||
@@ -119,6 +121,9 @@ export function OnboardingCarousel() {
|
||||
setDirection("next")
|
||||
setCurrentSlide(currentSlide + 1)
|
||||
} else {
|
||||
if (dontShowAgain) {
|
||||
localStorage.setItem("proxmenux-onboarding-seen", "true")
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
@@ -131,11 +136,16 @@ export function OnboardingCarousel() {
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
if (dontShowAgain) {
|
||||
localStorage.setItem("proxmenux-onboarding-seen", "true")
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleDontShowAgain = () => {
|
||||
localStorage.setItem("proxmenux-onboarding-seen", "true")
|
||||
const handleClose = () => {
|
||||
if (dontShowAgain) {
|
||||
localStorage.setItem("proxmenux-onboarding-seen", "true")
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
@@ -147,15 +157,15 @@ export function OnboardingCarousel() {
|
||||
const slide = slides[currentSlide]
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<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">
|
||||
{/* Close button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-4 right-4 z-50 h-8 w-8 rounded-full bg-background/80 backdrop-blur-sm hover:bg-background"
|
||||
onClick={handleSkip}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -166,7 +176,6 @@ export function OnboardingCarousel() {
|
||||
<div className="absolute inset-0 bg-black/10" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_120%,rgba(255,255,255,0.1),transparent)]" />
|
||||
|
||||
{/* Icon or Image */}
|
||||
<div className="relative z-10 text-white">
|
||||
{slide.image ? (
|
||||
<div className="relative w-full h-36 md:h-48 flex items-center justify-center px-4">
|
||||
@@ -192,20 +201,18 @@ export function OnboardingCarousel() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute top-10 left-10 w-20 h-20 bg-white/10 rounded-full blur-2xl" />
|
||||
<div className="absolute bottom-10 right-10 w-32 h-32 bg-white/10 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="p-4 md:p-8 space-y-4 md:space-y-6">
|
||||
<div className="p-4 md:p-8 space-y-3 md:space-y-6 max-h-[60vh] md:max-h-none overflow-y-auto">
|
||||
<div className="space-y-2 md:space-y-3">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-foreground text-balance">{slide.title}</h2>
|
||||
<p className="text-base md:text-lg text-muted-foreground leading-relaxed text-pretty">
|
||||
<h2 className="text-xl md:text-3xl font-bold text-foreground text-balance">{slide.title}</h2>
|
||||
<p className="text-sm md:text-lg text-muted-foreground leading-relaxed text-pretty">
|
||||
{slide.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress dots */}
|
||||
<div className="flex items-center justify-center gap-2 py-2 md:py-4">
|
||||
{slides.map((_, index) => (
|
||||
<button
|
||||
@@ -221,12 +228,12 @@ export function OnboardingCarousel() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 md:gap-4">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-2 md:gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handlePrev}
|
||||
disabled={currentSlide === 0}
|
||||
className="gap-2 w-full sm:w-auto"
|
||||
className="gap-2 w-full sm:w-auto text-sm"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
@@ -235,10 +242,17 @@ export function OnboardingCarousel() {
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
{currentSlide < slides.length - 1 ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleSkip} className="flex-1 sm:flex-none bg-transparent">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSkip}
|
||||
className="flex-1 sm:flex-none bg-transparent text-sm"
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button onClick={handleNext} className="gap-2 bg-blue-500 hover:bg-blue-600 flex-1 sm:flex-none">
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
className="gap-2 bg-blue-500 hover:bg-blue-600 flex-1 sm:flex-none text-sm"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -246,7 +260,7 @@ export function OnboardingCarousel() {
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
className="gap-2 bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 w-full sm:w-auto"
|
||||
className="gap-2 bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 w-full sm:w-auto text-sm"
|
||||
>
|
||||
Get Started!
|
||||
<Sparkles className="h-4 w-4" />
|
||||
@@ -255,17 +269,19 @@ export function OnboardingCarousel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Don't show again */}
|
||||
{currentSlide === slides.length - 1 && (
|
||||
<div className="text-center pt-2">
|
||||
<button
|
||||
onClick={handleDontShowAgain}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors underline"
|
||||
>
|
||||
Don't show again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-2 pt-2 pb-1">
|
||||
<Checkbox
|
||||
id="dont-show-again"
|
||||
checked={dontShowAgain}
|
||||
onCheckedChange={(checked) => setDontShowAgain(checked as boolean)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="dont-show-again"
|
||||
className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
|
||||
>
|
||||
Don't show this again
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -10,7 +10,13 @@ import { NetworkMetrics } from "./network-metrics"
|
||||
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,
|
||||
@@ -24,6 +30,10 @@ import {
|
||||
Box,
|
||||
Cpu,
|
||||
FileText,
|
||||
SettingsIcon,
|
||||
Terminal,
|
||||
ShieldCheck,
|
||||
Info,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { ThemeToggle } from "./theme-toggle"
|
||||
@@ -47,11 +57,20 @@ interface FlaskSystemData {
|
||||
load_average: number[]
|
||||
}
|
||||
|
||||
interface FlaskSystemInfo {
|
||||
hostname: string
|
||||
node_id: string
|
||||
uptime: string
|
||||
health: {
|
||||
status: "healthy" | "warning" | "critical"
|
||||
}
|
||||
}
|
||||
|
||||
export function ProxmoxDashboard() {
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
|
||||
status: "healthy",
|
||||
uptime: "Loading...",
|
||||
lastUpdate: new Date().toLocaleTimeString(),
|
||||
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||
serverName: "Loading...",
|
||||
nodeId: "Loading...",
|
||||
})
|
||||
@@ -60,57 +79,105 @@ 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 () => {
|
||||
console.log("[v0] Fetching system data from Flask server...")
|
||||
console.log("[v0] Current window location:", window.location.href)
|
||||
|
||||
const baseUrl = typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
const apiUrl = `${baseUrl}/api/system`
|
||||
|
||||
console.log("[v0] API URL:", apiUrl)
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
})
|
||||
console.log("[v0] Response status:", response.status)
|
||||
const data: FlaskSystemInfo = await fetchApi("/api/system-info")
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server responded with status: ${response.status}`)
|
||||
}
|
||||
const uptimeValue =
|
||||
data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : "N/A"
|
||||
|
||||
const data: FlaskSystemData = await response.json()
|
||||
console.log("[v0] System data received:", data)
|
||||
const backendStatus = data.health?.status?.toUpperCase() || "OK"
|
||||
let healthStatus: "healthy" | "warning" | "critical"
|
||||
|
||||
let status: "healthy" | "warning" | "critical" = "healthy"
|
||||
if (data.cpu_usage > 90 || data.memory_usage > 90) {
|
||||
status = "critical"
|
||||
} else if (data.cpu_usage > 75 || data.memory_usage > 75) {
|
||||
status = "warning"
|
||||
if (backendStatus === "CRITICAL") {
|
||||
healthStatus = "critical"
|
||||
} else if (backendStatus === "WARNING") {
|
||||
healthStatus = "warning"
|
||||
} else {
|
||||
healthStatus = "healthy"
|
||||
}
|
||||
|
||||
setSystemStatus({
|
||||
status,
|
||||
uptime: data.uptime,
|
||||
lastUpdate: new Date().toLocaleTimeString(),
|
||||
serverName: data.hostname,
|
||||
nodeId: data.node_id,
|
||||
status: healthStatus,
|
||||
uptime: uptimeValue,
|
||||
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||
serverName: data.hostname || "Unknown",
|
||||
nodeId: data.node_id || "Unknown",
|
||||
})
|
||||
setIsServerConnected(true)
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to fetch system data from Flask server:", error)
|
||||
console.error("[v0] Error details:", {
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
apiUrl,
|
||||
windowLocation: window.location.href,
|
||||
})
|
||||
// Expected to fail in v0 preview (no Flask server)
|
||||
|
||||
setIsServerConnected(false)
|
||||
setSystemStatus((prev) => ({
|
||||
@@ -119,16 +186,96 @@ export function ProxmoxDashboard() {
|
||||
serverName: "Server Offline",
|
||||
nodeId: "Server Offline",
|
||||
uptime: "N/A",
|
||||
lastUpdate: new Date().toLocaleTimeString(),
|
||||
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSystemData()
|
||||
const interval = setInterval(fetchSystemData, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [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, fetchHealthInfoCount, fetchUpdateStatus, activeTab])
|
||||
|
||||
useEffect(() => {
|
||||
const handleChangeTab = (event: CustomEvent) => {
|
||||
const { tab } = event.detail
|
||||
if (tab) {
|
||||
setActiveTab(tab)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("changeTab", handleChangeTab as EventListener)
|
||||
return () => {
|
||||
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, infoCount: newInfoCount } = event.detail
|
||||
let healthStatus: "healthy" | "warning" | "critical"
|
||||
|
||||
if (status === "CRITICAL") {
|
||||
healthStatus = "critical"
|
||||
} else if (status === "WARNING") {
|
||||
healthStatus = "warning"
|
||||
} else {
|
||||
healthStatus = "healthy"
|
||||
}
|
||||
|
||||
setSystemStatus((prev) => ({
|
||||
...prev,
|
||||
status: healthStatus,
|
||||
}))
|
||||
|
||||
// Update info count (INFO categories + dismissed items)
|
||||
if (typeof newInfoCount === "number") {
|
||||
setInfoCount(newInfoCount)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("healthStatusUpdated", handleHealthStatusUpdate as EventListener)
|
||||
return () => {
|
||||
window.removeEventListener("healthStatusUpdated", handleHealthStatusUpdate as EventListener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -212,8 +359,14 @@ export function ProxmoxDashboard() {
|
||||
return "VMs & LXCs"
|
||||
case "hardware":
|
||||
return "Hardware"
|
||||
case "terminal":
|
||||
return "Terminal"
|
||||
case "logs":
|
||||
return "System Logs"
|
||||
case "security":
|
||||
return "Security"
|
||||
case "settings":
|
||||
return "Settings"
|
||||
default:
|
||||
return "Navigation Menu"
|
||||
}
|
||||
@@ -222,6 +375,7 @@ export function ProxmoxDashboard() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<OnboardingCarousel />
|
||||
<ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />
|
||||
|
||||
{!isServerConnected && (
|
||||
<div className="bg-red-500/10 border-b border-red-500/20 px-6 py-3">
|
||||
@@ -235,13 +389,8 @@ export function ProxmoxDashboard() {
|
||||
<p>• The ProxMenux server should start automatically on port 8008</p>
|
||||
<p>
|
||||
• Try accessing:{" "}
|
||||
<a
|
||||
href={`http://${typeof window !== "undefined" ? window.location.host : "localhost:8008"}/api/health`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
http://{typeof window !== "undefined" ? window.location.host : "localhost:8008"}/api/health
|
||||
<a href={getApiUrl("/api/health")} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
{getApiUrl("/api/health")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -249,7 +398,10 @@ export function ProxmoxDashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<header className="border-b border-border bg-card sticky top-0 z-50 shadow-sm">
|
||||
<header
|
||||
className="border-b border-border bg-card sticky top-0 z-50 shadow-sm cursor-pointer hover:bg-accent/5 transition-colors"
|
||||
onClick={() => setShowHealthModal(true)}
|
||||
>
|
||||
<div className="container mx-auto px-4 md:px-6 py-4 md:py-4">
|
||||
{/* Logo and Title */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -257,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")
|
||||
@@ -294,17 +445,30 @@ 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}</div>
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Uptime: {systemStatus.uptime || "N/A"}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refreshData}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
refreshData()
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="border-border/50 bg-transparent hover:bg-secondary"
|
||||
>
|
||||
@@ -312,41 +476,61 @@ export function ProxmoxDashboard() {
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
<ThemeToggle />
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</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" size="sm" onClick={refreshData} disabled={isRefreshing} className="h-8 w-8 p-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
refreshData()
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="h-8 w-8 p-0 -mt-1"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
|
||||
<ThemeToggle />
|
||||
<div onClick={(e) => e.stopPropagation()} className="-mt-1">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Server Info */}
|
||||
<div className="lg:hidden mt-2 flex items-center justify-end text-xs text-muted-foreground">
|
||||
<span className="whitespace-nowrap">Uptime: {systemStatus.uptime}</span>
|
||||
<span className="whitespace-nowrap">Uptime: {systemStatus.uptime || "N/A"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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-6 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"
|
||||
@@ -383,10 +567,28 @@ 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"
|
||||
>
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||
<div className="md:hidden">
|
||||
<div className="lg:hidden">
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -491,6 +693,51 @@ 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={() => {
|
||||
setActiveTab("settings")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "settings"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<SettingsIcon className="h-5 w-5" />
|
||||
<span>Settings</span>
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -523,10 +770,22 @@ export function ProxmoxDashboard() {
|
||||
<TabsContent value="logs" className="space-y-4 md:space-y-6 mt-0">
|
||||
<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.0</p>
|
||||
<p className="font-medium mb-2">ProxMenux Monitor v1.2.0</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://ko-fi.com/macrimi"
|
||||
@@ -539,6 +798,8 @@ export function ProxmoxDashboard() {
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<HealthStatusModal open={showHealthModal} onOpenChange={setShowHealthModal} getApiUrl={getApiUrl} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
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.2.0" // Sync with AppImage/package.json
|
||||
|
||||
interface ReleaseNote {
|
||||
date: string
|
||||
changes: {
|
||||
added?: string[]
|
||||
changed?: string[]
|
||||
fixed?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
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: {
|
||||
added: [
|
||||
"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",
|
||||
"Two-Factor Authentication (2FA) - Enhanced security with TOTP support",
|
||||
"Health Monitoring System - Comprehensive system health checks with dismissible warnings",
|
||||
],
|
||||
changed: [
|
||||
"Optimized VM & LXC page - Reduced CPU usage by 85% through intelligent caching",
|
||||
"Storage metrics now separate local and remote storage for clarity",
|
||||
],
|
||||
fixed: [
|
||||
"Fixed dark mode text contrast issues in various components",
|
||||
"Corrected storage calculation discrepancies between Overview and Storage pages",
|
||||
],
|
||||
},
|
||||
},
|
||||
"1.0.0": {
|
||||
date: "October 15, 2025",
|
||||
changes: {
|
||||
added: [
|
||||
"Initial release of ProxMenux Monitor",
|
||||
"Real-time system monitoring dashboard",
|
||||
"Storage management with SMART health monitoring",
|
||||
"Network metrics and bandwidth tracking",
|
||||
"VM & LXC container management",
|
||||
"Hardware information display",
|
||||
"System logs viewer with filtering",
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const CURRENT_VERSION_FEATURES = [
|
||||
{
|
||||
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: "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 data fetching and reduced resource consumption",
|
||||
},
|
||||
]
|
||||
|
||||
interface ReleaseNotesModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false)
|
||||
|
||||
const handleClose = () => {
|
||||
if (dontShowAgain) {
|
||||
localStorage.setItem("proxmenux-last-seen-version", APP_VERSION)
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
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"
|
||||
size="icon"
|
||||
className="absolute top-4 right-4 z-50 h-8 w-8 rounded-full bg-background/80 backdrop-blur-sm hover:bg-background"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="relative h-32 md:h-40 bg-gradient-to-br from-amber-500 via-orange-500 to-red-500 flex items-center justify-center overflow-hidden flex-shrink-0">
|
||||
<div className="absolute inset-0 bg-black/10" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_120%,rgba(255,255,255,0.1),transparent)]" />
|
||||
|
||||
<div className="relative z-10 text-white animate-pulse">
|
||||
<Sparkles className="h-12 w-12 md:h-14 md:w-14" />
|
||||
</div>
|
||||
|
||||
<div className="absolute top-10 left-10 w-20 h-20 bg-white/10 rounded-full blur-2xl" />
|
||||
<div className="absolute bottom-10 right-10 w-32 h-32 bg-white/10 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 md:p-8 space-y-4 md:space-y-6 min-h-0">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-foreground text-balance">
|
||||
What's New in Version {APP_VERSION}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
We've added exciting new features and improvements to make ProxMenux Monitor even better!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{CURRENT_VERSION_FEATURES.map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-2 md:gap-3 p-3 rounded-lg bg-muted/50 border border-border/50 hover:bg-muted/70 transition-colors"
|
||||
>
|
||||
<div className="text-orange-500 mt-0.5 flex-shrink-0">{feature.icon}</div>
|
||||
<p className="text-xs md:text-sm text-foreground leading-relaxed">{feature.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 p-6 md:p-8 pt-4 border-t border-border/50 bg-card">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Got it!
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Checkbox
|
||||
id="dont-show-version-again"
|
||||
checked={dontShowAgain}
|
||||
onCheckedChange={(checked) => setDontShowAgain(checked as boolean)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="dont-show-version-again"
|
||||
className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
|
||||
>
|
||||
Don't show again for this version
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function useVersionCheck() {
|
||||
const [showReleaseNotes, setShowReleaseNotes] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const lastSeenVersion = localStorage.getItem("proxmenux-last-seen-version")
|
||||
|
||||
if (lastSeenVersion !== APP_VERSION) {
|
||||
setShowReleaseNotes(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { showReleaseNotes, setShowReleaseNotes }
|
||||
}
|
||||
|
||||
export { APP_VERSION }
|
||||
@@ -0,0 +1,950 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Loader2,
|
||||
Activity,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CornerDownLeft,
|
||||
GripHorizontal,
|
||||
ChevronDown,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import "xterm/css/xterm.css"
|
||||
import { API_PORT } from "@/lib/api-config"
|
||||
|
||||
interface WebInteraction {
|
||||
type: "yesno" | "menu" | "msgbox" | "input" | "inputbox"
|
||||
id: string
|
||||
title: string
|
||||
message: string
|
||||
options?: Array<{ label: string; value: string }>
|
||||
default?: string
|
||||
}
|
||||
|
||||
interface ScriptTerminalModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
scriptPath: string
|
||||
title: string
|
||||
description: string
|
||||
scriptName?: string
|
||||
params?: Record<string, string>
|
||||
}
|
||||
|
||||
export function ScriptTerminalModal({
|
||||
open: isOpen,
|
||||
onClose,
|
||||
scriptPath,
|
||||
title,
|
||||
description,
|
||||
params = { EXECUTION_MODE: "web" },
|
||||
}: ScriptTerminalModalProps) {
|
||||
const termRef = useRef<any>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const fitAddonRef = useRef<any>(null)
|
||||
const sessionIdRef = useRef<string>(Math.random().toString(36).substring(2, 8))
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = useState<"connecting" | "online" | "offline">("connecting")
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [currentInteraction, setCurrentInteraction] = useState<WebInteraction | null>(null)
|
||||
const [interactionInput, setInteractionInput] = useState("")
|
||||
const checkConnectionInterval = useRef<NodeJS.Timeout | null>(null)
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const reconnectAttemptsRef = useRef(0)
|
||||
const keepAliveIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isTablet, setIsTablet] = useState(false)
|
||||
|
||||
const [isWaitingNextInteraction, setIsWaitingNextInteraction] = useState(false)
|
||||
const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const [modalHeight, setModalHeight] = useState(600)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const resizeBarRef = useRef<HTMLDivElement>(null)
|
||||
const modalHeightRef = useRef(600)
|
||||
|
||||
const terminalContainerRef = useRef<HTMLDivElement>(null)
|
||||
const paramsRef = useRef(params)
|
||||
|
||||
// Keep paramsRef updated with latest params
|
||||
useEffect(() => {
|
||||
paramsRef.current = params
|
||||
}, [params])
|
||||
|
||||
const attemptReconnect = useCallback(() => {
|
||||
if (!isOpen || isComplete || reconnectAttemptsRef.current >= 3) {
|
||||
return
|
||||
}
|
||||
|
||||
reconnectAttemptsRef.current++
|
||||
setConnectionStatus("connecting")
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
if (wsRef.current?.readyState !== WebSocket.OPEN && termRef.current) {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
|
||||
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
|
||||
const ws = new WebSocket(wsUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnectionStatus("online")
|
||||
reconnectAttemptsRef.current = 0
|
||||
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
}
|
||||
keepAliveIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }))
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
const initMessage = {
|
||||
script_path: scriptPath,
|
||||
params: paramsRef.current,
|
||||
}
|
||||
ws.send(JSON.stringify(initMessage))
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddonRef.current && termRef.current && ws.readyState === WebSocket.OPEN) {
|
||||
const cols = termRef.current.cols
|
||||
const rows = termRef.current.rows
|
||||
ws.send(JSON.stringify({ type: "resize", cols, rows }))
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Filter out pong responses from heartbeat
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === "web_interaction" && msg.interaction) {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
setCurrentInteraction({
|
||||
type: msg.interaction.type,
|
||||
id: msg.interaction.id,
|
||||
title: msg.interaction.title || "",
|
||||
message: msg.interaction.message || "",
|
||||
options: msg.interaction.options,
|
||||
default: msg.interaction.default,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (msg.type === "error") {
|
||||
termRef.current?.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
termRef.current?.write(event.data)
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnectionStatus("offline")
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
setConnectionStatus("offline")
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
keepAliveIntervalRef.current = null
|
||||
}
|
||||
if (!isComplete && reconnectAttemptsRef.current < 3) {
|
||||
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
|
||||
} else {
|
||||
setIsComplete(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}, [isOpen, isComplete, scriptPath])
|
||||
|
||||
const sendKey = useCallback((key: string) => {
|
||||
if (!termRef.current) return
|
||||
|
||||
const keyMap: Record<string, string> = {
|
||||
escape: "\x1b",
|
||||
tab: "\t",
|
||||
up: "\x1bOA",
|
||||
down: "\x1bOB",
|
||||
left: "\x1bOD",
|
||||
right: "\x1bOC",
|
||||
enter: "\r",
|
||||
ctrlc: "\x03",
|
||||
}
|
||||
|
||||
const sequence = keyMap[key]
|
||||
if (sequence && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(sequence)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const initializeTerminal = async () => {
|
||||
const [TerminalClass, FitAddonClass] = await Promise.all([
|
||||
import("xterm").then((mod) => mod.Terminal),
|
||||
import("xterm-addon-fit").then((mod) => mod.FitAddon),
|
||||
import("xterm/css/xterm.css"),
|
||||
])
|
||||
|
||||
const fontSize = window.innerWidth < 768 ? 12 : 16
|
||||
|
||||
const term = new TerminalClass({
|
||||
rendererType: "dom",
|
||||
fontFamily: '"Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace',
|
||||
fontSize: fontSize,
|
||||
lineHeight: 1,
|
||||
cursorBlink: true,
|
||||
scrollback: 2000,
|
||||
disableStdin: false,
|
||||
customGlyphs: true,
|
||||
fontWeight: "500",
|
||||
fontWeightBold: "700",
|
||||
theme: {
|
||||
background: "#000000",
|
||||
foreground: "#ffffff",
|
||||
cursor: "#ffffff",
|
||||
cursorAccent: "#000000",
|
||||
black: "#2e3436",
|
||||
red: "#cc0000",
|
||||
green: "#4e9a06",
|
||||
yellow: "#c4a000",
|
||||
blue: "#3465a4",
|
||||
magenta: "#75507b",
|
||||
cyan: "#06989a",
|
||||
white: "#d3d7cf",
|
||||
brightBlack: "#555753",
|
||||
brightRed: "#ef2929",
|
||||
brightGreen: "#8ae234",
|
||||
brightYellow: "#fce94f",
|
||||
brightBlue: "#729fcf",
|
||||
brightMagenta: "#ad7fa8",
|
||||
brightCyan: "#34e2e2",
|
||||
brightWhite: "#eeeeec",
|
||||
},
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddonClass()
|
||||
term.loadAddon(fitAddon)
|
||||
if (terminalContainerRef.current) {
|
||||
term.open(terminalContainerRef.current)
|
||||
}
|
||||
|
||||
termRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddonRef.current && termRef.current) {
|
||||
fitAddonRef.current.fit()
|
||||
}
|
||||
}, 100)
|
||||
|
||||
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
|
||||
const ws = new WebSocket(wsUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnectionStatus("online")
|
||||
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
}
|
||||
keepAliveIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }))
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
const initMessage = {
|
||||
script_path: scriptPath,
|
||||
params: paramsRef.current,
|
||||
}
|
||||
ws.send(JSON.stringify(initMessage))
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddonRef.current && termRef.current && ws.readyState === WebSocket.OPEN) {
|
||||
const cols = termRef.current.cols
|
||||
const rows = termRef.current.rows
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Filter out pong responses from heartbeat - don't display in terminal
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
|
||||
if (msg.type === "web_interaction" && msg.interaction) {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
setCurrentInteraction({
|
||||
type: msg.interaction.type,
|
||||
id: msg.interaction.id,
|
||||
title: msg.interaction.title || "",
|
||||
message: msg.interaction.message || "",
|
||||
options: msg.interaction.options,
|
||||
default: msg.interaction.default,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === "error") {
|
||||
term.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, es output normal de terminal
|
||||
}
|
||||
|
||||
term.write(event.data)
|
||||
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
setConnectionStatus("offline")
|
||||
term.writeln("\x1b[31mWebSocket error occurred\x1b[0m")
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
setConnectionStatus("offline")
|
||||
term.writeln("\x1b[33mConnection closed\x1b[0m")
|
||||
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
keepAliveIntervalRef.current = null
|
||||
}
|
||||
|
||||
if (!isComplete) {
|
||||
setIsComplete(true)
|
||||
}
|
||||
}
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data)
|
||||
}
|
||||
})
|
||||
|
||||
checkConnectionInterval.current = setInterval(() => {
|
||||
if (wsRef.current) {
|
||||
setConnectionStatus(
|
||||
wsRef.current.readyState === WebSocket.OPEN
|
||||
? "online"
|
||||
: wsRef.current.readyState === WebSocket.CONNECTING
|
||||
? "connecting"
|
||||
: "offline",
|
||||
)
|
||||
}
|
||||
}, 500)
|
||||
|
||||
let resizeTimeout: NodeJS.Timeout | null = null
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeTimeout) clearTimeout(resizeTimeout)
|
||||
resizeTimeout = setTimeout(() => {
|
||||
if (fitAddonRef.current && termRef.current && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
fitAddonRef.current.fit()
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
cols: termRef.current.cols,
|
||||
rows: termRef.current.rows,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
|
||||
if (terminalContainerRef.current) {
|
||||
resizeObserver.observe(terminalContainerRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const savedHeight = localStorage.getItem("scriptModalHeight")
|
||||
if (savedHeight) {
|
||||
const height = Number.parseInt(savedHeight, 10)
|
||||
setModalHeight(height)
|
||||
modalHeightRef.current = height
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
initializeTerminal()
|
||||
} else {
|
||||
if (checkConnectionInterval.current) {
|
||||
clearInterval(checkConnectionInterval.current)
|
||||
}
|
||||
if (waitingTimeoutRef.current) {
|
||||
clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
if (termRef.current) {
|
||||
termRef.current.dispose()
|
||||
termRef.current = null
|
||||
}
|
||||
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
keepAliveIntervalRef.current = null
|
||||
}
|
||||
|
||||
sessionIdRef.current = Math.random().toString(36).substring(2, 8)
|
||||
reconnectAttemptsRef.current = 0
|
||||
setIsComplete(false)
|
||||
setInteractionInput("")
|
||||
setCurrentInteraction(null)
|
||||
setIsWaitingNextInteraction(false)
|
||||
setConnectionStatus("connecting")
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const updateDeviceType = () => {
|
||||
const width = window.innerWidth
|
||||
const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0
|
||||
const isTabletSize = width >= 768 && width <= 1366
|
||||
|
||||
setIsMobile(width < 768)
|
||||
setIsTablet(isTouchDevice && isTabletSize)
|
||||
}
|
||||
|
||||
updateDeviceType()
|
||||
const handleResize = () => updateDeviceType()
|
||||
window.addEventListener("resize", handleResize)
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (!document.hidden && isOpen) {
|
||||
if (wsRef.current?.readyState !== WebSocket.OPEN && !isComplete) {
|
||||
attemptReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
if (isOpen && wsRef.current?.readyState !== WebSocket.OPEN && !isComplete) {
|
||||
attemptReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
let wakeLock: any = null
|
||||
const requestWakeLock = async () => {
|
||||
if ("wakeLock" in navigator && isOpen) {
|
||||
try {
|
||||
wakeLock = await (navigator as any).wakeLock.request("screen")
|
||||
} catch (err) {
|
||||
// Wake Lock no soportado o denegado, continuar sin él
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestWakeLock()
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||
window.addEventListener("focus", handleFocus)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize)
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||
window.removeEventListener("focus", handleFocus)
|
||||
if (wakeLock) {
|
||||
wakeLock.release().catch(() => {})
|
||||
}
|
||||
}
|
||||
}, [isOpen, isComplete, attemptReconnect])
|
||||
|
||||
const getScriptWebSocketUrl = (sid: string): string => {
|
||||
if (typeof window === "undefined") {
|
||||
return `ws://localhost:${API_PORT}/ws/script/${sid}`
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location
|
||||
const isStandardPort = port === "" || port === "80" || port === "443"
|
||||
const wsProtocol = protocol === "https:" ? "wss:" : "ws:"
|
||||
|
||||
if (isStandardPort) {
|
||||
return `${wsProtocol}//${hostname}/ws/script/${sid}`
|
||||
} else {
|
||||
return `${wsProtocol}//${hostname}:${API_PORT}/ws/script/${sid}`
|
||||
}
|
||||
}
|
||||
|
||||
const handleInteractionResponse = (value: string) => {
|
||||
if (!wsRef.current || !currentInteraction) {
|
||||
return
|
||||
}
|
||||
|
||||
if (value === "cancel" || value === "") {
|
||||
setCurrentInteraction(null)
|
||||
setInteractionInput("")
|
||||
handleCloseModal()
|
||||
return
|
||||
}
|
||||
|
||||
const response = JSON.stringify({
|
||||
type: "interaction_response",
|
||||
id: currentInteraction.id,
|
||||
value: value,
|
||||
})
|
||||
|
||||
if (wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(response)
|
||||
}
|
||||
|
||||
setCurrentInteraction(null)
|
||||
setInteractionInput("")
|
||||
|
||||
waitingTimeoutRef.current = setTimeout(() => {
|
||||
setIsWaitingNextInteraction(true)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const handleCloseModal = () => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
if (checkConnectionInterval.current) {
|
||||
clearInterval(checkConnectionInterval.current)
|
||||
}
|
||||
if (termRef.current) {
|
||||
termRef.current.dispose()
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
setIsResizing(true)
|
||||
|
||||
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY
|
||||
const startY = clientY
|
||||
const startHeight = modalHeight
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent | TouchEvent) => {
|
||||
const currentY = "touches" in moveEvent ? moveEvent.touches[0].clientY : moveEvent.clientY
|
||||
const deltaY = currentY - startY
|
||||
const newHeight = Math.max(300, Math.min(window.innerHeight - 50, startHeight + deltaY))
|
||||
|
||||
modalHeightRef.current = newHeight
|
||||
setModalHeight(newHeight)
|
||||
}
|
||||
|
||||
const handleEnd = () => {
|
||||
const finalHeight = modalHeightRef.current
|
||||
setIsResizing(false)
|
||||
|
||||
document.removeEventListener("mousemove", handleMove as any)
|
||||
document.removeEventListener("mouseup", handleEnd)
|
||||
document.removeEventListener("touchmove", handleMove as any)
|
||||
document.removeEventListener("touchend", handleEnd)
|
||||
document.removeEventListener("touchcancel", handleEnd)
|
||||
|
||||
localStorage.setItem("scriptModalHeight", finalHeight.toString())
|
||||
|
||||
if (fitAddonRef.current && termRef.current && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit()
|
||||
wsRef.current?.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
cols: termRef.current.cols,
|
||||
rows: termRef.current.rows,
|
||||
}),
|
||||
)
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMove as any)
|
||||
document.addEventListener("mouseup", handleEnd)
|
||||
document.addEventListener("touchmove", handleMove as any, { passive: false })
|
||||
document.addEventListener("touchend", handleEnd)
|
||||
document.addEventListener("touchcancel", handleEnd)
|
||||
}
|
||||
|
||||
const sendCommand = (command: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(command)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className="max-w-7xl p-0 flex flex-col gap-0 overflow-hidden"
|
||||
style={{
|
||||
height: isMobile ? "80vh" : `${modalHeight}px`,
|
||||
maxHeight: "none",
|
||||
}}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
hideClose
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
|
||||
<div className="flex items-center gap-2 p-4 border-b">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden relative flex-1">
|
||||
<div ref={terminalContainerRef} className="w-full h-full" />
|
||||
|
||||
{isWaitingNextInteraction && !currentInteraction && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="text-sm text-muted-foreground">Processing...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<div
|
||||
ref={resizeBarRef}
|
||||
onMouseDown={handleResizeStart}
|
||||
onTouchStart={handleResizeStart}
|
||||
className={`h-4 w-full cursor-row-resize transition-colors flex items-center justify-center group relative ${
|
||||
isResizing ? "bg-blue-500" : "bg-zinc-800 hover:bg-blue-600"
|
||||
}`}
|
||||
style={{ touchAction: "none" }}
|
||||
>
|
||||
<GripHorizontal
|
||||
className={`h-5 w-5 transition-colors pointer-events-none ${
|
||||
isResizing ? "text-white" : "text-zinc-600 group-hover:text-white"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isMobile || isTablet) && (
|
||||
<div className="flex items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\x1b")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white min-w-[50px]"
|
||||
>
|
||||
ESC
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\t")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white min-w-[50px]"
|
||||
>
|
||||
TAB
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\x1bOA")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\x1bOB")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\x1bOD")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\x1bOC")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\r")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400"
|
||||
>
|
||||
<CornerDownLeft className="h-4 w-4 mr-1" />
|
||||
Enter
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white min-w-[65px] gap-1"
|
||||
>
|
||||
Ctrl
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => sendCommand("\x03")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendCommand("\x18")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||
<span className="text-muted-foreground text-xs">Exit (nano)</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => sendCommand("\x12")}>
|
||||
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
||||
<span className="text-muted-foreground text-xs">Search history</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-5 w-5 text-blue-500" />
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
connectionStatus === "online"
|
||||
? "bg-green-500"
|
||||
: connectionStatus === "connecting"
|
||||
? "bg-blue-500"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
title={
|
||||
connectionStatus === "online"
|
||||
? "Connected"
|
||||
: connectionStatus === "connecting"
|
||||
? "Connecting"
|
||||
: "Disconnected"
|
||||
}
|
||||
></div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{connectionStatus === "online"
|
||||
? "Online"
|
||||
: connectionStatus === "connecting"
|
||||
? "Connecting..."
|
||||
: "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleCloseModal}
|
||||
variant="outline"
|
||||
className="bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{currentInteraction && (
|
||||
<Dialog open={true}>
|
||||
<DialogContent
|
||||
className="max-w-4xl max-h-[80vh] overflow-y-auto animate-in fade-in-0 zoom-in-95 duration-100"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
hideClose
|
||||
>
|
||||
<DialogTitle>{currentInteraction.title}</DialogTitle>
|
||||
<div className="space-y-4">
|
||||
<p
|
||||
className="whitespace-pre-wrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: currentInteraction.message.replace(/\\n/g, "<br/>").replace(/\n/g, "<br/>"),
|
||||
}}
|
||||
/>
|
||||
|
||||
{currentInteraction.type === "yesno" && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("yes")}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white transition-all duration-150"
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("cancel")}
|
||||
variant="outline"
|
||||
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentInteraction.type === "menu" && currentInteraction.options && (
|
||||
<div className="space-y-2">
|
||||
{currentInteraction.options.map((option, index) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
onClick={() => handleInteractionResponse(option.value)}
|
||||
variant="outline"
|
||||
className="w-full justify-start hover:bg-blue-600 hover:text-white transition-all duration-100 animate-in fade-in-0 slide-in-from-left-2"
|
||||
style={{ animationDelay: `${index * 30}ms` }}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("cancel")}
|
||||
variant="outline"
|
||||
className="w-full hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(currentInteraction.type === "input" || currentInteraction.type === "inputbox") && (
|
||||
<div className="space-y-2">
|
||||
<Label>Your input:</Label>
|
||||
<Input
|
||||
value={interactionInput}
|
||||
onChange={(e) => setInteractionInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleInteractionResponse(interactionInput)
|
||||
}
|
||||
}}
|
||||
placeholder={currentInteraction.default || ""}
|
||||
className="transition-all duration-150"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse(interactionInput)}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("cancel")}
|
||||
variant="outline"
|
||||
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentInteraction.type === "msgbox" && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("ok")}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("cancel")}
|
||||
variant="outline"
|
||||
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,125 @@
|
||||
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText } from "path-to-icons"
|
||||
"use client"
|
||||
|
||||
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon, Terminal } from "lucide-react"
|
||||
|
||||
const menuItems = [
|
||||
{ name: "Overview", href: "/", icon: LayoutDashboard },
|
||||
{ name: "Storage", href: "/storage", icon: HardDrive },
|
||||
{ name: "Network", href: "/network", icon: Network },
|
||||
{ name: "Virtual Machines", href: "/virtual-machines", icon: Server },
|
||||
{ name: "Hardware", href: "/hardware", icon: Cpu }, // New Hardware section
|
||||
{ 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 },
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { HardDrive, Database, Archive, AlertTriangle, CheckCircle, Activity, AlertCircle } from "lucide-react"
|
||||
import { formatStorage } from "@/lib/utils"
|
||||
|
||||
interface StorageData {
|
||||
total: number
|
||||
@@ -116,10 +117,10 @@ export function StorageMetrics() {
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{storageData.total.toFixed(1)} GB</div>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{formatStorage(storageData.total)}</div>
|
||||
<Progress value={usagePercent} className="mt-2" />
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{storageData.used.toFixed(1)} GB used • {storageData.available.toFixed(1)} GB available
|
||||
{formatStorage(storageData.used)} used • {formatStorage(storageData.available)} available
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -130,7 +131,7 @@ export function StorageMetrics() {
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{storageData.used.toFixed(1)} GB</div>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{formatStorage(storageData.used)}</div>
|
||||
<Progress value={usagePercent} className="mt-2" />
|
||||
<p className="text-xs text-muted-foreground mt-2">{usagePercent.toFixed(1)}% of total space</p>
|
||||
</CardContent>
|
||||
@@ -144,7 +145,7 @@ export function StorageMetrics() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{storageData.available.toFixed(1)} GB</div>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{formatStorage(storageData.available)}</div>
|
||||
<div className="flex items-center mt-2">
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{((storageData.available / storageData.total) * 100).toFixed(1)}% Free
|
||||
@@ -201,7 +202,7 @@ export function StorageMetrics() {
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{disk.used.toFixed(1)} GB / {disk.total.toFixed(1)} GB
|
||||
{formatStorage(disk.used)} / {formatStorage(disk.total)}
|
||||
</div>
|
||||
<Progress value={disk.usage_percent} className="w-24 mt-1" />
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+281
-386
@@ -27,17 +27,8 @@ import {
|
||||
Menu,
|
||||
Terminal,
|
||||
} from "lucide-react"
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
interface Log {
|
||||
timestamp: string
|
||||
level: string
|
||||
service: string
|
||||
message: string
|
||||
source: string
|
||||
pid?: string
|
||||
hostname?: string
|
||||
}
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import { API_PORT, fetchApi } from "@/lib/api-config"
|
||||
|
||||
interface Backup {
|
||||
volid: string
|
||||
@@ -75,6 +66,7 @@ interface SystemLog {
|
||||
timestamp: string
|
||||
level: string
|
||||
service: string
|
||||
unit?: string
|
||||
message: string
|
||||
source: string
|
||||
pid?: string
|
||||
@@ -85,6 +77,7 @@ interface CombinedLogEntry {
|
||||
timestamp: string
|
||||
level: string
|
||||
service: string
|
||||
unit?: string
|
||||
message: string
|
||||
source: string
|
||||
pid?: string
|
||||
@@ -107,177 +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") {
|
||||
return `${window.location.protocol}//${window.location.hostname}:8008${endpoint}`
|
||||
}
|
||||
return `http://localhost:8008${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(),
|
||||
fetch(getApiUrl("/api/backups")),
|
||||
fetch(getApiUrl("/api/events?limit=50")),
|
||||
fetch(getApiUrl("/api/notifications")),
|
||||
])
|
||||
|
||||
setLogs(logsRes)
|
||||
|
||||
if (backupsRes.ok) {
|
||||
const backupsData = await backupsRes.json()
|
||||
setBackups(backupsData.backups || [])
|
||||
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)
|
||||
}
|
||||
|
||||
if (eventsRes.ok) {
|
||||
const eventsData = await eventsRes.json()
|
||||
setEvents(eventsData.events || [])
|
||||
}
|
||||
|
||||
if (notificationsRes.ok) {
|
||||
const notificationsData = await notificationsRes.json()
|
||||
setNotifications(notificationsData.notifications || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[v0] Error fetching system logs data:", err)
|
||||
setError("Failed to connect to server")
|
||||
} finally {
|
||||
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 = getApiUrl("/api/logs")
|
||||
const params = new URLSearchParams()
|
||||
|
||||
// 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 response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(30000), // 30 second timeout
|
||||
})
|
||||
|
||||
console.log("[v0] Response status:", response.status, "OK:", response.ok)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Flask server responded with status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log("[v0] Received logs data, count:", data.logs?.length || 0)
|
||||
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}`
|
||||
|
||||
const data = await fetchApi(apiUrl)
|
||||
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 []
|
||||
}
|
||||
}
|
||||
@@ -286,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`)
|
||||
|
||||
@@ -309,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"}`,
|
||||
@@ -356,39 +244,34 @@ export function SystemLogs() {
|
||||
if (upid) {
|
||||
// Try to fetch the complete task log from Proxmox
|
||||
try {
|
||||
const response = await fetch(getApiUrl(`/api/task-log/${encodeURIComponent(upid)}`))
|
||||
const taskLog = await fetchApi(`/api/task-log/${encodeURIComponent(upid)}`, {}, "text")
|
||||
|
||||
if (response.ok) {
|
||||
const taskLog = await response.text()
|
||||
// Download the complete task log
|
||||
const blob = new Blob(
|
||||
[
|
||||
`Proxmox Task Log\n`,
|
||||
`================\n\n`,
|
||||
`UPID: ${upid}\n`,
|
||||
`Timestamp: ${notification.timestamp}\n`,
|
||||
`Service: ${notification.service}\n`,
|
||||
`Source: ${notification.source}\n\n`,
|
||||
`Complete Task Log:\n`,
|
||||
`${"-".repeat(80)}\n`,
|
||||
`${taskLog}\n`,
|
||||
],
|
||||
{ type: "text/plain" },
|
||||
)
|
||||
|
||||
// Download the complete task log
|
||||
const blob = new Blob(
|
||||
[
|
||||
`Proxmox Task Log\n`,
|
||||
`================\n\n`,
|
||||
`UPID: ${upid}\n`,
|
||||
`Timestamp: ${notification.timestamp}\n`,
|
||||
`Service: ${notification.service}\n`,
|
||||
`Source: ${notification.source}\n\n`,
|
||||
`Complete Task Log:\n`,
|
||||
`${"-".repeat(80)}\n`,
|
||||
`${taskLog}\n`,
|
||||
],
|
||||
{ type: "text/plain" },
|
||||
)
|
||||
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `task_log_${upid.replace(/:/g, "_")}_${notification.timestamp.replace(/[:\s]/g, "_")}.txt`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to fetch task log from Proxmox:", error)
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `task_log_${upid.replace(/:/g, "_")}_${notification.timestamp.replace(/[:\s]/g, "_")}.txt`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
return
|
||||
} catch {
|
||||
// Fall through to download notification message
|
||||
}
|
||||
}
|
||||
@@ -416,79 +299,53 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
const logsOnly: CombinedLogEntry[] = logs
|
||||
.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() }))
|
||||
.sort((a, b) => b.sortTimestamp - a.sortTimestamp)
|
||||
const safeToLowerCase = (value: any): string => {
|
||||
if (value === null || value === undefined) return ""
|
||||
return String(value).toLowerCase()
|
||||
}
|
||||
|
||||
const eventsOnly: CombinedLogEntry[] = events
|
||||
.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)
|
||||
const combinedLogs: CombinedLogEntry[] = useMemo(
|
||||
() =>
|
||||
[
|
||||
...logs.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() })),
|
||||
...events.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),
|
||||
[logs, events],
|
||||
)
|
||||
|
||||
// Filter logs only
|
||||
const filteredLogsOnly = logsOnly.filter((log) => {
|
||||
const matchesSearch =
|
||||
log.message.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
log.service.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const matchesLevel = levelFilter === "all" || log.level === levelFilter
|
||||
const matchesService = serviceFilter === "all" || log.service === serviceFilter
|
||||
const filteredCombinedLogs = useMemo(
|
||||
() =>
|
||||
combinedLogs.filter((log) => {
|
||||
const searchTermLower = safeToLowerCase(searchTerm)
|
||||
|
||||
return matchesSearch && matchesLevel && matchesService
|
||||
})
|
||||
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
|
||||
|
||||
// Filter events only
|
||||
const filteredEventsOnly = eventsOnly.filter((event) => {
|
||||
const matchesSearch =
|
||||
event.message.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
event.service.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const matchesLevel = levelFilter === "all" || event.level === levelFilter
|
||||
const matchesService = serviceFilter === "all" || event.service === serviceFilter
|
||||
return matchesSearch && matchesLevel && matchesService
|
||||
}),
|
||||
[combinedLogs, searchTerm, levelFilter, serviceFilter],
|
||||
)
|
||||
|
||||
return matchesSearch && matchesLevel && matchesService
|
||||
})
|
||||
|
||||
const displayedLogsOnly = filteredLogsOnly.slice(0, displayedLogsCount)
|
||||
const displayedEventsOnly = filteredEventsOnly.slice(0, displayedLogsCount)
|
||||
|
||||
const combinedLogs: CombinedLogEntry[] = [
|
||||
...logs.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() })),
|
||||
...events.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) // Sort by timestamp descending
|
||||
|
||||
// Filter combined logs
|
||||
const filteredCombinedLogs = combinedLogs.filter((log) => {
|
||||
const matchesSearch =
|
||||
log.message.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
log.service.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const matchesLevel = levelFilter === "all" || log.level === levelFilter
|
||||
const matchesService = serviceFilter === "all" || log.service === serviceFilter
|
||||
|
||||
return matchesSearch && matchesLevel && matchesService
|
||||
})
|
||||
|
||||
// CHANGE: Re-assigning displayedLogs to use the filteredCombinedLogs
|
||||
const displayedLogs = filteredCombinedLogs.slice(0, displayedLogsCount)
|
||||
const hasMoreLogs = displayedLogsCount < filteredCombinedLogs.length
|
||||
|
||||
@@ -548,7 +405,9 @@ export function SystemLogs() {
|
||||
}
|
||||
|
||||
const getNotificationTypeColor = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
if (!type) return "bg-gray-500/10 text-gray-500 border-gray-500/20"
|
||||
|
||||
switch (safeToLowerCase(type)) {
|
||||
case "error":
|
||||
return "bg-red-500/10 text-red-500 border-red-500/20"
|
||||
case "warning":
|
||||
@@ -562,9 +421,10 @@ export function SystemLogs() {
|
||||
}
|
||||
}
|
||||
|
||||
// ADDED: New function for notification source colors
|
||||
const getNotificationSourceColor = (source: string) => {
|
||||
switch (source.toLowerCase()) {
|
||||
if (!source) return "bg-gray-500/10 text-gray-500 border-gray-500/20"
|
||||
|
||||
switch (safeToLowerCase(source)) {
|
||||
case "task-log":
|
||||
return "bg-purple-500/10 text-purple-500 border-purple-500/20"
|
||||
case "journal":
|
||||
@@ -583,7 +443,10 @@ export function SystemLogs() {
|
||||
info: logs.filter((log) => ["info", "notice", "debug"].includes(log.level)).length,
|
||||
}
|
||||
|
||||
const uniqueServices = [...new Set(logs.map((log) => log.service))]
|
||||
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")) {
|
||||
@@ -678,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>
|
||||
)}
|
||||
@@ -746,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" />
|
||||
@@ -858,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"
|
||||
@@ -908,9 +777,11 @@ export function SystemLogs() {
|
||||
<SelectValue placeholder="Filter by service" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Services</SelectItem>
|
||||
{uniqueServices.slice(0, 20).map((service) => (
|
||||
<SelectItem key={service} value={service}>
|
||||
<SelectItem key="service-all" value="all">
|
||||
All Services
|
||||
</SelectItem>
|
||||
{uniqueServices.map((service) => (
|
||||
<SelectItem key={`service-${service}`} value={service}>
|
||||
{service}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -923,53 +794,62 @@ 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">
|
||||
{displayedLogs.map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
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"
|
||||
onClick={() => {
|
||||
if (log.eventData) {
|
||||
setSelectedEvent(log.eventData)
|
||||
setIsEventModalOpen(true)
|
||||
} else {
|
||||
setSelectedLog(log as SystemLog)
|
||||
setIsLogModalOpen(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex-shrink-0 flex gap-2 flex-wrap">
|
||||
<Badge variant="outline" className={getLevelColor(log.level)}>
|
||||
{getLevelIcon(log.level)}
|
||||
{log.level.toUpperCase()}
|
||||
</Badge>
|
||||
{log.eventData && (
|
||||
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
|
||||
<Activity className="h-3 w-3 mr-1" />
|
||||
EVENT
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<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()
|
||||
const uniqueKey = log.eventData
|
||||
? `event-${log.eventData.upid.replace(/:/g, "-")}-${timestampMs}`
|
||||
: `log-${timestampMs}-${log.service?.substring(0, 10) || "unknown"}-${log.pid || "nopid"}-${index}`
|
||||
|
||||
<div className="flex-1 min-w-0 overflow-hidden box-border">
|
||||
<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}
|
||||
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 w-full max-w-full min-w-0"
|
||||
onClick={() => {
|
||||
if (log.eventData) {
|
||||
setSelectedEvent(log.eventData)
|
||||
setIsEventModalOpen(true)
|
||||
} else {
|
||||
setSelectedLog(log as SystemLog)
|
||||
setIsLogModalOpen(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex-shrink-0 flex gap-2 flex-wrap">
|
||||
<Badge variant="outline" className={getLevelColor(log.level)}>
|
||||
{getLevelIcon(log.level)}
|
||||
{log.level.toUpperCase()}
|
||||
</Badge>
|
||||
{log.eventData && (
|
||||
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
|
||||
<Activity className="h-3 w-3 mr-1" />
|
||||
EVENT
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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-words overflow-hidden">
|
||||
{log.message}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="text-sm text-foreground mb-1 line-clamp-2 break-all overflow-hidden">
|
||||
{log.message}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate break-all overflow-hidden">
|
||||
{log.source}
|
||||
{log.pid && ` • PID: ${log.pid}`}
|
||||
{log.hostname && ` • Host: ${log.hostname}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
|
||||
{displayedLogs.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
@@ -979,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" />
|
||||
@@ -1030,44 +910,48 @@ export function SystemLogs() {
|
||||
|
||||
<ScrollArea className="h-[500px] w-full rounded-md border border-border">
|
||||
<div className="space-y-2 p-4">
|
||||
{backups.map((backup, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start 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"
|
||||
onClick={() => {
|
||||
setSelectedBackup(backup)
|
||||
setIsBackupModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<HardDrive className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
{backups.map((backup, index) => {
|
||||
const uniqueKey = `backup-${backup.volid.replace(/[/:]/g, "-")}-${backup.timestamp || index}`
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1 gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className={getBackupTypeColor(backup.volid)}>
|
||||
{getBackupTypeLabel(backup.volid)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={getBackupStorageColor(backup.volid)}>
|
||||
{getBackupStorageLabel(backup.volid)}
|
||||
return (
|
||||
<div
|
||||
key={uniqueKey}
|
||||
className="flex items-start 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"
|
||||
onClick={() => {
|
||||
setSelectedBackup(backup)
|
||||
setIsBackupModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<HardDrive className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1 gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className={getBackupTypeColor(backup.volid)}>
|
||||
{getBackupTypeLabel(backup.volid)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={getBackupStorageColor(backup.volid)}>
|
||||
{getBackupStorageLabel(backup.volid)}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-green-500/10 text-green-500 border-green-500/20 whitespace-nowrap"
|
||||
>
|
||||
{backup.size_human}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-green-500/10 text-green-500 border-green-500/20 whitespace-nowrap"
|
||||
>
|
||||
{backup.size_human}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mb-1 truncate">Storage: {backup.storage}</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center">
|
||||
<Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
|
||||
<span className="truncate">{backup.created}</span>
|
||||
<div className="text-xs text-muted-foreground mb-1 truncate">Storage: {backup.storage}</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center">
|
||||
<Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
|
||||
<span className="truncate">{backup.created}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
|
||||
{backups.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
@@ -1083,42 +967,47 @@ 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">
|
||||
{notifications.map((notification, index) => (
|
||||
<div
|
||||
key={index}
|
||||
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"
|
||||
onClick={() => {
|
||||
setSelectedNotification(notification)
|
||||
setIsNotificationModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<div className="flex-shrink-0 flex gap-2 flex-wrap">
|
||||
<Badge variant="outline" className={getNotificationTypeColor(notification.type)}>
|
||||
{notification.type.toUpperCase()}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={getNotificationSourceColor(notification.source)}>
|
||||
{notification.source === "task-log" && <Activity className="h-3 w-3 mr-1" />}
|
||||
{notification.source === "journal" && <FileText className="h-3 w-3 mr-1" />}
|
||||
{notification.source.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
{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}`
|
||||
|
||||
<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">{notification.service}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono truncate">
|
||||
{notification.timestamp}
|
||||
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 w-full"
|
||||
onClick={() => {
|
||||
setSelectedNotification(notification)
|
||||
setIsNotificationModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<div className="flex-shrink-0 flex gap-2 flex-wrap">
|
||||
<Badge variant="outline" className={getNotificationTypeColor(notification.type)}>
|
||||
{notification.type.toUpperCase()}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={getNotificationSourceColor(notification.source)}>
|
||||
{notification.source === "task-log" && <Activity className="h-3 w-3 mr-1" />}
|
||||
{notification.source === "journal" && <FileText className="h-3 w-3 mr-1" />}
|
||||
{notification.source.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<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">{notification.service}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono truncate">
|
||||
{notification.timestamp}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-foreground mb-1 line-clamp-2 break-all overflow-hidden">
|
||||
{notification.message}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground break-words overflow-hidden">
|
||||
Service: {notification.service} • Source: {notification.source}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-foreground mb-1 line-clamp-2 break-all overflow-hidden">
|
||||
{notification.message}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground break-words overflow-hidden">
|
||||
Service: {notification.service} • Source: {notification.source}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
|
||||
{notifications.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
@@ -1166,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,7 +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
|
||||
@@ -15,6 +25,7 @@ interface SystemData {
|
||||
memory_total: number
|
||||
memory_used: number
|
||||
temperature: number
|
||||
temperature_sparkline?: TempDataPoint[]
|
||||
uptime: string
|
||||
load_average: number[]
|
||||
hostname: string
|
||||
@@ -95,245 +106,166 @@ interface ProxmoxStorageData {
|
||||
}>
|
||||
}
|
||||
|
||||
const fetchSystemData = async (): Promise<SystemData | null> => {
|
||||
try {
|
||||
const baseUrl = typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
const apiUrl = `${baseUrl}/api/system`
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Flask server responded with status: ${response.status}`)
|
||||
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))
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to fetch system data:", error)
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const fetchVMData = async (): Promise<VMData[]> => {
|
||||
try {
|
||||
const baseUrl = typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
const apiUrl = `${baseUrl}/api/vms`
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Flask server responded with status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
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 []
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStorageData = async (): Promise<StorageData | null> => {
|
||||
try {
|
||||
const baseUrl = typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
const apiUrl = `${baseUrl}/api/storage/summary`
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.log("[v0] Storage API not available (this is normal if not configured)")
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const data = await fetchApi<StorageData>("/api/storage/summary")
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log("[v0] Storage data unavailable:", error instanceof Error ? error.message : "Unknown error")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchNetworkData = async (): Promise<NetworkData | null> => {
|
||||
try {
|
||||
const baseUrl = typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
const apiUrl = `${baseUrl}/api/network/summary`
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.log("[v0] Network API not available (this is normal if not configured)")
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const data = await fetchApi<NetworkData>("/api/network/summary")
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log("[v0] Network data unavailable:", error instanceof Error ? error.message : "Unknown error")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchProxmoxStorageData = async (): Promise<ProxmoxStorageData | null> => {
|
||||
const fetchProxmoxStorageData = async (): Promise<ProxmoxStorage[] | null> => {
|
||||
try {
|
||||
const baseUrl = typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
const apiUrl = `${baseUrl}/api/proxmox-storage`
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.log("[v0] Proxmox storage API not available")
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const data = await fetchApi<ProxmoxStorage[]>("/api/proxmox-storage")
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log("[v0] Proxmox storage data unavailable:", error instanceof Error ? error.message : "Unknown error")
|
||||
} 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[]>([])
|
||||
const [storageData, setStorageData] = useState<StorageData | null>(null)
|
||||
const [proxmoxStorageData, setProxmoxStorageData] = useState<ProxmoxStorageData | null>(null)
|
||||
const [networkData, setNetworkData] = useState<NetworkData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingStates, setLoadingStates] = useState({
|
||||
system: true,
|
||||
vms: true,
|
||||
storage: true,
|
||||
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 fetchData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const fetchAllData = async () => {
|
||||
const [systemResult, vmResult, storageResults, networkResult] = await Promise.all([
|
||||
fetchSystemData().finally(() => setLoadingStates((prev) => ({ ...prev, system: false }))),
|
||||
fetchVMData().finally(() => setLoadingStates((prev) => ({ ...prev, vms: false }))),
|
||||
Promise.all([fetchStorageData(), fetchProxmoxStorageData()]).finally(() =>
|
||||
setLoadingStates((prev) => ({ ...prev, storage: false })),
|
||||
),
|
||||
fetchNetworkData().finally(() => setLoadingStates((prev) => ({ ...prev, network: false }))),
|
||||
])
|
||||
|
||||
const systemResult = await fetchSystemData()
|
||||
setHasAttemptedLoad(true)
|
||||
|
||||
if (!systemResult) {
|
||||
setError("Flask server not available. Please ensure the server is running.")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSystemData(systemResult)
|
||||
} catch (err) {
|
||||
console.error("[v0] Error fetching system data:", err)
|
||||
setError("Failed to connect to Flask server. Please check your connection.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (!systemResult) {
|
||||
setError("Flask server not available. Please ensure the server is running.")
|
||||
return
|
||||
}
|
||||
|
||||
setSystemData(systemResult)
|
||||
setVmData(vmResult)
|
||||
setStorageData(storageResults[0])
|
||||
setProxmoxStorageData(storageResults[1])
|
||||
setNetworkData(networkResult)
|
||||
|
||||
setTimeout(async () => {
|
||||
const refreshedSystemData = await fetchSystemData()
|
||||
if (refreshedSystemData) {
|
||||
setSystemData(refreshedSystemData)
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
fetchData()
|
||||
fetchAllData()
|
||||
|
||||
const systemInterval = setInterval(() => {
|
||||
fetchSystemData().then((data) => {
|
||||
if (data) setSystemData(data)
|
||||
})
|
||||
}, 10000)
|
||||
const systemInterval = setInterval(async () => {
|
||||
const data = await fetchSystemData()
|
||||
if (data) setSystemData(data)
|
||||
}, 5000)
|
||||
|
||||
const vmInterval = setInterval(async () => {
|
||||
const data = await fetchVMData()
|
||||
setVmData(data)
|
||||
}, 59000)
|
||||
|
||||
const storageInterval = setInterval(async () => {
|
||||
const [storage, proxmoxStorage] = await Promise.all([fetchStorageData(), fetchProxmoxStorageData()])
|
||||
if (storage) setStorageData(storage)
|
||||
if (proxmoxStorage) setProxmoxStorageData(proxmoxStorage)
|
||||
}, 59000)
|
||||
|
||||
const networkInterval = setInterval(async () => {
|
||||
const data = await fetchNetworkData()
|
||||
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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchVMs = async () => {
|
||||
const vmResult = await fetchVMData()
|
||||
setVmData(vmResult)
|
||||
}
|
||||
|
||||
fetchVMs()
|
||||
const vmInterval = setInterval(fetchVMs, 60000)
|
||||
|
||||
return () => {
|
||||
clearInterval(vmInterval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStorage = async () => {
|
||||
const storageResult = await fetchStorageData()
|
||||
setStorageData(storageResult)
|
||||
|
||||
const proxmoxStorageResult = await fetchProxmoxStorageData()
|
||||
setProxmoxStorageData(proxmoxStorageResult)
|
||||
}
|
||||
|
||||
fetchStorage()
|
||||
const storageInterval = setInterval(fetchStorage, 60000)
|
||||
|
||||
return () => {
|
||||
clearInterval(storageInterval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchNetwork = async () => {
|
||||
const networkResult = await fetchNetworkData()
|
||||
setNetworkData(networkResult)
|
||||
}
|
||||
|
||||
fetchNetwork()
|
||||
const networkInterval = setInterval(fetchNetwork, 60000)
|
||||
|
||||
return () => {
|
||||
clearInterval(networkInterval)
|
||||
window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
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-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i} className="bg-card border-border animate-pulse">
|
||||
<CardContent className="p-6">
|
||||
<div className="h-4 bg-muted rounded w-1/2 mb-4"></div>
|
||||
<div className="h-8 bg-muted rounded w-3/4 mb-2"></div>
|
||||
<div className="h-2 bg-muted rounded w-full mb-2"></div>
|
||||
<div className="h-3 bg-muted rounded w-2/3"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="relative">
|
||||
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
||||
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">Loading system overview...</div>
|
||||
<p className="text-xs text-muted-foreground">Fetching system status and metrics</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -388,32 +320,16 @@ export function SystemOverview() {
|
||||
return (bytes / 1024 ** 3).toFixed(2)
|
||||
}
|
||||
|
||||
const formatStorage = (sizeInGB: number): string => {
|
||||
if (sizeInGB < 1) {
|
||||
// Less than 1 GB, show in MB
|
||||
return `${(sizeInGB * 1024).toFixed(1)} MB`
|
||||
} else if (sizeInGB < 1024) {
|
||||
// Less than 1024 GB, show in GB
|
||||
return `${sizeInGB.toFixed(1)} GB`
|
||||
} else {
|
||||
// 1024 GB or more, show in TB
|
||||
return `${(sizeInGB / 1024).toFixed(2)} TB`
|
||||
}
|
||||
}
|
||||
|
||||
const tempStatus = getTemperatureStatus(systemData.temperature)
|
||||
|
||||
const localStorage = proxmoxStorageData?.storage.find((s) => s.name === "local")
|
||||
|
||||
const vmLxcStorages = proxmoxStorageData?.storage.filter(
|
||||
(s) =>
|
||||
// Include only local storage types that can host VMs/LXCs
|
||||
(s.type === "lvm" || s.type === "lvmthin" || s.type === "zfspool" || s.type === "btrfs" || s.type === "dir") &&
|
||||
// Exclude network storage
|
||||
s.type !== "nfs" &&
|
||||
s.type !== "cifs" &&
|
||||
s.type !== "iscsi" &&
|
||||
// Exclude the "local" storage (used for ISOs/templates)
|
||||
s.name !== "local",
|
||||
)
|
||||
|
||||
@@ -479,7 +395,6 @@ export function SystemOverview() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Key Metrics Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
@@ -509,54 +424,97 @@ export function SystemOverview() {
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Server className="h-5 w-5 mr-2" />
|
||||
Active VM & LXC
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingStates.vms ? (
|
||||
<div className="space-y-2 animate-pulse">
|
||||
<div className="h-8 bg-muted rounded w-12"></div>
|
||||
<div className="h-5 bg-muted rounded w-24"></div>
|
||||
<div className="h-4 bg-muted rounded w-32"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{vmStats.running}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{vmStats.running} Running
|
||||
</Badge>
|
||||
{vmStats.stopped > 0 && (
|
||||
<Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20">
|
||||
{vmStats.stopped} Stopped
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Total: {vmStats.vms} VMs, {vmStats.lxc} LXC
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</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="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">
|
||||
<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>
|
||||
<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 className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Active VM & LXC</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{vmStats.running}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{vmStats.running} Running
|
||||
</Badge>
|
||||
{vmStats.stopped > 0 && (
|
||||
<Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20">
|
||||
{vmStats.stopped} Stopped
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Total: {vmStats.vms} VMs, {vmStats.lxc} LXC
|
||||
</p>
|
||||
{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>
|
||||
|
||||
{/* Node Metrics Charts */}
|
||||
<TemperatureDetailModal
|
||||
open={tempModalOpen}
|
||||
onOpenChange={setTempModalOpen}
|
||||
liveTemperature={systemData.temperature}
|
||||
/>
|
||||
|
||||
<NodeMetricsCharts />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Storage Summary */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
@@ -565,8 +523,53 @@ export function SystemOverview() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{storageData ? (
|
||||
{loadingStates.storage ? (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="h-6 bg-muted rounded w-full"></div>
|
||||
<div className="h-4 bg-muted rounded w-3/4"></div>
|
||||
<div className="h-4 bg-muted rounded w-2/3"></div>
|
||||
</div>
|
||||
) : storageData ? (
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const totalCapacity = (vmLxcStorageTotal || 0) + (localStorage?.total || 0)
|
||||
const totalUsed = (vmLxcStorageUsed || 0) + (localStorage?.used || 0)
|
||||
const totalAvailable = (vmLxcStorageAvailable || 0) + (localStorage?.available || 0)
|
||||
const totalPercent = totalCapacity > 0 ? (totalUsed / totalCapacity) * 100 : 0
|
||||
|
||||
return totalCapacity > 0 ? (
|
||||
<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>
|
||||
</div>
|
||||
<Progress
|
||||
value={totalPercent}
|
||||
className="mt-2 h-3 [&>div]:bg-gradient-to-r [&>div]:from-blue-500 [&>div]:to-purple-500"
|
||||
/>
|
||||
<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>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
|
||||
<div className="space-y-2 pb-3 border-b border-border">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Total Capacity:</span>
|
||||
@@ -585,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>
|
||||
@@ -596,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>
|
||||
@@ -618,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>
|
||||
@@ -629,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>
|
||||
@@ -642,7 +651,6 @@ export function SystemOverview() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Network Summary */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center justify-between">
|
||||
@@ -665,7 +673,13 @@ export function SystemOverview() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{networkData ? (
|
||||
{loadingStates.network ? (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="h-6 bg-muted rounded w-full"></div>
|
||||
<div className="h-4 bg-muted rounded w-3/4"></div>
|
||||
<div className="h-4 bg-muted rounded w-2/3"></div>
|
||||
</div>
|
||||
) : networkData ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center pb-3 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">Active Interfaces:</span>
|
||||
@@ -712,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>
|
||||
) : (
|
||||
@@ -736,7 +760,6 @@ export function SystemOverview() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* System Information */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
@@ -769,7 +792,6 @@ export function SystemOverview() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Health & Alerts */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
|
||||
@@ -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
@@ -0,0 +1,297 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { AlertCircle, CheckCircle, Copy, Shield, Check } from "lucide-react"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
|
||||
interface TwoFactorSetupProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps) {
|
||||
const [step, setStep] = useState(1)
|
||||
const [qrCode, setQrCode] = useState("")
|
||||
const [secret, setSecret] = useState("")
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([])
|
||||
const [verificationCode, setVerificationCode] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [copiedSecret, setCopiedSecret] = useState(false)
|
||||
const [copiedCodes, setCopiedCodes] = useState(false)
|
||||
|
||||
const handleSetupStart = async () => {
|
||||
setError("")
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("proxmenux-auth-token")
|
||||
const response = await fetch(getApiUrl("/api/auth/totp/setup"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || "Failed to setup 2FA")
|
||||
}
|
||||
|
||||
setQrCode(data.qr_code)
|
||||
setSecret(data.secret)
|
||||
setBackupCodes(data.backup_codes)
|
||||
setStep(2)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to setup 2FA")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!verificationCode || verificationCode.length !== 6) {
|
||||
setError("Please enter a 6-digit code")
|
||||
return
|
||||
}
|
||||
|
||||
setError("")
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("proxmenux-auth-token")
|
||||
const response = await fetch(getApiUrl("/api/auth/totp/enable"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ token: verificationCode }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || "Invalid verification code")
|
||||
}
|
||||
|
||||
setStep(3)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Verification failed")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
} else {
|
||||
setCopiedCodes(true)
|
||||
setTimeout(() => setCopiedCodes(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setStep(1)
|
||||
setQrCode("")
|
||||
setSecret("")
|
||||
setBackupCodes([])
|
||||
setVerificationCode("")
|
||||
setError("")
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleFinish = () => {
|
||||
handleClose()
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-blue-500" />
|
||||
Setup Two-Factor Authentication
|
||||
</DialogTitle>
|
||||
<DialogDescription>Add an extra layer of security to your account</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-500">
|
||||
Two-factor authentication (2FA) adds an extra layer of security by requiring a code from your
|
||||
authentication app in addition to your password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">You will need:</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
|
||||
<li>An authentication app (Google Authenticator, Authy, etc.)</li>
|
||||
<li>Scan a QR code or enter a key manually</li>
|
||||
<li>Store backup codes securely</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSetupStart} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||
{loading ? "Starting..." : "Start Setup"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">1. Scan the QR code</h4>
|
||||
<p className="text-sm text-muted-foreground">Open your authentication app and scan this QR code</p>
|
||||
{qrCode && (
|
||||
<div className="flex justify-center p-4 bg-white rounded-lg">
|
||||
<img src={qrCode || "/placeholder.svg"} alt="QR Code" width={200} height={200} className="rounded" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Or enter the key manually:</h4>
|
||||
<div className="flex gap-2">
|
||||
<Input value={secret} readOnly className="font-mono text-sm" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(secret, "secret")}
|
||||
title="Copy key"
|
||||
>
|
||||
{copiedSecret ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">2. Enter the verification code</h4>
|
||||
<p className="text-sm text-muted-foreground">Enter the 6-digit code that appears in your app</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
className="text-center text-lg tracking-widest font-mono text-base"
|
||||
maxLength={6}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleVerify} className="flex-1 bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||
{loading ? "Verifying..." : "Verify and Enable"}
|
||||
</Button>
|
||||
<Button onClick={handleClose} variant="outline" className="flex-1 bg-transparent" disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-4 flex items-start gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-green-500">2FA Enabled Successfully</p>
|
||||
<p className="text-sm text-green-500 mt-1">
|
||||
Your account is now protected with two-factor authentication
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-orange-500">Important: Save your backup codes</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
These codes will allow you to access your account if you lose access to your authentication app. Store
|
||||
them in a safe place.
|
||||
</p>
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium">Backup Codes</span>
|
||||
<Button variant="outline" size="sm" onClick={() => copyToClipboard(backupCodes.join("\n"), "codes")}>
|
||||
{copiedCodes ? (
|
||||
<Check className="h-4 w-4 text-green-500 mr-2" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Copy All
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{backupCodes.map((code, index) => (
|
||||
<div key={index} className="bg-background rounded px-3 py-2 font-mono text-sm text-center">
|
||||
{code}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleFinish} className="w-full bg-blue-500 hover:bg-blue-600">
|
||||
Finish
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
@@ -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
|
||||
@@ -41,13 +43,16 @@ const DialogContent = React.forwardRef<
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||
className,
|
||||
)}
|
||||
aria-describedby={props["aria-describedby"] || undefined}
|
||||
{...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,
|
||||
}
|
||||
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-4 py-2 text-sm shadow-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium 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 hover:border-ring/50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70")
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768)
|
||||
}
|
||||
|
||||
// Check on mount
|
||||
checkMobile()
|
||||
|
||||
// Listen for resize
|
||||
window.addEventListener("resize", checkMobile)
|
||||
|
||||
return () => window.removeEventListener("resize", checkMobile)
|
||||
}, [])
|
||||
|
||||
return isMobile
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* API Configuration for ProxMenux Monitor
|
||||
* Handles API URL generation with automatic proxy detection
|
||||
*/
|
||||
|
||||
/**
|
||||
* API Server Port Configuration
|
||||
* Default: 8008 (production)
|
||||
* Can be changed to 8009 for beta testing
|
||||
* This can also be set via NEXT_PUBLIC_API_PORT environment variable
|
||||
*/
|
||||
export const API_PORT = process.env.NEXT_PUBLIC_API_PORT || "8008"
|
||||
|
||||
/**
|
||||
* Gets the base URL for API calls
|
||||
* Automatically detects if running behind a proxy by checking if we're on a standard port
|
||||
*
|
||||
* @returns Base URL for API endpoints
|
||||
*/
|
||||
export function getApiBaseUrl(): string {
|
||||
if (typeof window === "undefined") {
|
||||
return ""
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location
|
||||
|
||||
// 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"
|
||||
|
||||
if (isStandardPort) {
|
||||
return ""
|
||||
} else {
|
||||
return `${protocol}//${hostname}:${API_PORT}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a full API URL
|
||||
*
|
||||
* @param endpoint - API endpoint path (e.g., '/api/system')
|
||||
* @returns Full API URL
|
||||
*/
|
||||
export function getApiUrl(endpoint: string): string {
|
||||
const baseUrl = getApiBaseUrl()
|
||||
|
||||
// Ensure endpoint starts with /
|
||||
const normalizedEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`
|
||||
|
||||
return `${baseUrl}${normalizedEndpoint}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the JWT token from localStorage
|
||||
*
|
||||
* @returns JWT token or null if not authenticated
|
||||
*/
|
||||
export function getAuthToken(): string | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null
|
||||
}
|
||||
return localStorage.getItem("proxmenux-auth-token")
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches data from an API endpoint with error handling
|
||||
*
|
||||
* @param endpoint - API endpoint path
|
||||
* @param options - Fetch options
|
||||
* @returns Promise with the response data
|
||||
*/
|
||||
export async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const url = getApiUrl(endpoint)
|
||||
|
||||
const token = getAuthToken()
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options?.headers as Record<string, string>),
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
console.error("[v0] fetchApi: 401 UNAUTHORIZED -", endpoint, "- Token present:", !!token)
|
||||
throw new Error(`Unauthorized: ${endpoint}`)
|
||||
}
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,18 @@ import { twMerge } from "tailwind-merge"
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatStorage(sizeInGB: number): string {
|
||||
if (sizeInGB < 1) {
|
||||
// Less than 1 GB, show in MB
|
||||
const mb = sizeInGB * 1024
|
||||
return `${mb % 1 === 0 ? mb.toFixed(0) : mb.toFixed(1)} MB`
|
||||
} else if (sizeInGB < 1024) {
|
||||
// Less than 1024 GB, show in GB
|
||||
return `${sizeInGB % 1 === 0 ? sizeInGB.toFixed(0) : sizeInGB.toFixed(1)} GB`
|
||||
} else {
|
||||
// 1024 GB or more, show in TB
|
||||
const tb = sizeInGB / 1024
|
||||
return `${tb % 1 === 0 ? tb.toFixed(0) : tb.toFixed(1)} TB`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxmenux-monitor",
|
||||
"version": "1.0.0",
|
||||
"name": "ProxMenux-Monitor",
|
||||
"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}")
|
||||
@@ -0,0 +1,803 @@
|
||||
"""
|
||||
Authentication Manager Module
|
||||
Handles all authentication-related operations including:
|
||||
- Loading/saving auth configuration
|
||||
- Password hashing and verification
|
||||
- JWT token generation and validation
|
||||
- Auth status checking
|
||||
- Two-Factor Authentication (2FA/TOTP)
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import jwt
|
||||
JWT_AVAILABLE = True
|
||||
except ImportError:
|
||||
JWT_AVAILABLE = False
|
||||
print("Warning: PyJWT not available. Authentication features will be limited.")
|
||||
|
||||
try:
|
||||
import pyotp
|
||||
import segno
|
||||
import io
|
||||
import base64
|
||||
TOTP_AVAILABLE = True
|
||||
except ImportError:
|
||||
TOTP_AVAILABLE = False
|
||||
print("Warning: pyotp/segno not available. 2FA features will be disabled.")
|
||||
|
||||
# Configuration
|
||||
CONFIG_DIR = Path.home() / ".config" / "proxmenux-monitor"
|
||||
AUTH_CONFIG_FILE = CONFIG_DIR / "auth.json"
|
||||
JWT_SECRET = "proxmenux-monitor-secret-key-change-in-production"
|
||||
JWT_ALGORITHM = "HS256"
|
||||
TOKEN_EXPIRATION_HOURS = 24
|
||||
|
||||
|
||||
def ensure_config_dir():
|
||||
"""Ensure the configuration directory exists"""
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def load_auth_config():
|
||||
"""
|
||||
Load authentication configuration from file
|
||||
Returns dict with structure:
|
||||
{
|
||||
"enabled": bool,
|
||||
"username": str,
|
||||
"password_hash": str,
|
||||
"declined": bool,
|
||||
"configured": bool,
|
||||
"totp_enabled": bool, # 2FA enabled flag
|
||||
"totp_secret": str, # TOTP secret key
|
||||
"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():
|
||||
return {
|
||||
"enabled": False,
|
||||
"username": None,
|
||||
"password_hash": None,
|
||||
"declined": False,
|
||||
"configured": False,
|
||||
"totp_enabled": False,
|
||||
"totp_secret": None,
|
||||
"backup_codes": [],
|
||||
"api_tokens": [],
|
||||
"revoked_tokens": []
|
||||
}
|
||||
|
||||
try:
|
||||
with open(AUTH_CONFIG_FILE, 'r') as f:
|
||||
config = json.load(f)
|
||||
# Ensure all required fields exist
|
||||
config.setdefault("declined", False)
|
||||
config.setdefault("configured", config.get("enabled", False) or config.get("declined", False))
|
||||
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}")
|
||||
return {
|
||||
"enabled": False,
|
||||
"username": None,
|
||||
"password_hash": None,
|
||||
"declined": False,
|
||||
"configured": False,
|
||||
"totp_enabled": False,
|
||||
"totp_secret": None,
|
||||
"backup_codes": [],
|
||||
"api_tokens": [],
|
||||
"revoked_tokens": []
|
||||
}
|
||||
|
||||
|
||||
def save_auth_config(config):
|
||||
"""Save authentication configuration to file"""
|
||||
ensure_config_dir()
|
||||
try:
|
||||
with open(AUTH_CONFIG_FILE, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error saving auth config: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def hash_password(password):
|
||||
"""Hash a password using SHA-256"""
|
||||
return hashlib.sha256(password.encode()).hexdigest()
|
||||
|
||||
|
||||
def verify_password(password, password_hash):
|
||||
"""Verify a password against its hash"""
|
||||
return hash_password(password) == password_hash
|
||||
|
||||
|
||||
def generate_token(username):
|
||||
"""Generate a JWT token for the given username"""
|
||||
if not JWT_AVAILABLE:
|
||||
return None
|
||||
|
||||
payload = {
|
||||
'username': username,
|
||||
'exp': datetime.utcnow() + timedelta(hours=TOKEN_EXPIRATION_HOURS),
|
||||
'iat': datetime.utcnow()
|
||||
}
|
||||
|
||||
try:
|
||||
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
return token
|
||||
except Exception as e:
|
||||
print(f"Error generating token: {e}")
|
||||
return None
|
||||
|
||||
|
||||
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:
|
||||
print("Token has expired")
|
||||
return None
|
||||
except jwt.InvalidTokenError as e:
|
||||
print(f"Invalid token: {e}")
|
||||
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
|
||||
Returns dict with:
|
||||
{
|
||||
"auth_enabled": bool,
|
||||
"auth_configured": bool,
|
||||
"declined": bool,
|
||||
"username": str or None,
|
||||
"authenticated": bool,
|
||||
"totp_enabled": bool # 2FA status
|
||||
}
|
||||
"""
|
||||
config = load_auth_config()
|
||||
return {
|
||||
"auth_enabled": config.get("enabled", False),
|
||||
"auth_configured": config.get("configured", False),
|
||||
"declined": config.get("declined", False),
|
||||
"username": config.get("username") if config.get("enabled") else None,
|
||||
"authenticated": False,
|
||||
"totp_enabled": config.get("totp_enabled", False) # Include 2FA status
|
||||
}
|
||||
|
||||
|
||||
def setup_auth(username, password):
|
||||
"""
|
||||
Set up authentication with username and password
|
||||
Returns (success: bool, message: str)
|
||||
"""
|
||||
if not username or not password:
|
||||
return False, "Username and password are required"
|
||||
|
||||
if len(password) < 6:
|
||||
return False, "Password must be at least 6 characters"
|
||||
|
||||
config = {
|
||||
"enabled": True,
|
||||
"username": username,
|
||||
"password_hash": hash_password(password),
|
||||
"declined": False,
|
||||
"configured": True,
|
||||
"totp_enabled": False,
|
||||
"totp_secret": None,
|
||||
"backup_codes": []
|
||||
}
|
||||
|
||||
if save_auth_config(config):
|
||||
return True, "Authentication configured successfully"
|
||||
else:
|
||||
return False, "Failed to save authentication configuration"
|
||||
|
||||
|
||||
def decline_auth():
|
||||
"""
|
||||
Mark authentication as declined by user
|
||||
Returns (success: bool, message: str)
|
||||
"""
|
||||
config = load_auth_config()
|
||||
config["enabled"] = False
|
||||
config["declined"] = True
|
||||
config["configured"] = True
|
||||
config["username"] = None
|
||||
config["password_hash"] = None
|
||||
config["totp_enabled"] = False
|
||||
config["totp_secret"] = None
|
||||
config["backup_codes"] = []
|
||||
|
||||
if save_auth_config(config):
|
||||
return True, "Authentication declined"
|
||||
else:
|
||||
return False, "Failed to save configuration"
|
||||
|
||||
|
||||
def disable_auth():
|
||||
"""
|
||||
Disable authentication (different from decline - can be re-enabled)
|
||||
Returns (success: bool, message: str)
|
||||
"""
|
||||
config = load_auth_config()
|
||||
config["enabled"] = False
|
||||
config["username"] = None
|
||||
config["password_hash"] = None
|
||||
config["declined"] = False
|
||||
config["configured"] = False
|
||||
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"
|
||||
else:
|
||||
return False, "Failed to save configuration"
|
||||
|
||||
|
||||
def enable_auth():
|
||||
"""
|
||||
Enable authentication (must already be configured)
|
||||
Returns (success: bool, message: str)
|
||||
"""
|
||||
config = load_auth_config()
|
||||
|
||||
if not config.get("username") or not config.get("password_hash"):
|
||||
return False, "Authentication not configured. Please set up username and password first."
|
||||
|
||||
config["enabled"] = True
|
||||
config["declined"] = False
|
||||
|
||||
if save_auth_config(config):
|
||||
return True, "Authentication enabled"
|
||||
else:
|
||||
return False, "Failed to save configuration"
|
||||
|
||||
|
||||
def change_password(old_password, new_password):
|
||||
"""
|
||||
Change the authentication password
|
||||
Returns (success: bool, message: str)
|
||||
"""
|
||||
config = load_auth_config()
|
||||
|
||||
if not config.get("enabled"):
|
||||
return False, "Authentication is not enabled"
|
||||
|
||||
if not verify_password(old_password, config.get("password_hash", "")):
|
||||
return False, "Current password is incorrect"
|
||||
|
||||
if len(new_password) < 6:
|
||||
return False, "New password must be at least 6 characters"
|
||||
|
||||
config["password_hash"] = hash_password(new_password)
|
||||
|
||||
if save_auth_config(config):
|
||||
return True, "Password changed successfully"
|
||||
else:
|
||||
return False, "Failed to save new password"
|
||||
|
||||
|
||||
def generate_totp_secret():
|
||||
"""Generate a new TOTP secret key"""
|
||||
if not TOTP_AVAILABLE:
|
||||
return None
|
||||
return pyotp.random_base32()
|
||||
|
||||
|
||||
def generate_totp_qr(username, secret):
|
||||
"""
|
||||
Generate a QR code for TOTP setup
|
||||
Returns base64 encoded SVG image
|
||||
"""
|
||||
if not TOTP_AVAILABLE:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Create TOTP URI
|
||||
totp = pyotp.TOTP(secret)
|
||||
uri = totp.provisioning_uri(
|
||||
name=username,
|
||||
issuer_name="ProxMenux Monitor"
|
||||
)
|
||||
|
||||
qr = segno.make(uri)
|
||||
|
||||
# Convert to SVG string
|
||||
buffer = io.BytesIO()
|
||||
qr.save(buffer, kind='svg', scale=4, border=2)
|
||||
svg_bytes = buffer.getvalue()
|
||||
svg_content = svg_bytes.decode('utf-8')
|
||||
|
||||
# Return as data URL
|
||||
svg_base64 = base64.b64encode(svg_content.encode()).decode('utf-8')
|
||||
return f"data:image/svg+xml;base64,{svg_base64}"
|
||||
except Exception as e:
|
||||
print(f"Error generating QR code: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def generate_backup_codes(count=8):
|
||||
"""Generate backup codes for 2FA recovery"""
|
||||
codes = []
|
||||
for _ in range(count):
|
||||
# Generate 8-character alphanumeric code
|
||||
code = ''.join(secrets.choice('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') for _ in range(8))
|
||||
# Format as XXXX-XXXX for readability
|
||||
formatted = f"{code[:4]}-{code[4:]}"
|
||||
codes.append({
|
||||
"code": hashlib.sha256(formatted.encode()).hexdigest(),
|
||||
"used": False
|
||||
})
|
||||
return codes
|
||||
|
||||
|
||||
def setup_totp(username):
|
||||
"""
|
||||
Set up TOTP for a user
|
||||
Returns (success: bool, secret: str, qr_code: str, backup_codes: list, message: str)
|
||||
"""
|
||||
if not TOTP_AVAILABLE:
|
||||
return False, None, None, None, "2FA is not available (pyotp/segno not installed)"
|
||||
|
||||
config = load_auth_config()
|
||||
|
||||
if not config.get("enabled"):
|
||||
return False, None, None, None, "Authentication must be enabled first"
|
||||
|
||||
if config.get("username") != username:
|
||||
return False, None, None, None, "Invalid username"
|
||||
|
||||
# Generate new secret and backup codes
|
||||
secret = generate_totp_secret()
|
||||
qr_code = generate_totp_qr(username, secret)
|
||||
backup_codes_plain = []
|
||||
backup_codes_hashed = generate_backup_codes()
|
||||
|
||||
# Generate plain text backup codes for display (only returned once)
|
||||
for i in range(8):
|
||||
code = ''.join(secrets.choice('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') for _ in range(8))
|
||||
formatted = f"{code[:4]}-{code[4:]}"
|
||||
backup_codes_plain.append(formatted)
|
||||
backup_codes_hashed[i]["code"] = hashlib.sha256(formatted.encode()).hexdigest()
|
||||
|
||||
# Store secret and hashed backup codes (not enabled yet until verified)
|
||||
config["totp_secret"] = secret
|
||||
config["backup_codes"] = backup_codes_hashed
|
||||
|
||||
if save_auth_config(config):
|
||||
return True, secret, qr_code, backup_codes_plain, "2FA setup initiated"
|
||||
else:
|
||||
return False, None, None, None, "Failed to save 2FA configuration"
|
||||
|
||||
|
||||
def verify_totp(username, token, use_backup=False):
|
||||
"""
|
||||
Verify a TOTP token or backup code
|
||||
Returns (success: bool, message: str)
|
||||
"""
|
||||
if not TOTP_AVAILABLE and not use_backup:
|
||||
return False, "2FA is not available"
|
||||
|
||||
config = load_auth_config()
|
||||
|
||||
if not config.get("totp_enabled"):
|
||||
return False, "2FA is not enabled"
|
||||
|
||||
if config.get("username") != username:
|
||||
return False, "Invalid username"
|
||||
|
||||
# Check backup code
|
||||
if use_backup:
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
for backup_code in config.get("backup_codes", []):
|
||||
if backup_code["code"] == token_hash and not backup_code["used"]:
|
||||
backup_code["used"] = True
|
||||
save_auth_config(config)
|
||||
return True, "Backup code accepted"
|
||||
return False, "Invalid or already used backup code"
|
||||
|
||||
# Check TOTP token
|
||||
totp = pyotp.TOTP(config.get("totp_secret"))
|
||||
if totp.verify(token, valid_window=1): # Allow 1 time step tolerance
|
||||
return True, "2FA verification successful"
|
||||
else:
|
||||
return False, "Invalid 2FA code"
|
||||
|
||||
|
||||
def enable_totp(username, verification_token):
|
||||
"""
|
||||
Enable TOTP after successful verification
|
||||
Returns (success: bool, message: str)
|
||||
"""
|
||||
if not TOTP_AVAILABLE:
|
||||
return False, "2FA is not available"
|
||||
|
||||
config = load_auth_config()
|
||||
|
||||
if not config.get("totp_secret"):
|
||||
return False, "2FA has not been set up. Please set up 2FA first."
|
||||
|
||||
if config.get("username") != username:
|
||||
return False, "Invalid username"
|
||||
|
||||
# Verify the token before enabling
|
||||
totp = pyotp.TOTP(config.get("totp_secret"))
|
||||
if not totp.verify(verification_token, valid_window=1):
|
||||
return False, "Invalid verification code. Please try again."
|
||||
|
||||
config["totp_enabled"] = True
|
||||
|
||||
if save_auth_config(config):
|
||||
return True, "2FA enabled successfully"
|
||||
else:
|
||||
return False, "Failed to enable 2FA"
|
||||
|
||||
|
||||
def disable_totp(username, password):
|
||||
"""
|
||||
Disable TOTP (requires password confirmation)
|
||||
Returns (success: bool, message: str)
|
||||
"""
|
||||
config = load_auth_config()
|
||||
|
||||
if config.get("username") != username:
|
||||
return False, "Invalid username"
|
||||
|
||||
if not verify_password(password, config.get("password_hash", "")):
|
||||
return False, "Invalid password"
|
||||
|
||||
config["totp_enabled"] = False
|
||||
config["totp_secret"] = None
|
||||
config["backup_codes"] = []
|
||||
|
||||
if save_auth_config(config):
|
||||
return True, "2FA disabled successfully"
|
||||
else:
|
||||
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
|
||||
Returns (success: bool, token: str or None, requires_totp: bool, message: str)
|
||||
"""
|
||||
config = load_auth_config()
|
||||
|
||||
if not config.get("enabled"):
|
||||
return False, None, False, "Authentication is not enabled"
|
||||
|
||||
if username != config.get("username"):
|
||||
return False, None, False, "Invalid username or password"
|
||||
|
||||
if not verify_password(password, config.get("password_hash", "")):
|
||||
return False, None, False, "Invalid username or password"
|
||||
|
||||
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:
|
||||
# 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:
|
||||
return True, token, False, "Authentication successful"
|
||||
else:
|
||||
return False, None, False, "Failed to generate authentication token"
|
||||
@@ -78,6 +78,52 @@ cd "$SCRIPT_DIR"
|
||||
# Copy Flask server
|
||||
echo "📋 Copying Flask server..."
|
||||
cp "$SCRIPT_DIR/flask_server.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/flask_auth_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_auth_routes.py not found"
|
||||
cp "$SCRIPT_DIR/auth_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ auth_manager.py not found"
|
||||
cp "$SCRIPT_DIR/jwt_middleware.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ jwt_middleware.py not found"
|
||||
cp "$SCRIPT_DIR/health_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_monitor.py not found"
|
||||
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'
|
||||
@@ -274,16 +320,38 @@ 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 \
|
||||
requests \
|
||||
googletrans==4.0.0-rc1 \
|
||||
httpx==0.13.3 \
|
||||
httpcore==0.9.1 \
|
||||
PyJWT \
|
||||
pyotp \
|
||||
segno \
|
||||
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:
|
||||
@@ -321,10 +389,6 @@ echo "🔧 Installing hardware monitoring tools..."
|
||||
mkdir -p "$WORK_DIR/debs"
|
||||
cd "$WORK_DIR/debs"
|
||||
|
||||
|
||||
# ==============================================================
|
||||
|
||||
|
||||
echo "📥 Downloading hardware monitoring tools (dynamic via APT)..."
|
||||
|
||||
dl_pkg() {
|
||||
@@ -361,21 +425,12 @@ dl_pkg() {
|
||||
return 1
|
||||
}
|
||||
|
||||
mkdir -p "$WORK_DIR/debs"
|
||||
cd "$WORK_DIR/debs"
|
||||
|
||||
|
||||
dl_pkg "ipmitool.deb" "ipmitool" || true
|
||||
dl_pkg "libfreeipmi17.deb" "libfreeipmi17" || true
|
||||
dl_pkg "lm-sensors.deb" "lm-sensors" || true
|
||||
dl_pkg "nut-client.deb" "nut-client" || true
|
||||
dl_pkg "libupsclient.deb" "libupsclient6" "libupsclient5" "libupsclient4" || true
|
||||
|
||||
|
||||
# dl_pkg "nvidia-smi.deb" "nvidia-smi" "nvidia-utils" "nvidia-utils-535" "nvidia-utils-550" || true
|
||||
# dl_pkg "intel-gpu-tools.deb" "intel-gpu-tools" || true
|
||||
# dl_pkg "radeontop.deb" "radeontop" || true
|
||||
|
||||
echo "📦 Extracting .deb packages into AppDir..."
|
||||
extracted_count=0
|
||||
shopt -s nullglob
|
||||
@@ -395,7 +450,6 @@ else
|
||||
echo "✅ Extracted $extracted_count package(s)"
|
||||
fi
|
||||
|
||||
|
||||
if [ -d "$APP_DIR/bin" ]; then
|
||||
echo "📋 Normalizing /bin -> /usr/bin"
|
||||
mkdir -p "$APP_DIR/usr/bin"
|
||||
@@ -403,24 +457,20 @@ if [ -d "$APP_DIR/bin" ]; then
|
||||
rm -rf "$APP_DIR/bin"
|
||||
fi
|
||||
|
||||
|
||||
echo "🔍 Sanity check (ldd + presence of libfreeipmi)"
|
||||
export LD_LIBRARY_PATH="$APP_DIR/lib:$APP_DIR/lib/x86_64-linux-gnu:$APP_DIR/usr/lib:$APP_DIR/usr/lib/x86_64-linux-gnu"
|
||||
|
||||
|
||||
if ! find "$APP_DIR/usr/lib" "$APP_DIR/lib" -maxdepth 3 -name 'libfreeipmi.so.17*' | grep -q .; then
|
||||
echo "❌ libfreeipmi.so.17 not found inside AppDir (ipmitool will fail)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
if [ -x "$APP_DIR/usr/bin/ipmitool" ] && ldd "$APP_DIR/usr/bin/ipmitool" | grep -q 'not found'; then
|
||||
echo "❌ ipmitool has unresolved libs:"
|
||||
ldd "$APP_DIR/usr/bin/ipmitool" | grep 'not found' || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
if [ -x "$APP_DIR/usr/bin/upsc" ] && ldd "$APP_DIR/usr/bin/upsc" | grep -q 'not found'; then
|
||||
echo "⚠️ upsc has unresolved libs, trying to auto-fix..."
|
||||
missing="$(ldd "$APP_DIR/usr/bin/upsc" | awk '/not found/{print $1}' | tr -d ' ')"
|
||||
@@ -463,12 +513,6 @@ echo "✅ Sanity check OK (ipmitool/upsc ready; libfreeipmi present)"
|
||||
[ -x "$APP_DIR/usr/bin/intel_gpu_top" ] && echo " • intel-gpu-tools: OK" || echo " • intel-gpu-tools: missing"
|
||||
[ -x "$APP_DIR/usr/bin/radeontop" ] && echo " • radeontop: OK" || echo " • radeontop: missing"
|
||||
|
||||
|
||||
|
||||
# ==============================================================
|
||||
|
||||
|
||||
|
||||
# Build AppImage
|
||||
echo "🔨 Building unified AppImage v${VERSION}..."
|
||||
cd "$WORK_DIR"
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
"""
|
||||
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'])
|
||||
def auth_status():
|
||||
"""Get current authentication status"""
|
||||
try:
|
||||
status = auth_manager.get_auth_status()
|
||||
|
||||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
if token:
|
||||
username = auth_manager.verify_token(token)
|
||||
if username:
|
||||
status['authenticated'] = True
|
||||
|
||||
return jsonify(status)
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 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:
|
||||
config = auth_manager.load_ssl_config()
|
||||
detection = auth_manager.detect_proxmox_certificates()
|
||||
|
||||
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:
|
||||
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"""
|
||||
try:
|
||||
success, message = auth_manager.decline_auth()
|
||||
|
||||
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
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/login', methods=['POST'])
|
||||
def auth_login():
|
||||
"""Authenticate user and return JWT token"""
|
||||
try:
|
||||
data = request.json
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
totp_token = data.get('totp_token') # Optional 2FA token
|
||||
|
||||
success, token, requires_totp, message = auth_manager.authenticate(username, password, totp_token)
|
||||
|
||||
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:
|
||||
# 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 (must already be configured)"""
|
||||
try:
|
||||
success, message = auth_manager.enable_auth()
|
||||
|
||||
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
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/disable', methods=['POST'])
|
||||
def auth_disable():
|
||||
"""Disable authentication"""
|
||||
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.disable_auth()
|
||||
|
||||
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
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/change-password', methods=['POST'])
|
||||
def auth_change_password():
|
||||
"""Change authentication password"""
|
||||
try:
|
||||
data = request.json
|
||||
old_password = data.get('old_password')
|
||||
new_password = data.get('new_password')
|
||||
|
||||
success, message = auth_manager.change_password(old_password, new_password)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/skip', methods=['POST'])
|
||||
def auth_skip():
|
||||
"""Skip authentication setup (same as decline)"""
|
||||
try:
|
||||
success, message = auth_manager.decline_auth()
|
||||
|
||||
if success:
|
||||
# Return success with clear indication that APIs should be accessible
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": message,
|
||||
"auth_declined": True # Add explicit flag for frontend
|
||||
})
|
||||
else:
|
||||
return jsonify({"success": False, "message": message}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/totp/setup', methods=['POST'])
|
||||
def totp_setup():
|
||||
"""Initialize TOTP setup for a user"""
|
||||
try:
|
||||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
username = auth_manager.verify_token(token)
|
||||
|
||||
if not username:
|
||||
return jsonify({"success": False, "message": "Unauthorized"}), 401
|
||||
|
||||
success, secret, qr_code, backup_codes, message = auth_manager.setup_totp(username)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"secret": secret,
|
||||
"qr_code": qr_code,
|
||||
"backup_codes": backup_codes,
|
||||
"message": message
|
||||
})
|
||||
else:
|
||||
return jsonify({"success": False, "message": message}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/totp/enable', methods=['POST'])
|
||||
def totp_enable():
|
||||
"""Enable TOTP after verification"""
|
||||
try:
|
||||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
username = auth_manager.verify_token(token)
|
||||
|
||||
if not username:
|
||||
return jsonify({"success": False, "message": "Unauthorized"}), 401
|
||||
|
||||
data = request.json
|
||||
verification_token = data.get('token')
|
||||
|
||||
if not verification_token:
|
||||
return jsonify({"success": False, "message": "Verification token required"}), 400
|
||||
|
||||
success, message = auth_manager.enable_totp(username, verification_token)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/totp/disable', methods=['POST'])
|
||||
def totp_disable():
|
||||
"""Disable TOTP (requires password confirmation)"""
|
||||
try:
|
||||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
username = auth_manager.verify_token(token)
|
||||
|
||||
if not username:
|
||||
return jsonify({"success": False, "message": "Unauthorized"}), 401
|
||||
|
||||
data = request.json
|
||||
password = data.get('password')
|
||||
|
||||
if not password:
|
||||
return jsonify({"success": False, "message": "Password required"}), 400
|
||||
|
||||
success, message = auth_manager.disable_totp(username, password)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/generate-api-token', methods=['POST'])
|
||||
def generate_api_token():
|
||||
"""Generate a long-lived API token for external integrations (Homepage, Home Assistant, etc.)"""
|
||||
try:
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
token = auth_header.replace('Bearer ', '')
|
||||
|
||||
if not token:
|
||||
return jsonify({"success": False, "message": "Unauthorized. Please log in first."}), 401
|
||||
|
||||
username = auth_manager.verify_token(token)
|
||||
|
||||
if not username:
|
||||
return jsonify({"success": False, "message": "Invalid or expired session. Please log in again."}), 401
|
||||
|
||||
data = request.json
|
||||
password = data.get('password')
|
||||
totp_token = data.get('totp_token') # Optional 2FA token
|
||||
token_name = data.get('token_name', 'API Token') # Optional token description
|
||||
|
||||
if not password:
|
||||
return jsonify({"success": False, "message": "Password is required"}), 400
|
||||
|
||||
# Authenticate user with password and optional 2FA
|
||||
success, _, requires_totp, message = auth_manager.authenticate(username, password, totp_token)
|
||||
|
||||
if success:
|
||||
# Generate a long-lived token (1 year expiration)
|
||||
api_token = jwt.encode({
|
||||
'username': username,
|
||||
'token_name': token_name,
|
||||
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=365),
|
||||
'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,
|
||||
"token_name": token_name,
|
||||
"expires_in": "365 days",
|
||||
"message": "API token generated successfully. Store this token securely, it will not be shown again."
|
||||
})
|
||||
elif requires_totp:
|
||||
return jsonify({"success": False, "requires_totp": True, "message": message}), 200
|
||||
else:
|
||||
return jsonify({"success": False, "message": message}), 401
|
||||
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
|
||||
@@ -0,0 +1,600 @@
|
||||
"""
|
||||
Flask routes for health monitoring with persistence support
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from health_monitor import health_monitor
|
||||
from health_persistence import health_persistence
|
||||
|
||||
health_bp = Blueprint('health', __name__)
|
||||
|
||||
@health_bp.route('/api/health/status', methods=['GET'])
|
||||
def get_health_status():
|
||||
"""Get overall health status summary"""
|
||||
try:
|
||||
status = health_monitor.get_overall_status()
|
||||
return jsonify(status)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@health_bp.route('/api/health/details', methods=['GET'])
|
||||
def get_health_details():
|
||||
"""Get detailed health status with all checks"""
|
||||
try:
|
||||
details = health_monitor.get_detailed_status()
|
||||
return jsonify(details)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@health_bp.route('/api/system-info', methods=['GET'])
|
||||
def get_system_info():
|
||||
"""
|
||||
Get lightweight system info for header display.
|
||||
Returns: hostname, uptime, and health status with proper structure.
|
||||
"""
|
||||
try:
|
||||
info = health_monitor.get_system_info()
|
||||
|
||||
if 'health' in info:
|
||||
status_map = {
|
||||
'OK': 'healthy',
|
||||
'WARNING': 'warning',
|
||||
'CRITICAL': 'critical',
|
||||
'UNKNOWN': 'warning'
|
||||
}
|
||||
current_status = info['health'].get('status', 'OK').upper()
|
||||
info['health']['status'] = status_map.get(current_status, 'healthy')
|
||||
|
||||
return jsonify(info)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@health_bp.route('/api/health/acknowledge', methods=['POST'])
|
||||
def acknowledge_error():
|
||||
"""
|
||||
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']
|
||||
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
|
||||
|
||||
@health_bp.route('/api/health/active-errors', methods=['GET'])
|
||||
def get_active_errors():
|
||||
"""Get all active persistent errors"""
|
||||
try:
|
||||
category = request.args.get('category')
|
||||
errors = health_persistence.get_active_errors(category)
|
||||
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
|
||||
@@ -0,0 +1,296 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
proxmenux_bp = Blueprint('proxmenux', __name__)
|
||||
|
||||
# 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"""
|
||||
installed_tools_path = '/usr/local/share/proxmenux/installed_tools.json'
|
||||
|
||||
try:
|
||||
if not os.path.exists(installed_tools_path):
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'installed_tools': [],
|
||||
'message': 'No ProxMenux optimizations installed yet'
|
||||
})
|
||||
|
||||
with open(installed_tools_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 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': 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
|
||||
tools.sort(key=lambda x: x['name'])
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'installed_tools': tools,
|
||||
'total_count': len(tools)
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid JSON format in installed_tools.json'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'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
|
||||
+5369
-483
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
JWT Middleware Module
|
||||
Provides decorator to protect Flask routes with JWT authentication
|
||||
Automatically checks auth status and validates tokens
|
||||
"""
|
||||
|
||||
from flask import request, jsonify
|
||||
from functools import wraps
|
||||
from auth_manager import load_auth_config, verify_token
|
||||
|
||||
|
||||
def require_auth(f):
|
||||
"""
|
||||
Decorator to protect Flask routes with JWT authentication
|
||||
|
||||
Behavior:
|
||||
- If auth is disabled or declined: Allow access (no token required)
|
||||
- If auth is enabled: Require valid JWT token in Authorization header
|
||||
- Returns 401 if auth required but token missing/invalid
|
||||
|
||||
Usage:
|
||||
@app.route('/api/protected')
|
||||
@require_auth
|
||||
def protected_route():
|
||||
return jsonify({"data": "secret"})
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Check if authentication is enabled
|
||||
config = load_auth_config()
|
||||
|
||||
# If auth is disabled or declined, allow access
|
||||
if not config.get("enabled", False) or config.get("declined", False):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Auth is enabled, require token
|
||||
auth_header = request.headers.get('Authorization')
|
||||
|
||||
if not auth_header:
|
||||
return jsonify({
|
||||
"error": "Authentication required",
|
||||
"message": "No authorization header provided"
|
||||
}), 401
|
||||
|
||||
# Extract token from "Bearer <token>" format
|
||||
parts = auth_header.split()
|
||||
if len(parts) != 2 or parts[0].lower() != 'bearer':
|
||||
return jsonify({
|
||||
"error": "Invalid authorization header",
|
||||
"message": "Authorization header must be in format: Bearer <token>"
|
||||
}), 401
|
||||
|
||||
token = parts[1]
|
||||
|
||||
# Verify token
|
||||
username = verify_token(token)
|
||||
if not username:
|
||||
return jsonify({
|
||||
"error": "Invalid or expired token",
|
||||
"message": "Please log in again"
|
||||
}), 401
|
||||
|
||||
# Token is valid, allow access
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def optional_auth(f):
|
||||
"""
|
||||
Decorator for routes that can optionally use auth
|
||||
Passes username if authenticated, None otherwise
|
||||
|
||||
Usage:
|
||||
@app.route('/api/optional')
|
||||
@optional_auth
|
||||
def optional_route(username=None):
|
||||
if username:
|
||||
return jsonify({"message": f"Hello {username}"})
|
||||
return jsonify({"message": "Hello guest"})
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
config = load_auth_config()
|
||||
username = None
|
||||
|
||||
if config.get("enabled", False):
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if auth_header:
|
||||
parts = auth_header.split()
|
||||
if len(parts) == 2 and parts[0].lower() == 'bearer':
|
||||
username = verify_token(parts[1])
|
||||
|
||||
# Inject username into kwargs
|
||||
kwargs['username'] = username
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
@@ -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()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user