Compare commits
868 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1eae7b768 | |||
| 55bb5b5a1c | |||
| e8232a9ea0 | |||
| aeabb99be6 | |||
| 38ee6d836d | |||
| 7bb4bd3da5 | |||
| 7524615671 | |||
| f5fe883d49 | |||
| bec6406216 | |||
| ef041f2702 | |||
| df0f15419e | |||
| dc531eaa37 | |||
| 1eaabd14bd | |||
| c9c8987cca | |||
| 06dc6ea23f | |||
| 8b3a76dfc5 | |||
| 60398210c7 | |||
| 486c7ef530 | |||
| 94131097a5 | |||
| 6d69e009dc | |||
| 6d9b132ab8 | |||
| efec1aff18 | |||
| 258d6d9a49 | |||
| 1c4b7c7b97 | |||
| 8bc6306813 | |||
| 2923c00738 | |||
| b30b6a062a | |||
| 8f5df889ab | |||
| 4ec8b19251 | |||
| 1035a94775 | |||
| 3ca2ae7175 | |||
| 4ba1ca890c | |||
| cba012bd15 | |||
| 9515ccd816 | |||
| 46622f5028 | |||
| 9190c8e5bf | |||
| 109498e2df | |||
| 60d7c395bc | |||
| 782d847e54 | |||
| d96e4019aa | |||
| 6b438bc4aa | |||
| 50d07f81fd | |||
| 7d69e64adc | |||
| c2fa6095cc | |||
| 0b8b72be5c | |||
| fd6f0967b0 | |||
| ca9698f75d | |||
| 968a5bd789 | |||
| 1fe4ee5b81 | |||
| 137aeac91a | |||
| ccb0b58a2d | |||
| 680123eb64 | |||
| aec04f0b8c | |||
| e75bbc0a22 | |||
| 81fc625c5d | |||
| f85683239f | |||
| c0f54c334e | |||
| 5c2d4e4718 | |||
| 64a0aa6157 | |||
| ff2e40d49a | |||
| 1226e7bee1 | |||
| 342203bb81 | |||
| e4bc526a09 | |||
| 5941bd4b68 | |||
| 1c95319608 | |||
| eeea948844 | |||
| 59bb0070e9 | |||
| ec2206ade0 | |||
| 7796f7d3bc | |||
| b806bf80b1 | |||
| 173ea58701 | |||
| 775b6ff4fd | |||
| b0f18461b3 | |||
| b8ccbfd222 | |||
| c2fa497137 | |||
| bdcfa6929c | |||
| 8470b58b60 | |||
| 002413c067 | |||
| ecce59e734 | |||
| 1935c76f30 | |||
| 81b7a3e665 | |||
| a68bf6fc8f | |||
| 459dd2d9c7 | |||
| fed4cc2a97 | |||
| 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,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@v5
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
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@v5
|
||||
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@v5
|
||||
with:
|
||||
ref: develop
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
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@v5
|
||||
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
|
||||
@@ -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@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
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@v5
|
||||
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
|
||||
f35de512c1a19843d15a9a3263a5104759d041ffc9d01249450babe0b0c3f889 ProxMenux-1.0.1.AppImage
|
||||
|
||||
+718
-23
@@ -2,40 +2,735 @@
|
||||
|
||||
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
|
||||
```
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**ProxMenux Monitor** - Made with ❤️ for the Proxmox community
|
||||
|
||||
@@ -144,3 +144,22 @@
|
||||
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;
|
||||
}
|
||||
|
||||
+79
-1
@@ -1,7 +1,85 @@
|
||||
"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}` } : {},
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
console.log("[v0] Auth status:", data)
|
||||
|
||||
const authenticated = data.auth_enabled ? data.authenticated : true
|
||||
|
||||
setAuthStatus({
|
||||
loading: false,
|
||||
authEnabled: data.auth_enabled,
|
||||
authConfigured: data.auth_configured,
|
||||
authenticated,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to check auth status:", 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="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</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,278 @@
|
||||
"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"))
|
||||
const data = await response.json()
|
||||
|
||||
console.log("[v0] Auth status for modal check:", data)
|
||||
|
||||
// Show modal if auth is not configured and not declined
|
||||
if (!data.auth_configured) {
|
||||
setTimeout(() => setOpen(true), 500)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to check auth status:", error)
|
||||
// Fail-safe: show modal if we can't check status
|
||||
setTimeout(() => setOpen(true), 500)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
+507
-265
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,366 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
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,
|
||||
Activity,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
Disc,
|
||||
Network,
|
||||
Box,
|
||||
Settings,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
|
||||
interface CategoryCheck {
|
||||
status: string
|
||||
reason?: string
|
||||
details?: any
|
||||
dismissable?: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
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 HealthStatusModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
getApiUrl: (path: string) => string
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: "cpu", label: "CPU Usage & Temperature", Icon: Cpu },
|
||||
{ key: "memory", label: "Memory & Swap", Icon: MemoryStick },
|
||||
{ key: "storage", label: "Storage Mounts & Space", Icon: HardDrive },
|
||||
{ key: "disks", label: "Disk I/O & Errors", Icon: Disc },
|
||||
{ key: "network", label: "Network Interfaces", Icon: Network },
|
||||
{ key: "vms", label: "VMs & Containers", Icon: Box },
|
||||
{ key: "services", label: "PVE Services", Icon: Settings },
|
||||
{ key: "logs", label: "System Logs", Icon: FileText },
|
||||
{ key: "updates", label: "System Updates", Icon: RefreshCw },
|
||||
{ key: "security", label: "Security & Certificates", Icon: Shield },
|
||||
]
|
||||
|
||||
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchHealthDetails()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const fetchHealthDetails = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/health/details"))
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch health details")
|
||||
}
|
||||
const data = await response.json()
|
||||
console.log("[v0] Health data received:", data)
|
||||
setHealthData(data)
|
||||
|
||||
const event = new CustomEvent("healthStatusUpdated", {
|
||||
detail: { status: data.overall },
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
} catch (err) {
|
||||
console.error("[v0] Error fetching health data:", err)
|
||||
setError(err instanceof Error ? err.message : "Unknown error")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
const statusUpper = status?.toUpperCase()
|
||||
switch (statusUpper) {
|
||||
case "OK":
|
||||
return <CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
case "WARNING":
|
||||
return <AlertTriangle className="h-5 w-5 text-yellow-500" />
|
||||
case "CRITICAL":
|
||||
return <XCircle className="h-5 w-5 text-red-500" />
|
||||
default:
|
||||
return <Activity className="h-5 w-5 text-gray-500" />
|
||||
}
|
||||
}
|
||||
|
||||
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 "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>
|
||||
default:
|
||||
return <Badge>Unknown</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
const getHealthStats = () => {
|
||||
if (!healthData?.details) {
|
||||
return { total: 0, healthy: 0, warnings: 0, critical: 0 }
|
||||
}
|
||||
|
||||
let healthy = 0
|
||||
let warnings = 0
|
||||
let critical = 0
|
||||
|
||||
CATEGORIES.forEach(({ key }) => {
|
||||
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
||||
if (categoryData) {
|
||||
const status = categoryData.status?.toUpperCase()
|
||||
if (status === "OK") healthy++
|
||||
else if (status === "WARNING") warnings++
|
||||
else if (status === "CRITICAL") critical++
|
||||
}
|
||||
})
|
||||
|
||||
return { total: CATEGORIES.length, healthy, warnings, critical }
|
||||
}
|
||||
|
||||
const stats = getHealthStats()
|
||||
|
||||
const handleCategoryClick = (categoryKey: string, status: string) => {
|
||||
if (status === "OK") return // No navegar si está OK
|
||||
|
||||
onOpenChange(false) // Cerrar el modal
|
||||
|
||||
// Mapear categorías a tabs
|
||||
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) {
|
||||
// Disparar evento para cambiar tab
|
||||
const event = new CustomEvent("changeTab", { detail: { tab: targetTab } })
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAcknowledge = async (errorKey: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation() // Prevent navigation
|
||||
|
||||
console.log("[v0] Dismissing error:", errorKey)
|
||||
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/health/acknowledge"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ error_key: errorKey }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
console.error("[v0] Acknowledge failed:", errorData)
|
||||
throw new Error(errorData.error || "Failed to acknowledge error")
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log("[v0] Acknowledge success:", result)
|
||||
|
||||
// Refresh health data
|
||||
await fetchHealthDetails()
|
||||
} catch (err) {
|
||||
console.error("[v0] Error acknowledging:", err)
|
||||
alert("Failed to dismiss error. Please try again.")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DialogTitle className="flex items-center gap-2 flex-1">
|
||||
<Activity className="h-6 w-6" />
|
||||
System Health Status
|
||||
{healthData && <div className="ml-2">{getStatusBadge(healthData.overall)}</div>}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription>Detailed health checks for all system components</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 grid-cols-4 gap-3 p-4 rounded-lg bg-muted/30 border">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{stats.total}</div>
|
||||
<div className="text-xs text-muted-foreground">Total Checks</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-500">{stats.healthy}</div>
|
||||
<div className="text-xs text-muted-foreground">Healthy</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-yellow-500">{stats.warnings}</div>
|
||||
<div className="text-xs text-muted-foreground">Warnings</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-red-500">{stats.critical}</div>
|
||||
<div className="text-xs text-muted-foreground">Critical</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{healthData.summary && healthData.summary !== "All systems operational" && (
|
||||
<div className="text-sm p-3 rounded-lg bg-muted/20 border">
|
||||
<span className="font-medium text-foreground">{healthData.summary}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{CATEGORIES.map(({ key, label, Icon }) => {
|
||||
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
||||
const status = categoryData?.status || "UNKNOWN"
|
||||
const reason = categoryData?.reason
|
||||
const details = categoryData?.details
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => handleCategoryClick(key, status)}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
|
||||
status === "OK"
|
||||
? "bg-card border-border hover:bg-muted/30"
|
||||
: status === "WARNING"
|
||||
? "bg-yellow-500/5 border-yellow-500/20 hover:bg-yellow-500/10 cursor-pointer"
|
||||
: status === "CRITICAL"
|
||||
? "bg-red-500/5 border-red-500/20 hover:bg-red-500/10 cursor-pointer"
|
||||
: "bg-muted/30 hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<div className="mt-0.5 flex-shrink-0 flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-blue-500" />
|
||||
{getStatusIcon(status)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<p className="font-medium text-sm">{label}</p>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`shrink-0 text-xs ${
|
||||
status === "OK"
|
||||
? "border-green-500 text-green-500 bg-transparent"
|
||||
: status === "WARNING"
|
||||
? "border-yellow-500 text-yellow-500 bg-yellow-500/5"
|
||||
: status === "CRITICAL"
|
||||
? "border-red-500 text-red-500 bg-red-500/5"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
{reason && <p className="text-xs text-muted-foreground mt-1">{reason}</p>}
|
||||
{details && typeof details === "object" && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{Object.entries(details).map(([detailKey, detailValue]: [string, any]) => {
|
||||
if (typeof detailValue === "object" && detailValue !== null) {
|
||||
const isDismissable = detailValue.dismissable !== false
|
||||
|
||||
return (
|
||||
<div
|
||||
key={detailKey}
|
||||
className="flex items-start justify-between gap-2 text-xs pl-3 border-l-2 border-muted py-1"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<span className="font-medium">{detailKey}:</span>
|
||||
{detailValue.reason && (
|
||||
<span className="ml-1 text-muted-foreground">{detailValue.reason}</span>
|
||||
)}
|
||||
</div>
|
||||
{(status === "WARNING" || status === "CRITICAL") && isDismissable && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 px-2 shrink-0 hover:bg-red-500/10 hover:border-red-500/50 bg-transparent"
|
||||
onClick={(e) => handleAcknowledge(detailKey, e)}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
<span className="text-xs">Dismiss</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
"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 } 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 [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="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
disabled={loading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</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.0.2</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,15 @@
|
||||
"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 } 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"
|
||||
|
||||
interface NetworkData {
|
||||
interfaces: NetworkInterface[]
|
||||
@@ -128,28 +130,17 @@ 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
|
||||
refreshInterval: 53000,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: true,
|
||||
})
|
||||
@@ -160,14 +151,27 @@ export function NetworkMetrics() {
|
||||
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 [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(() => getNetworkUnit())
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -202,8 +206,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)
|
||||
@@ -386,7 +398,7 @@ export function NetworkMetrics() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<NetworkTrafficChart timeframe={timeframe} onTotalsCalculated={setNetworkTotals} />
|
||||
<NetworkTrafficChart timeframe={timeframe} onTotalsCalculated={setNetworkTotals} networkUnit={networkUnit} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -688,6 +700,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 +735,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 +885,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 +959,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>
|
||||
|
||||
@@ -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,6 +71,7 @@ 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 },
|
||||
@@ -86,24 +89,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)
|
||||
@@ -318,15 +305,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 +330,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 +341,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 +377,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 +401,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 />} />
|
||||
|
||||
@@ -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,12 @@ import { NetworkMetrics } from "./network-metrics"
|
||||
import { VirtualMachines } from "./virtual-machines"
|
||||
import Hardware from "./hardware"
|
||||
import { SystemLogs } from "./system-logs"
|
||||
import { Settings } from "./settings"
|
||||
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 +29,8 @@ import {
|
||||
Box,
|
||||
Cpu,
|
||||
FileText,
|
||||
SettingsIcon,
|
||||
Terminal,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { ThemeToggle } from "./theme-toggle"
|
||||
@@ -47,11 +54,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...",
|
||||
})
|
||||
@@ -62,55 +78,37 @@ export function ProxmoxDashboard() {
|
||||
const [activeTab, setActiveTab] = useState("overview")
|
||||
const [showNavigation, setShowNavigation] = useState(true)
|
||||
const [lastScrollY, setLastScrollY] = useState(0)
|
||||
const [showHealthModal, setShowHealthModal] = useState(false)
|
||||
const { showReleaseNotes, setShowReleaseNotes } = useVersionCheck()
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
setIsServerConnected(false)
|
||||
setSystemStatus((prev) => ({
|
||||
@@ -119,16 +117,67 @@ 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(() => {
|
||||
// Siempre fetch inicial
|
||||
fetchSystemData()
|
||||
const interval = setInterval(fetchSystemData, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchSystemData])
|
||||
|
||||
// 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
|
||||
if (activeTab === "overview") {
|
||||
interval = setInterval(fetchSystemData, 30000) // 30 segundos
|
||||
} else {
|
||||
interval = setInterval(fetchSystemData, 60000) // 60 segundos
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval)
|
||||
}
|
||||
}, [fetchSystemData, 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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleHealthStatusUpdate = (event: CustomEvent) => {
|
||||
const { status } = 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,
|
||||
}))
|
||||
}
|
||||
|
||||
window.addEventListener("healthStatusUpdated", handleHealthStatusUpdate as EventListener)
|
||||
return () => {
|
||||
window.removeEventListener("healthStatusUpdated", handleHealthStatusUpdate as EventListener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -212,8 +261,12 @@ export function ProxmoxDashboard() {
|
||||
return "VMs & LXCs"
|
||||
case "hardware":
|
||||
return "Hardware"
|
||||
case "terminal":
|
||||
return "Terminal"
|
||||
case "logs":
|
||||
return "System Logs"
|
||||
case "settings":
|
||||
return "Settings"
|
||||
default:
|
||||
return "Navigation Menu"
|
||||
}
|
||||
@@ -222,6 +275,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 +289,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 +298,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">
|
||||
@@ -299,12 +351,17 @@ export function ProxmoxDashboard() {
|
||||
<span className="ml-1 capitalize">{systemStatus.status}</span>
|
||||
</Badge>
|
||||
|
||||
<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,7 +369,9 @@ export function ProxmoxDashboard() {
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
<ThemeToggle />
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Actions */}
|
||||
@@ -322,17 +381,28 @@ export function ProxmoxDashboard() {
|
||||
<span className="ml-1 capitalize hidden sm:inline">{systemStatus.status}</span>
|
||||
</Badge>
|
||||
|
||||
<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"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
|
||||
<ThemeToggle />
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<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>
|
||||
@@ -346,7 +416,7 @@ export function ProxmoxDashboard() {
|
||||
>
|
||||
<div className="container mx-auto px-4 md:px-6 pt-4 md: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 md:grid w-full grid-cols-8 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,6 +453,18 @@ export function ProxmoxDashboard() {
|
||||
>
|
||||
System Logs
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="terminal"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Terminal
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="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}>
|
||||
@@ -491,6 +573,36 @@ export function ProxmoxDashboard() {
|
||||
<FileText className="h-5 w-5" />
|
||||
<span>System Logs</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("terminal")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "terminal"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Terminal className="h-5 w-5" />
|
||||
<span>Terminal</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("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 +635,18 @@ 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="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.0.2</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://ko-fi.com/macrimi"
|
||||
@@ -539,6 +659,8 @@ export function ProxmoxDashboard() {
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<HealthStatusModal open={showHealthModal} onOpenChange={setShowHealthModal} getApiUrl={getApiUrl} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||
import { X, Sparkles, Link2, Shield, Zap, HardDrive, Gauge, Wrench, Settings } from "lucide-react"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
|
||||
const APP_VERSION = "1.0.2" // Sync with AppImage/package.json
|
||||
|
||||
interface ReleaseNote {
|
||||
date: string
|
||||
changes: {
|
||||
added?: string[]
|
||||
changed?: string[]
|
||||
fixed?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
"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",
|
||||
"Enhanced Storage Display - Better formatting for disk sizes (auto-converts GB to TB when needed)",
|
||||
"SATA/SAS Information - View detailed interface information for all storage devices",
|
||||
"Two-Factor Authentication (2FA) - Enhanced security with TOTP support",
|
||||
"Health Monitoring System - Comprehensive system health checks with dismissible warnings",
|
||||
"Release Notes Modal - Automatic notification of new features and improvements",
|
||||
],
|
||||
changed: [
|
||||
"Optimized VM & LXC page - Reduced CPU usage by 85% through intelligent caching",
|
||||
"Storage metrics now separate local and remote storage for clarity",
|
||||
"Update warnings now appear only after 365 days instead of 30 days",
|
||||
"API intervals staggered to distribute server load (23s and 37s)",
|
||||
],
|
||||
fixed: [
|
||||
"Fixed dark mode text contrast issues in various components",
|
||||
"Corrected storage calculation discrepancies between Overview and Storage pages",
|
||||
"Resolved JSON stringify error in VM control actions",
|
||||
"Improved IP address fetching for LXC containers",
|
||||
],
|
||||
},
|
||||
},
|
||||
"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: <Link2 className="h-5 w-5" />,
|
||||
text: "Proxy Support - Access ProxMenux through reverse proxies with full functionality",
|
||||
},
|
||||
{
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
text: "Two-Factor Authentication (2FA) - Enhanced security with TOTP support for login protection",
|
||||
},
|
||||
{
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
text: "Performance Improvements - Optimized loading times and reduced CPU usage across the application",
|
||||
},
|
||||
{
|
||||
icon: <HardDrive className="h-5 w-5" />,
|
||||
text: "Storage Enhancements - Improved disk space consumption display with local and remote storage separation",
|
||||
},
|
||||
{
|
||||
icon: <Gauge className="h-5 w-5" />,
|
||||
text: "PCIe Link Speed Detection - View NVMe drive connection speeds and identify performance bottlenecks",
|
||||
},
|
||||
{
|
||||
icon: <Wrench className="h-5 w-5" />,
|
||||
text: "Hardware Page Improvements - Enhanced hardware information display with detailed PCIe and interface data",
|
||||
},
|
||||
{
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
text: "New Settings Page - Centralized configuration for authentication, optimizations, and system preferences",
|
||||
},
|
||||
]
|
||||
|
||||
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,910 @@
|
||||
"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,
|
||||
} from "lucide-react"
|
||||
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
|
||||
}
|
||||
|
||||
export function ScriptTerminalModal({
|
||||
open: isOpen,
|
||||
onClose,
|
||||
scriptPath,
|
||||
title,
|
||||
description,
|
||||
}: 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 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: {
|
||||
EXECUTION_MODE: "web",
|
||||
},
|
||||
}
|
||||
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) => {
|
||||
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: {
|
||||
EXECUTION_MODE: "web",
|
||||
},
|
||||
}
|
||||
|
||||
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) => {
|
||||
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-2 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-4 w-4 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-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
|
||||
>
|
||||
<CornerDownLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\x03")
|
||||
}}
|
||||
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]"
|
||||
>
|
||||
CTRL+C
|
||||
</Button>
|
||||
</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 hover:bg-red-700 border-red-500 text-white"
|
||||
>
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,955 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut, Wrench, Package, Key, Copy, Eye, EyeOff, Ruler } from 'lucide-react'
|
||||
import { APP_VERSION } from "./release-notes-modal"
|
||||
import { getApiUrl, fetchApi } from "../lib/api-config"
|
||||
import { TwoFactorSetup } from "./two-factor-setup"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { getNetworkUnit } from "../lib/format-network"
|
||||
|
||||
interface ProxMenuxTool {
|
||||
key: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const [authEnabled, setAuthEnabled] = useState(false)
|
||||
const [totpEnabled, setTotpEnabled] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [success, setSuccess] = useState("")
|
||||
|
||||
// Setup form state
|
||||
const [showSetupForm, setShowSetupForm] = useState(false)
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [confirmPassword, setConfirmPassword] = useState("")
|
||||
|
||||
// Change password form state
|
||||
const [showChangePassword, setShowChangePassword] = useState(false)
|
||||
const [currentPassword, setCurrentPassword] = useState("")
|
||||
const [newPassword, setNewPassword] = useState("")
|
||||
const [confirmNewPassword, setConfirmNewPassword] = useState("")
|
||||
|
||||
const [show2FASetup, setShow2FASetup] = useState(false)
|
||||
const [show2FADisable, setShow2FADisable] = useState(false)
|
||||
const [disable2FAPassword, setDisable2FAPassword] = useState("")
|
||||
|
||||
const [proxmenuxTools, setProxmenuxTools] = useState<ProxMenuxTool[]>([])
|
||||
const [loadingTools, setLoadingTools] = useState(true)
|
||||
const [expandedVersions, setExpandedVersions] = useState<Record<string, boolean>>({
|
||||
[APP_VERSION]: true, // Current version expanded by default
|
||||
})
|
||||
|
||||
// API Token state management
|
||||
const [showApiTokenSection, setShowApiTokenSection] = useState(false)
|
||||
const [apiToken, setApiToken] = useState("")
|
||||
const [apiTokenVisible, setApiTokenVisible] = useState(false)
|
||||
const [tokenPassword, setTokenPassword] = useState("")
|
||||
const [tokenTotpCode, setTokenTotpCode] = useState("")
|
||||
const [generatingToken, setGeneratingToken] = useState(false)
|
||||
const [tokenCopied, setTokenCopied] = useState(false)
|
||||
|
||||
const [networkUnitSettings, setNetworkUnitSettings] = useState<"Bytes" | "Bits">("Bytes")
|
||||
const [loadingUnitSettings, setLoadingUnitSettings] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuthStatus()
|
||||
loadProxmenuxTools()
|
||||
getUnitsSettings() // Load units settings on mount
|
||||
}, [])
|
||||
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/auth/status"))
|
||||
const data = await response.json()
|
||||
setAuthEnabled(data.auth_enabled || false)
|
||||
setTotpEnabled(data.totp_enabled || false) // Get 2FA status
|
||||
} catch (err) {
|
||||
console.error("Failed to check auth status:", err)
|
||||
}
|
||||
}
|
||||
|
||||
const loadProxmenuxTools = async () => {
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/proxmenux/installed-tools"))
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
setProxmenuxTools(data.installed_tools || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load ProxMenux tools:", err)
|
||||
} finally {
|
||||
setLoadingTools(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnableAuth = async () => {
|
||||
setError("")
|
||||
setSuccess("")
|
||||
|
||||
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 {
|
||||
const response = await fetch(getApiUrl("/api/auth/setup"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
enable_auth: true,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Failed to enable authentication")
|
||||
}
|
||||
|
||||
// Save token
|
||||
localStorage.setItem("proxmenux-auth-token", data.token)
|
||||
localStorage.setItem("proxmenux-auth-setup-complete", "true")
|
||||
|
||||
setSuccess("Authentication enabled successfully!")
|
||||
setAuthEnabled(true)
|
||||
setShowSetupForm(false)
|
||||
setUsername("")
|
||||
setPassword("")
|
||||
setConfirmPassword("")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to enable authentication")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisableAuth = async () => {
|
||||
if (
|
||||
!confirm(
|
||||
"Are you sure you want to disable authentication? This will remove password protection from your dashboard.",
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError("")
|
||||
setSuccess("")
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("proxmenux-auth-token")
|
||||
const response = await fetch(getApiUrl("/api/auth/disable"), {
|
||||
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 disable authentication")
|
||||
}
|
||||
|
||||
localStorage.removeItem("proxmenux-auth-token")
|
||||
localStorage.removeItem("proxmenux-auth-setup-complete")
|
||||
|
||||
setSuccess("Authentication disabled successfully! Reloading...")
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to disable authentication. Please try again.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
setError("")
|
||||
setSuccess("")
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
setError("Please fill in all fields")
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword !== confirmNewPassword) {
|
||||
setError("New passwords do not match")
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError("Password must be at least 6 characters")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/auth/change-password"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("proxmenux-auth-token")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Failed to change password")
|
||||
}
|
||||
|
||||
// Update token if provided
|
||||
if (data.token) {
|
||||
localStorage.setItem("proxmenux-auth-token", data.token)
|
||||
}
|
||||
|
||||
setSuccess("Password changed successfully!")
|
||||
setShowChangePassword(false)
|
||||
setCurrentPassword("")
|
||||
setNewPassword("")
|
||||
setConfirmNewPassword("")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to change password")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisable2FA = async () => {
|
||||
setError("")
|
||||
setSuccess("")
|
||||
|
||||
if (!disable2FAPassword) {
|
||||
setError("Please enter your password")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("proxmenux-auth-token")
|
||||
const response = await fetch(getApiUrl("/api/auth/totp/disable"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ password: disable2FAPassword }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || "Failed to disable 2FA")
|
||||
}
|
||||
|
||||
setSuccess("2FA disabled successfully!")
|
||||
setTotpEnabled(false)
|
||||
setShow2FADisable(false)
|
||||
setDisable2FAPassword("")
|
||||
checkAuthStatus()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to disable 2FA")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("proxmenux-auth-token")
|
||||
localStorage.removeItem("proxmenux-auth-setup-complete")
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const handleGenerateApiToken = async () => {
|
||||
setError("")
|
||||
setSuccess("")
|
||||
|
||||
if (!tokenPassword) {
|
||||
setError("Please enter your password")
|
||||
return
|
||||
}
|
||||
|
||||
if (totpEnabled && !tokenTotpCode) {
|
||||
setError("Please enter your 2FA code")
|
||||
return
|
||||
}
|
||||
|
||||
setGeneratingToken(true)
|
||||
|
||||
try {
|
||||
const data = await fetchApi("/api/auth/generate-api-token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
password: tokenPassword,
|
||||
totp_token: totpEnabled ? tokenTotpCode : undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
setError(data.message || data.error || "Failed to generate API token")
|
||||
return
|
||||
}
|
||||
|
||||
if (!data.token) {
|
||||
setError("No token received from server")
|
||||
return
|
||||
}
|
||||
|
||||
setApiToken(data.token)
|
||||
setSuccess("API token generated successfully! Make sure to copy it now as you won't be able to see it again.")
|
||||
setTokenPassword("")
|
||||
setTokenTotpCode("")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to generate API token. Please try again.")
|
||||
} finally {
|
||||
setGeneratingToken(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyApiToken = () => {
|
||||
navigator.clipboard.writeText(apiToken)
|
||||
setTokenCopied(true)
|
||||
setTimeout(() => setTokenCopied(false), 2000)
|
||||
}
|
||||
|
||||
const toggleVersion = (version: string) => {
|
||||
setExpandedVersions((prev) => ({
|
||||
...prev,
|
||||
[version]: !prev[version],
|
||||
}))
|
||||
}
|
||||
|
||||
const changeNetworkUnit = (unit: string) => {
|
||||
const networkUnit = unit as "Bytes" | "Bits"
|
||||
localStorage.setItem("proxmenux-network-unit", networkUnit)
|
||||
setNetworkUnitSettings(networkUnit)
|
||||
|
||||
// Dispatch custom event to notify other components
|
||||
window.dispatchEvent(new CustomEvent("networkUnitChanged", { detail: networkUnit }))
|
||||
|
||||
// Also dispatch storage event for backward compatibility
|
||||
window.dispatchEvent(new StorageEvent("storage", {
|
||||
key: "proxmenux-network-unit",
|
||||
newValue: networkUnit,
|
||||
url: window.location.href
|
||||
}))
|
||||
}
|
||||
|
||||
const getUnitsSettings = () => {
|
||||
const networkUnit = getNetworkUnit()
|
||||
setNetworkUnitSettings(networkUnit)
|
||||
setLoadingUnitSettings(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Settings</h1>
|
||||
<p className="text-muted-foreground mt-2">Manage your dashboard security and preferences</p>
|
||||
</div>
|
||||
|
||||
{/* Authentication Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-blue-500" />
|
||||
<CardTitle>Authentication</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Protect your dashboard with username and password authentication</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent 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>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-3 flex items-start gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-green-500">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center ${authEnabled ? "bg-green-500/10" : "bg-gray-500/10"}`}
|
||||
>
|
||||
<Lock className={`h-5 w-5 ${authEnabled ? "text-green-500" : "text-gray-500"}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Authentication Status</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{authEnabled ? "Password protection is enabled" : "No password protection"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium ${authEnabled ? "bg-green-500/10 text-green-500" : "bg-gray-500/10 text-gray-500"}`}
|
||||
>
|
||||
{authEnabled ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!authEnabled && !showSetupForm && (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 flex items-start gap-2">
|
||||
<Info className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-blue-500">
|
||||
Enable authentication to protect your dashboard when accessing from non-private networks.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowSetupForm(true)} className="w-full bg-blue-500 hover:bg-blue-600">
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Enable Authentication
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!authEnabled && showSetupForm && (
|
||||
<div className="space-y-4 border border-border rounded-lg p-4">
|
||||
<h3 className="font-semibold">Setup Authentication</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="setup-username">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="setup-username"
|
||||
type="text"
|
||||
placeholder="Enter username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="pl-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="setup-password">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="setup-password"
|
||||
type="password"
|
||||
placeholder="Enter password (min 6 characters)"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="setup-confirm-password">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="setup-confirm-password"
|
||||
type="password"
|
||||
placeholder="Confirm password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="pl-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleEnableAuth} className="flex-1 bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||
{loading ? "Enabling..." : "Enable"}
|
||||
</Button>
|
||||
<Button onClick={() => setShowSetupForm(false)} variant="outline" className="flex-1" disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authEnabled && (
|
||||
<div className="space-y-3">
|
||||
<Button onClick={handleLogout} variant="outline" className="w-full bg-transparent">
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Logout
|
||||
</Button>
|
||||
|
||||
{!showChangePassword && (
|
||||
<Button onClick={() => setShowChangePassword(true)} variant="outline" className="w-full">
|
||||
<Lock className="h-4 w-4 mr-2" />
|
||||
Change Password
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showChangePassword && (
|
||||
<div className="space-y-4 border border-border rounded-lg p-4">
|
||||
<h3 className="font-semibold">Change Password</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password">Current 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="current-password"
|
||||
type="password"
|
||||
placeholder="Enter current password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="pl-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">New 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="new-password"
|
||||
type="password"
|
||||
placeholder="Enter new password (min 6 characters)"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="pl-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-new-password">Confirm New 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-new-password"
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
value={confirmNewPassword}
|
||||
onChange={(e) => setConfirmNewPassword(e.target.value)}
|
||||
className="pl-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleChangePassword}
|
||||
className="flex-1 bg-blue-500 hover:bg-blue-600"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Changing..." : "Change Password"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowChangePassword(false)}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!totpEnabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 flex items-start gap-2">
|
||||
<Info className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-400">
|
||||
<p className="font-medium mb-1">Two-Factor Authentication (2FA)</p>
|
||||
<p className="text-blue-300">
|
||||
Add an extra layer of security by requiring a code from your authenticator app in addition to
|
||||
your password.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setShow2FASetup(true)} variant="outline" className="w-full">
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Enable Two-Factor Authentication
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totpEnabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-3 flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<p className="text-sm text-green-500 font-medium">2FA is enabled</p>
|
||||
</div>
|
||||
|
||||
{!show2FADisable && (
|
||||
<Button onClick={() => setShow2FADisable(true)} variant="outline" className="w-full">
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Disable 2FA
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{show2FADisable && (
|
||||
<div className="space-y-4 border border-border rounded-lg p-4">
|
||||
<h3 className="font-semibold">Disable Two-Factor Authentication</h3>
|
||||
<p className="text-sm text-muted-foreground">Enter your password to confirm</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disable-2fa-password">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="disable-2fa-password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={disable2FAPassword}
|
||||
onChange={(e) => setDisable2FAPassword(e.target.value)}
|
||||
className="pl-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleDisable2FA} variant="destructive" className="flex-1" disabled={loading}>
|
||||
{loading ? "Disabling..." : "Disable 2FA"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShow2FADisable(false)
|
||||
setDisable2FAPassword("")
|
||||
setError("")
|
||||
}}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleDisableAuth} variant="destructive" className="w-full" disabled={loading}>
|
||||
Disable Authentication
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Network Units Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5 text-green-500" />
|
||||
<CardTitle>Network Units</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Change how network traffic is displayed</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingUnitSettings ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-green-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-foreground flex items-center justify-between">
|
||||
<div className="flex items-center">Network Unit Display</div>
|
||||
<Select value={networkUnitSettings} onValueChange={changeNetworkUnit}>
|
||||
<SelectTrigger className="w-28 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Bytes">Bytes</SelectItem>
|
||||
<SelectItem value="Bits">Bits</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* API Access Tokens */}
|
||||
{authEnabled && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5 text-purple-500" />
|
||||
<CardTitle>API Access Tokens</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Generate long-lived API tokens for external integrations like Homepage and Home Assistant
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent 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>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-3 flex items-start gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-green-500">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-2 text-sm text-blue-400">
|
||||
<p className="font-medium">About API Tokens</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-blue-300">
|
||||
<li>Tokens are valid for 1 year</li>
|
||||
<li>Use them to access APIs from external services</li>
|
||||
<li>Include in Authorization header: Bearer YOUR_TOKEN</li>
|
||||
<li>See README.md for complete integration examples</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!showApiTokenSection && !apiToken && (
|
||||
<Button onClick={() => setShowApiTokenSection(true)} className="w-full bg-purple-500 hover:bg-purple-600">
|
||||
<Key className="h-4 w-4 mr-2" />
|
||||
Generate New API Token
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showApiTokenSection && !apiToken && (
|
||||
<div className="space-y-4 border border-border rounded-lg p-4">
|
||||
<h3 className="font-semibold">Generate API Token</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your credentials to generate a new long-lived API token
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token-password">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="token-password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={tokenPassword}
|
||||
onChange={(e) => setTokenPassword(e.target.value)}
|
||||
className="pl-10"
|
||||
disabled={generatingToken}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totpEnabled && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token-totp">2FA Code</Label>
|
||||
<div className="relative">
|
||||
<Shield className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="token-totp"
|
||||
type="text"
|
||||
placeholder="Enter 6-digit code"
|
||||
value={tokenTotpCode}
|
||||
onChange={(e) => setTokenTotpCode(e.target.value)}
|
||||
className="pl-10"
|
||||
maxLength={6}
|
||||
disabled={generatingToken}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleGenerateApiToken}
|
||||
className="flex-1 bg-purple-500 hover:bg-purple-600"
|
||||
disabled={generatingToken}
|
||||
>
|
||||
{generatingToken ? "Generating..." : "Generate Token"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowApiTokenSection(false)
|
||||
setTokenPassword("")
|
||||
setTokenTotpCode("")
|
||||
setError("")
|
||||
}}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={generatingToken}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiToken && (
|
||||
<div className="space-y-4 border border-green-500/20 bg-green-500/5 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-green-500">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
<h3 className="font-semibold">Your API Token</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3 flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400 font-semibold">
|
||||
⚠️ Important: Save this token now!
|
||||
</p>
|
||||
<p className="text-xs text-amber-600/80 dark:text-amber-400/80">
|
||||
You won't be able to see it again. Store it securely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Token</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={apiToken}
|
||||
readOnly
|
||||
type={apiTokenVisible ? "text" : "password"}
|
||||
className="pr-20 font-mono text-sm"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setApiTokenVisible(!apiTokenVisible)}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
{apiTokenVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={copyApiToken} className="h-7 w-7 p-0">
|
||||
<Copy className={`h-4 w-4 ${tokenCopied ? "text-green-500" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{tokenCopied && (
|
||||
<p className="text-xs text-green-500 flex items-center gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Copied to clipboard!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">How to use this token:</p>
|
||||
<div className="bg-muted/50 rounded p-3 text-xs font-mono">
|
||||
<p className="text-muted-foreground mb-2"># Add to request headers:</p>
|
||||
<p>Authorization: Bearer YOUR_TOKEN_HERE</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
See the README documentation for complete integration examples with Homepage and Home Assistant.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setApiToken("")
|
||||
setShowApiTokenSection(false)
|
||||
}}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ProxMenux Optimizations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench className="h-5 w-5 text-orange-500" />
|
||||
<CardTitle>ProxMenux Optimizations</CardTitle>
|
||||
</div>
|
||||
<CardDescription>System optimizations and utilities installed via ProxMenux</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingTools ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-orange-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : proxmenuxTools.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Package className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" />
|
||||
<p className="text-muted-foreground">No ProxMenux optimizations installed yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Run ProxMenux to configure system optimizations</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between mb-4 pb-2 border-b border-border">
|
||||
<span className="text-sm font-medium text-muted-foreground">Installed Tools</span>
|
||||
<span className="text-sm font-semibold text-orange-500">{proxmenuxTools.length} active</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{proxmenuxTools.map((tool) => (
|
||||
<div
|
||||
key={tool.key}
|
||||
className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg border border-border hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
|
||||
<span className="text-sm font-medium">{tool.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TwoFactorSetup
|
||||
open={show2FASetup}
|
||||
onClose={() => setShow2FASetup(false)}
|
||||
onSuccess={() => {
|
||||
setSuccess("2FA enabled successfully!")
|
||||
checkAuthStatus()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer } from "lucide-react"
|
||||
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
|
||||
interface DiskInfo {
|
||||
name: string
|
||||
@@ -64,6 +65,7 @@ interface ProxmoxStorage {
|
||||
used: number
|
||||
available: number
|
||||
percent: number
|
||||
node: string // Added node property for detailed debug logging
|
||||
}
|
||||
|
||||
interface ProxmoxStorageData {
|
||||
@@ -75,12 +77,11 @@ 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 if (sizeInGB > 999) {
|
||||
return `${(sizeInGB / 1024).toFixed(2)} TB`
|
||||
} else {
|
||||
// 1024 GB or more, show in TB
|
||||
return `${(sizeInGB / 1024).toFixed(1)} TB`
|
||||
// Between 1 and 999 GB, show in GB
|
||||
return `${sizeInGB.toFixed(2)} GB`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,20 +94,11 @@ export function StorageOverview() {
|
||||
|
||||
const fetchStorageData = async () => {
|
||||
try {
|
||||
const baseUrl =
|
||||
typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
|
||||
const [storageResponse, proxmoxResponse] = await Promise.all([
|
||||
fetch(`${baseUrl}/api/storage`),
|
||||
fetch(`${baseUrl}/api/proxmox-storage`),
|
||||
const [data, proxmoxData] = await Promise.all([
|
||||
fetchApi<StorageData>("/api/storage"),
|
||||
fetchApi<ProxmoxStorageData>("/api/proxmox-storage"),
|
||||
])
|
||||
|
||||
const data = await storageResponse.json()
|
||||
const proxmoxData = await proxmoxResponse.json()
|
||||
|
||||
console.log("[v0] Storage data received:", data)
|
||||
console.log("[v0] Proxmox storage data received:", proxmoxData)
|
||||
|
||||
setStorageData(data)
|
||||
setProxmoxStorage(proxmoxData)
|
||||
} catch (error) {
|
||||
@@ -211,6 +203,12 @@ export function StorageOverview() {
|
||||
if (diskName.startsWith("nvme")) {
|
||||
return "NVMe"
|
||||
}
|
||||
// rotation_rate = -1 means HDD but RPM is unknown (detected via kernel rotational flag)
|
||||
// rotation_rate = 0 or undefined means SSD
|
||||
// rotation_rate > 0 means HDD with known RPM
|
||||
if (rotationRate === -1) {
|
||||
return "HDD"
|
||||
}
|
||||
if (!rotationRate || rotationRate === 0) {
|
||||
return "SSD"
|
||||
}
|
||||
@@ -393,20 +391,88 @@ export function StorageOverview() {
|
||||
return "[&>div]:bg-red-500"
|
||||
}
|
||||
|
||||
const getUsageColor = (percent: number): string => {
|
||||
if (percent < 70) return "text-blue-500"
|
||||
if (percent < 85) return "text-yellow-500"
|
||||
if (percent < 95) return "text-orange-500"
|
||||
return "text-red-500"
|
||||
}
|
||||
|
||||
const diskHealthBreakdown = getDiskHealthBreakdown()
|
||||
const diskTypesBreakdown = getDiskTypesBreakdown()
|
||||
|
||||
const totalProxmoxUsed =
|
||||
proxmoxStorage && proxmoxStorage.storage
|
||||
? proxmoxStorage.storage
|
||||
.filter(
|
||||
(storage) => storage && storage.total > 0 && storage.status && storage.status.toLowerCase() === "active",
|
||||
)
|
||||
.reduce((sum, storage) => sum + storage.used, 0)
|
||||
: 0
|
||||
const localStorageTypes = ["dir", "lvmthin", "lvm", "zfspool", "btrfs"]
|
||||
const remoteStorageTypes = ["pbs", "nfs", "cifs", "smb", "glusterfs", "iscsi", "iscsidirect", "rbd", "cephfs"]
|
||||
|
||||
const usagePercent =
|
||||
storageData && storageData.total > 0 ? ((totalProxmoxUsed / (storageData.total * 1024)) * 100).toFixed(2) : "0.00"
|
||||
const totalLocalUsed =
|
||||
proxmoxStorage?.storage
|
||||
.filter(
|
||||
(storage) =>
|
||||
storage &&
|
||||
storage.name &&
|
||||
storage.status === "active" &&
|
||||
storage.total > 0 &&
|
||||
storage.used >= 0 &&
|
||||
storage.available >= 0 &&
|
||||
localStorageTypes.includes(storage.type.toLowerCase()),
|
||||
)
|
||||
.reduce((sum, storage) => sum + storage.used, 0) || 0
|
||||
|
||||
const totalLocalCapacity =
|
||||
proxmoxStorage?.storage
|
||||
.filter(
|
||||
(storage) =>
|
||||
storage &&
|
||||
storage.name &&
|
||||
storage.status === "active" &&
|
||||
storage.total > 0 &&
|
||||
storage.used >= 0 &&
|
||||
storage.available >= 0 &&
|
||||
localStorageTypes.includes(storage.type.toLowerCase()),
|
||||
)
|
||||
.reduce((sum, storage) => sum + storage.total, 0) || 0
|
||||
|
||||
const localUsagePercent = totalLocalCapacity > 0 ? ((totalLocalUsed / totalLocalCapacity) * 100).toFixed(2) : "0.00"
|
||||
|
||||
const totalRemoteUsed =
|
||||
proxmoxStorage?.storage
|
||||
.filter(
|
||||
(storage) =>
|
||||
storage &&
|
||||
storage.name &&
|
||||
storage.status === "active" &&
|
||||
storage.total > 0 &&
|
||||
storage.used >= 0 &&
|
||||
storage.available >= 0 &&
|
||||
remoteStorageTypes.includes(storage.type.toLowerCase()),
|
||||
)
|
||||
.reduce((sum, storage) => sum + storage.used, 0) || 0
|
||||
|
||||
const totalRemoteCapacity =
|
||||
proxmoxStorage?.storage
|
||||
.filter(
|
||||
(storage) =>
|
||||
storage &&
|
||||
storage.name &&
|
||||
storage.status === "active" &&
|
||||
storage.total > 0 &&
|
||||
storage.used >= 0 &&
|
||||
storage.available >= 0 &&
|
||||
remoteStorageTypes.includes(storage.type.toLowerCase()),
|
||||
)
|
||||
.reduce((sum, storage) => sum + storage.total, 0) || 0
|
||||
|
||||
const remoteUsagePercent =
|
||||
totalRemoteCapacity > 0 ? ((totalRemoteUsed / totalRemoteCapacity) * 100).toFixed(2) : "0.00"
|
||||
|
||||
const remoteStorageCount =
|
||||
proxmoxStorage?.storage.filter(
|
||||
(storage) =>
|
||||
storage &&
|
||||
storage.name &&
|
||||
storage.status === "active" &&
|
||||
remoteStorageTypes.includes(storage.type.toLowerCase()),
|
||||
).length || 0
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -441,64 +507,81 @@ export function StorageOverview() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Used Storage</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">Local Used</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold">{formatStorage(totalProxmoxUsed)}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{usagePercent}% used</p>
|
||||
<div className="text-xl lg:text-2xl font-bold">{formatStorage(totalLocalUsed)}</div>
|
||||
<p className="text-xs mt-1">
|
||||
<span className={getUsageColor(Number.parseFloat(localUsagePercent))}>{localUsagePercent}%</span>
|
||||
<span className="text-muted-foreground"> of </span>
|
||||
<span className="text-green-500">{formatStorage(totalLocalCapacity)}</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Disk Health */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Disk Health</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium">Remote Used</CardTitle>
|
||||
<Archive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold">{storageData.disk_count} disks</div>
|
||||
<div className="text-xl lg:text-2xl font-bold">
|
||||
{remoteStorageCount > 0 ? formatStorage(totalRemoteUsed) : "None"}
|
||||
</div>
|
||||
<p className="text-xs mt-1">
|
||||
<span className="text-green-500">{diskHealthBreakdown.normal} normal</span>
|
||||
{diskHealthBreakdown.warning > 0 && (
|
||||
{remoteStorageCount > 0 ? (
|
||||
<>
|
||||
{", "}
|
||||
<span className="text-yellow-500">{diskHealthBreakdown.warning} warning</span>
|
||||
</>
|
||||
)}
|
||||
{diskHealthBreakdown.critical > 0 && (
|
||||
<>
|
||||
{", "}
|
||||
<span className="text-red-500">{diskHealthBreakdown.critical} critical</span>
|
||||
<span className={getUsageColor(Number.parseFloat(remoteUsagePercent))}>{remoteUsagePercent}%</span>
|
||||
<span className="text-muted-foreground"> of </span>
|
||||
<span className="text-green-500">{formatStorage(totalRemoteCapacity)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">No remote storage</span>
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Disk Types */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Disk Types</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">Physical Disks</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold">{storageData.disk_count} disks</div>
|
||||
<p className="text-xs mt-1">
|
||||
{diskTypesBreakdown.nvme > 0 && <span className="text-purple-500">{diskTypesBreakdown.nvme} NVMe</span>}
|
||||
{diskTypesBreakdown.ssd > 0 && (
|
||||
<>
|
||||
{diskTypesBreakdown.nvme > 0 && ", "}
|
||||
<span className="text-cyan-500">{diskTypesBreakdown.ssd} SSD</span>
|
||||
</>
|
||||
)}
|
||||
{diskTypesBreakdown.hdd > 0 && (
|
||||
<>
|
||||
{(diskTypesBreakdown.nvme > 0 || diskTypesBreakdown.ssd > 0) && ", "}
|
||||
<span className="text-blue-500">{diskTypesBreakdown.hdd} HDD</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="space-y-1 mt-1">
|
||||
<p className="text-xs">
|
||||
{diskTypesBreakdown.nvme > 0 && <span className="text-purple-500">{diskTypesBreakdown.nvme} NVMe</span>}
|
||||
{diskTypesBreakdown.ssd > 0 && (
|
||||
<>
|
||||
{diskTypesBreakdown.nvme > 0 && ", "}
|
||||
<span className="text-cyan-500">{diskTypesBreakdown.ssd} SSD</span>
|
||||
</>
|
||||
)}
|
||||
{diskTypesBreakdown.hdd > 0 && (
|
||||
<>
|
||||
{(diskTypesBreakdown.nvme > 0 || diskTypesBreakdown.ssd > 0) && ", "}
|
||||
<span className="text-blue-500">{diskTypesBreakdown.hdd} HDD</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
<span className="text-green-500">{diskHealthBreakdown.normal} normal</span>
|
||||
{diskHealthBreakdown.warning > 0 && (
|
||||
<>
|
||||
{", "}
|
||||
<span className="text-yellow-500">{diskHealthBreakdown.warning} warning</span>
|
||||
</>
|
||||
)}
|
||||
{diskHealthBreakdown.critical > 0 && (
|
||||
<>
|
||||
{", "}
|
||||
<span className="text-red-500">{diskHealthBreakdown.critical} critical</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -514,10 +597,15 @@ export function StorageOverview() {
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{proxmoxStorage.storage
|
||||
.filter((storage) => storage && storage.name && storage.total > 0)
|
||||
.filter((storage) => storage && storage.name && storage.used >= 0 && storage.available >= 0)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((storage) => (
|
||||
<div key={storage.name} className="border rounded-lg p-4">
|
||||
<div
|
||||
key={storage.name}
|
||||
className={`border rounded-lg p-4 ${
|
||||
storage.status === "error" ? "border-red-500/50 bg-red-500/5" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
{/* Desktop: Icon + Name + Badge tipo alineados horizontalmente */}
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
@@ -539,7 +627,9 @@ export function StorageOverview() {
|
||||
className={
|
||||
storage.status === "active"
|
||||
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
: "bg-gray-500/10 text-gray-500 border-gray-500/20"
|
||||
: storage.status === "error"
|
||||
? "bg-red-500/10 text-red-500 border-red-500/20"
|
||||
: "bg-gray-500/10 text-gray-500 border-gray-500/20"
|
||||
}
|
||||
>
|
||||
{storage.status}
|
||||
@@ -562,7 +652,7 @@ export function StorageOverview() {
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Total</p>
|
||||
<p className="font-medium">{storage.total.toLocaleString()} GB</p>
|
||||
<p className="font-medium">{formatStorage(storage.total)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Used</p>
|
||||
@@ -575,12 +665,12 @@ export function StorageOverview() {
|
||||
: "text-blue-400"
|
||||
}`}
|
||||
>
|
||||
{storage.used.toLocaleString()} GB
|
||||
{formatStorage(storage.used)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Available</p>
|
||||
<p className="font-medium text-green-400">{storage.available.toLocaleString()} GB</p>
|
||||
<p className="font-medium text-green-400">{formatStorage(storage.available)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+258
-222
@@ -27,7 +27,8 @@ import {
|
||||
Menu,
|
||||
Terminal,
|
||||
} from "lucide-react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import { API_PORT, fetchApi } from "@/lib/api-config"
|
||||
|
||||
interface Log {
|
||||
timestamp: string
|
||||
@@ -125,9 +126,20 @@ export function SystemLogs() {
|
||||
|
||||
const getApiUrl = (endpoint: string) => {
|
||||
if (typeof window !== "undefined") {
|
||||
return `${window.location.protocol}//${window.location.hostname}:8008${endpoint}`
|
||||
const { protocol, hostname, port } = window.location
|
||||
const isStandardPort = port === "" || port === "80" || port === "443"
|
||||
|
||||
if (isStandardPort) {
|
||||
return endpoint
|
||||
} else {
|
||||
return `${protocol}//${hostname}:${API_PORT}${endpoint}`
|
||||
}
|
||||
}
|
||||
return `http://localhost:8008${endpoint}`
|
||||
// This part might not be strictly necessary if only running client-side, but good for SSR safety
|
||||
// In a real SSR scenario, you'd need to handle API_PORT differently
|
||||
const protocol = typeof window !== "undefined" ? window.location.protocol : "http:" // Defaulting to http for SSR safety
|
||||
const hostname = typeof window !== "undefined" ? window.location.hostname : "localhost" // Defaulting to localhost for SSR safety
|
||||
return `${protocol}//${hostname}:${API_PORT}${endpoint}`
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -186,27 +198,15 @@ export function SystemLogs() {
|
||||
|
||||
const [logsRes, backupsRes, eventsRes, notificationsRes] = await Promise.all([
|
||||
fetchSystemLogs(),
|
||||
fetch(getApiUrl("/api/backups")),
|
||||
fetch(getApiUrl("/api/events?limit=50")),
|
||||
fetch(getApiUrl("/api/notifications")),
|
||||
fetchApi("/api/backups"),
|
||||
fetchApi("/api/events?limit=50"),
|
||||
fetchApi("/api/notifications"),
|
||||
])
|
||||
|
||||
setLogs(logsRes)
|
||||
|
||||
if (backupsRes.ok) {
|
||||
const backupsData = await backupsRes.json()
|
||||
setBackups(backupsData.backups || [])
|
||||
}
|
||||
|
||||
if (eventsRes.ok) {
|
||||
const eventsData = await eventsRes.json()
|
||||
setEvents(eventsData.events || [])
|
||||
}
|
||||
|
||||
if (notificationsRes.ok) {
|
||||
const notificationsData = await notificationsRes.json()
|
||||
setNotifications(notificationsData.notifications || [])
|
||||
}
|
||||
setBackups(backupsRes.backups || [])
|
||||
setEvents(eventsRes.events || [])
|
||||
setNotifications(notificationsRes.notifications || [])
|
||||
} catch (err) {
|
||||
console.error("[v0] Error fetching system logs data:", err)
|
||||
setError("Failed to connect to server")
|
||||
@@ -217,7 +217,7 @@ export function SystemLogs() {
|
||||
|
||||
const fetchSystemLogs = async (): Promise<SystemLog[]> => {
|
||||
try {
|
||||
let apiUrl = getApiUrl("/api/logs")
|
||||
let apiUrl = "/api/logs"
|
||||
const params = new URLSearchParams()
|
||||
|
||||
// CHANGE: Always add since_days parameter (no more "now" option)
|
||||
@@ -250,22 +250,7 @@ export function SystemLogs() {
|
||||
}
|
||||
|
||||
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()
|
||||
const data = await fetchApi(apiUrl)
|
||||
console.log("[v0] Received logs data, count:", data.logs?.length || 0)
|
||||
|
||||
const logsArray = Array.isArray(data) ? data : data.logs || []
|
||||
@@ -356,37 +341,33 @@ 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
|
||||
}
|
||||
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)
|
||||
// Fall through to download notification message
|
||||
@@ -421,39 +402,61 @@ export function SystemLogs() {
|
||||
}
|
||||
}
|
||||
|
||||
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 memoizedLogs = useMemo(() => logs, [logs])
|
||||
const memoizedEvents = useMemo(() => events, [events])
|
||||
const memoizedBackups = useMemo(() => backups, [backups])
|
||||
const memoizedNotifications = useMemo(() => notifications, [notifications])
|
||||
|
||||
const logsOnly: CombinedLogEntry[] = useMemo(
|
||||
() =>
|
||||
memoizedLogs
|
||||
.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() }))
|
||||
.sort((a, b) => b.sortTimestamp - a.sortTimestamp),
|
||||
[memoizedLogs],
|
||||
)
|
||||
|
||||
const eventsOnly: CombinedLogEntry[] = useMemo(
|
||||
() =>
|
||||
memoizedEvents
|
||||
.map((event) => ({
|
||||
timestamp: event.starttime,
|
||||
level: event.level,
|
||||
service: event.type,
|
||||
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`,
|
||||
source: `Node: ${event.node} • User: ${event.user}`,
|
||||
isEvent: true,
|
||||
eventData: event,
|
||||
sortTimestamp: new Date(event.starttime).getTime(),
|
||||
}))
|
||||
.sort((a, b) => b.sortTimestamp - a.sortTimestamp),
|
||||
[memoizedEvents],
|
||||
)
|
||||
|
||||
// Filter logs only
|
||||
const filteredLogsOnly = logsOnly.filter((log) => {
|
||||
const message = log.message || ""
|
||||
const service = log.service || ""
|
||||
const searchTermLower = safeToLowerCase(searchTerm)
|
||||
|
||||
const matchesSearch =
|
||||
log.message.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
log.service.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
safeToLowerCase(message).includes(searchTermLower) || safeToLowerCase(service).includes(searchTermLower)
|
||||
const matchesLevel = levelFilter === "all" || log.level === levelFilter
|
||||
const matchesService = serviceFilter === "all" || log.service === serviceFilter
|
||||
|
||||
return matchesSearch && matchesLevel && matchesService
|
||||
})
|
||||
|
||||
// Filter events only
|
||||
const filteredEventsOnly = eventsOnly.filter((event) => {
|
||||
const message = event.message || ""
|
||||
const service = event.service || ""
|
||||
const searchTermLower = safeToLowerCase(searchTerm)
|
||||
|
||||
const matchesSearch =
|
||||
event.message.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
event.service.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
safeToLowerCase(message).includes(searchTermLower) || safeToLowerCase(service).includes(searchTermLower)
|
||||
const matchesLevel = levelFilter === "all" || event.level === levelFilter
|
||||
const matchesService = serviceFilter === "all" || event.service === serviceFilter
|
||||
|
||||
@@ -463,30 +466,40 @@ export function SystemLogs() {
|
||||
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
|
||||
const combinedLogs: CombinedLogEntry[] = useMemo(
|
||||
() =>
|
||||
[
|
||||
...memoizedLogs.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() })),
|
||||
...memoizedEvents.map((event) => ({
|
||||
timestamp: event.starttime,
|
||||
level: event.level,
|
||||
service: event.type,
|
||||
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`,
|
||||
source: `Node: ${event.node} • User: ${event.user}`,
|
||||
isEvent: true,
|
||||
eventData: event,
|
||||
sortTimestamp: new Date(event.starttime).getTime(),
|
||||
})),
|
||||
].sort((a, b) => b.sortTimestamp - a.sortTimestamp),
|
||||
[memoizedLogs, memoizedEvents],
|
||||
)
|
||||
|
||||
// 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
|
||||
const filteredCombinedLogs = useMemo(
|
||||
() =>
|
||||
combinedLogs.filter((log) => {
|
||||
const message = log.message || ""
|
||||
const service = log.service || ""
|
||||
const searchTermLower = safeToLowerCase(searchTerm)
|
||||
|
||||
return matchesSearch && matchesLevel && matchesService
|
||||
})
|
||||
const matchesSearch =
|
||||
safeToLowerCase(message).includes(searchTermLower) || safeToLowerCase(service).includes(searchTermLower)
|
||||
const matchesLevel = levelFilter === "all" || log.level === levelFilter
|
||||
const matchesService = serviceFilter === "all" || log.service === serviceFilter
|
||||
|
||||
return matchesSearch && matchesLevel && matchesService
|
||||
}),
|
||||
[combinedLogs, searchTerm, levelFilter, serviceFilter],
|
||||
)
|
||||
|
||||
// CHANGE: Re-assigning displayedLogs to use the filteredCombinedLogs
|
||||
const displayedLogs = filteredCombinedLogs.slice(0, displayedLogsCount)
|
||||
@@ -548,7 +561,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":
|
||||
@@ -564,7 +579,9 @@ 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 +600,7 @@ 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(memoizedLogs.map((log) => log.service))], [memoizedLogs])
|
||||
|
||||
const getBackupType = (volid: string): "vm" | "lxc" => {
|
||||
if (volid.includes("/vm/") || volid.includes("vzdump-qemu")) {
|
||||
@@ -908,9 +925,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.slice(0, 20).map((service, idx) => (
|
||||
<SelectItem key={`service-${service}-${idx}`} value={service}>
|
||||
{service}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -925,51 +944,59 @@ export function SystemLogs() {
|
||||
|
||||
<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>
|
||||
{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 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>
|
||||
|
||||
<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}
|
||||
</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 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">
|
||||
@@ -1030,44 +1057,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>
|
||||
{memoizedBackups.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 +1114,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>
|
||||
{memoizedNotifications.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">
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Net
|
||||
import { NodeMetricsCharts } from "./node-metrics-charts"
|
||||
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"
|
||||
|
||||
interface SystemData {
|
||||
cpu_usage: number
|
||||
@@ -95,49 +97,26 @@ 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 (error) {
|
||||
if (attempt === retries - 1) {
|
||||
console.error("[v0] Failed to fetch system data after retries:", error)
|
||||
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)
|
||||
@@ -147,182 +126,134 @@ const fetchVMData = async (): Promise<VMData[]> => {
|
||||
|
||||
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")
|
||||
console.log("[v0] Storage API not available (this is normal if not configured)")
|
||||
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")
|
||||
console.log("[v0] Network API not available (this is normal if not configured)")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchProxmoxStorageData = async (): Promise<ProxmoxStorageData | 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<ProxmoxStorageData>("/api/proxmox-storage")
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log("[v0] Proxmox storage data unavailable:", error instanceof Error ? error.message : "Unknown error")
|
||||
console.log("[v0] Proxmox storage API not available")
|
||||
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
|
||||
|
||||
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)
|
||||
}, 9000)
|
||||
|
||||
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">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i} className="bg-card border-border animate-pulse">
|
||||
<CardContent className="p-6">
|
||||
@@ -388,32 +319,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 +394,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">
|
||||
@@ -508,6 +422,41 @@ export function SystemOverview() {
|
||||
</CardContent>
|
||||
</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">
|
||||
<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>
|
||||
@@ -527,36 +476,11 @@ export function SystemOverview() {
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Node Metrics Charts */}
|
||||
<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 +489,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">
|
||||
{formatNetworkTraffic(totalCapacity, "Bytes")}
|
||||
</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">
|
||||
{formatNetworkTraffic(totalUsed, "Bytes")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Free:{" "}
|
||||
<span className="font-semibold text-green-500">
|
||||
{formatNetworkTraffic(totalAvailable, "Bytes")}
|
||||
</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,18 +554,21 @@ 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">
|
||||
{formatNetworkTraffic(vmLxcStorageUsed, "Bytes")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Available:</span>
|
||||
<span className="text-sm font-semibold text-green-500">
|
||||
{formatStorage(vmLxcStorageAvailable)}
|
||||
{formatNetworkTraffic(vmLxcStorageAvailable, "Bytes")}
|
||||
</span>
|
||||
</div>
|
||||
<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)}
|
||||
{formatNetworkTraffic(vmLxcStorageUsed, "Bytes")} /{" "}
|
||||
{formatNetworkTraffic(vmLxcStorageTotal, "Bytes")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{vmLxcStoragePercent.toFixed(1)}%</span>
|
||||
</div>
|
||||
@@ -618,18 +590,21 @@ 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">
|
||||
{formatNetworkTraffic(localStorage.used, "Bytes")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Available:</span>
|
||||
<span className="text-sm font-semibold text-green-500">
|
||||
{formatStorage(localStorage.available)}
|
||||
{formatNetworkTraffic(localStorage.available, "Bytes")}
|
||||
</span>
|
||||
</div>
|
||||
<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)}
|
||||
{formatNetworkTraffic(localStorage.used, "Bytes")} /{" "}
|
||||
{formatNetworkTraffic(localStorage.total, "Bytes")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{localStorage.percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
@@ -642,7 +617,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 +639,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 +692,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 +726,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 +758,6 @@ export function SystemOverview() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Health & Alerts */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
||||
"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 = (text: string, type: "secret" | "codes") => {
|
||||
navigator.clipboard.writeText(text)
|
||||
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>
|
||||
))
|
||||
|
||||
@@ -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 }
|
||||
@@ -8,23 +8,12 @@ import { Badge } from "./ui/badge"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import {
|
||||
Server,
|
||||
Play,
|
||||
Square,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
Network,
|
||||
Power,
|
||||
RotateCcw,
|
||||
StopCircle,
|
||||
Container,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from "lucide-react"
|
||||
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import useSWR from "swr"
|
||||
import { MetricsView } from "./metrics-dialog"
|
||||
import { formatStorage } from "../lib/utils"
|
||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
|
||||
interface VMData {
|
||||
vmid: number
|
||||
@@ -123,7 +112,6 @@ interface VMDetails extends VMData {
|
||||
gpu_passthrough?: string[]
|
||||
devices?: string[]
|
||||
}
|
||||
lxc_ip?: string
|
||||
lxc_ip_info?: {
|
||||
all_ips: string[]
|
||||
real_ips: string[]
|
||||
@@ -133,24 +121,18 @@ interface VMDetails extends VMData {
|
||||
}
|
||||
|
||||
const fetcher = async (url: string) => {
|
||||
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}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
return fetchApi(url)
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number | undefined): string => {
|
||||
if (!bytes || bytes === 0) return "0 B"
|
||||
const formatBytes = (bytes: number | undefined, isNetwork: boolean = false): string => {
|
||||
if (!bytes || bytes === 0) return isNetwork ? "0 B/s" : "0 B"
|
||||
|
||||
if (isNetwork) {
|
||||
const networkUnit = getNetworkUnit()
|
||||
return formatNetworkTraffic(bytes, networkUnit, 2)
|
||||
}
|
||||
|
||||
// For non-network (disk), use standard bytes
|
||||
const k = 1024
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
@@ -194,18 +176,18 @@ const extractIPFromConfig = (config?: VMConfig, lxcIPInfo?: VMDetails["lxc_ip_in
|
||||
return "DHCP"
|
||||
}
|
||||
|
||||
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(1)} TB`
|
||||
}
|
||||
}
|
||||
// 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(1)} TB`
|
||||
// }
|
||||
// }
|
||||
|
||||
const getUsageColor = (percent: number): string => {
|
||||
if (percent >= 95) return "text-red-500"
|
||||
@@ -263,9 +245,11 @@ export function VirtualMachines() {
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useSWR<VMData[]>("/api/vms", fetcher, {
|
||||
refreshInterval: 30000,
|
||||
refreshInterval: 23000,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: true,
|
||||
dedupingInterval: 10000,
|
||||
errorRetryCount: 2,
|
||||
})
|
||||
|
||||
const [selectedVM, setSelectedVM] = useState<VMData | null>(null)
|
||||
@@ -280,37 +264,77 @@ export function VirtualMachines() {
|
||||
const [editedNotes, setEditedNotes] = useState("")
|
||||
const [savingNotes, setSavingNotes] = useState(false)
|
||||
const [selectedMetric, setSelectedMetric] = useState<string | null>(null)
|
||||
const [ipsLoaded, setIpsLoaded] = useState(false)
|
||||
const [loadingIPs, setLoadingIPs] = useState(false)
|
||||
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">("Bytes")
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLXCIPs = async () => {
|
||||
if (!vmData) return
|
||||
// Only fetch if data exists, not already loaded, and not currently loading
|
||||
if (!vmData || ipsLoaded || loadingIPs) return
|
||||
|
||||
const lxcs = vmData.filter((vm) => vm.type === "lxc")
|
||||
|
||||
if (lxcs.length === 0) {
|
||||
setIpsLoaded(true)
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingIPs(true)
|
||||
const configs: Record<number, string> = {}
|
||||
|
||||
await Promise.all(
|
||||
lxcs.map(async (lxc) => {
|
||||
try {
|
||||
const response = await fetch(`/api/vms/${lxc.vmid}`)
|
||||
if (response.ok) {
|
||||
const details = await response.json()
|
||||
const batchSize = 5
|
||||
for (let i = 0; i < lxcs.length; i += batchSize) {
|
||||
const batch = lxcs.slice(i, i + batchSize)
|
||||
|
||||
await Promise.all(
|
||||
batch.map(async (lxc) => {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000)
|
||||
|
||||
const details = await fetchApi(`/api/vms/${lxc.vmid}`)
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (details.lxc_ip_info?.primary_ip) {
|
||||
configs[lxc.vmid] = details.lxc_ip_info.primary_ip
|
||||
} else if (details.config) {
|
||||
configs[lxc.vmid] = extractIPFromConfig(details.config, details.lxc_ip_info)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`[v0] Could not fetch IP for LXC ${lxc.vmid}`)
|
||||
configs[lxc.vmid] = "N/A"
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching config for LXC ${lxc.vmid}:`, error)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
setVmConfigs(configs)
|
||||
setVmConfigs((prev) => ({ ...prev, ...configs }))
|
||||
}
|
||||
|
||||
setLoadingIPs(false)
|
||||
setIpsLoaded(true)
|
||||
}
|
||||
|
||||
fetchLXCIPs()
|
||||
}, [vmData])
|
||||
}, [vmData, ipsLoaded, loadingIPs])
|
||||
|
||||
// Load initial network unit and listen for changes
|
||||
useEffect(() => {
|
||||
setNetworkUnit(getNetworkUnit())
|
||||
|
||||
const handleNetworkUnitChange = () => {
|
||||
setNetworkUnit(getNetworkUnit())
|
||||
}
|
||||
|
||||
window.addEventListener("networkUnitChanged", handleNetworkUnitChange)
|
||||
window.addEventListener("storage", handleNetworkUnitChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("networkUnitChanged", handleNetworkUnitChange)
|
||||
window.removeEventListener("storage", handleNetworkUnitChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleVMClick = async (vm: VMData) => {
|
||||
setSelectedVM(vm)
|
||||
@@ -321,11 +345,8 @@ export function VirtualMachines() {
|
||||
setEditedNotes("")
|
||||
setDetailsLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/vms/${vm.vmid}`)
|
||||
if (response.ok) {
|
||||
const details = await response.json()
|
||||
setVMDetails(details)
|
||||
}
|
||||
const details = await fetchApi(`/api/vms/${vm.vmid}`)
|
||||
setVMDetails(details)
|
||||
} catch (error) {
|
||||
console.error("Error fetching VM details:", error)
|
||||
} finally {
|
||||
@@ -344,23 +365,16 @@ export function VirtualMachines() {
|
||||
const handleVMControl = async (vmid: number, action: string) => {
|
||||
setControlLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/vms/${vmid}/control`, {
|
||||
await fetchApi(`/api/vms/${vmid}/control`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ action }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
mutate()
|
||||
setSelectedVM(null)
|
||||
setVMDetails(null)
|
||||
} else {
|
||||
console.error("Failed to control VM")
|
||||
}
|
||||
mutate()
|
||||
setSelectedVM(null)
|
||||
setVMDetails(null)
|
||||
} catch (error) {
|
||||
console.error("Error controlling VM:", error)
|
||||
console.error("Failed to control VM")
|
||||
} finally {
|
||||
setControlLoading(false)
|
||||
}
|
||||
@@ -368,36 +382,33 @@ export function VirtualMachines() {
|
||||
|
||||
const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/vms/${vmid}/logs`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const data = await fetchApi(`/api/vms/${vmid}/logs`)
|
||||
|
||||
// Format logs as plain text
|
||||
let logText = `=== Logs for ${vmName} (VMID: ${vmid}) ===\n`
|
||||
logText += `Node: ${data.node}\n`
|
||||
logText += `Type: ${data.type}\n`
|
||||
logText += `Total lines: ${data.log_lines}\n`
|
||||
logText += `Generated: ${new Date().toISOString()}\n`
|
||||
logText += `\n${"=".repeat(80)}\n\n`
|
||||
// Format logs as plain text
|
||||
let logText = `=== Logs for ${vmName} (VMID: ${vmid}) ===\n`
|
||||
logText += `Node: ${data.node}\n`
|
||||
logText += `Type: ${data.type}\n`
|
||||
logText += `Total lines: ${data.log_lines}\n`
|
||||
logText += `Generated: ${new Date().toISOString()}\n`
|
||||
logText += `\n${"=".repeat(80)}\n\n`
|
||||
|
||||
if (data.logs && Array.isArray(data.logs)) {
|
||||
data.logs.forEach((log: any) => {
|
||||
if (typeof log === "object" && log.t) {
|
||||
logText += `${log.t}\n`
|
||||
} else if (typeof log === "string") {
|
||||
logText += `${log}\n`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const blob = new Blob([logText], { type: "text/plain" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${vmName}-${vmid}-logs.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
if (data.logs && Array.isArray(data.logs)) {
|
||||
data.logs.forEach((log: any) => {
|
||||
if (typeof log === "object" && log.t) {
|
||||
logText += `${log.t}\n`
|
||||
} else if (typeof log === "string") {
|
||||
logText += `${log}\n`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const blob = new Blob([logText], { type: "text/plain" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${vmName}-${vmid}-logs.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error("Error downloading logs:", error)
|
||||
}
|
||||
@@ -450,7 +461,7 @@ export function VirtualMachines() {
|
||||
"/api/system",
|
||||
fetcher,
|
||||
{
|
||||
refreshInterval: 30000,
|
||||
refreshInterval: 37000,
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
)
|
||||
@@ -592,29 +603,21 @@ export function VirtualMachines() {
|
||||
|
||||
setSavingNotes(true)
|
||||
try {
|
||||
const response = await fetch(`/api/vms/${selectedVM.vmid}/config`, {
|
||||
await fetchApi(`/api/vms/${selectedVM.vmid}/config`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
description: editedNotes, // Send as-is, pvesh will handle encoding
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setVMDetails({
|
||||
...vmDetails,
|
||||
config: {
|
||||
...vmDetails.config,
|
||||
description: editedNotes, // Store unencoded
|
||||
},
|
||||
})
|
||||
setIsEditingNotes(false)
|
||||
} else {
|
||||
console.error("Failed to save notes")
|
||||
alert("Failed to save notes. Please try again.")
|
||||
}
|
||||
setVMDetails({
|
||||
...vmDetails,
|
||||
config: {
|
||||
...vmDetails.config,
|
||||
description: editedNotes, // Store unencoded
|
||||
},
|
||||
})
|
||||
setIsEditingNotes(false)
|
||||
} catch (error) {
|
||||
console.error("Error saving notes:", error)
|
||||
alert("Error saving notes. Please try again.")
|
||||
@@ -933,11 +936,11 @@ export function VirtualMachines() {
|
||||
<div className="text-sm font-semibold space-y-0.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3 text-green-500" />
|
||||
<span className="text-green-500">↓ {formatBytes(vm.diskread)}</span>
|
||||
<span className="text-green-500">↓ {formatBytes(vm.diskread, false)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3 text-blue-500" />
|
||||
<span className="text-blue-500">↑ {formatBytes(vm.diskwrite)}</span>
|
||||
<span className="text-blue-500">↑ {formatBytes(vm.diskwrite, false)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -947,11 +950,11 @@ export function VirtualMachines() {
|
||||
<div className="text-sm font-semibold space-y-0.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<Network className="h-3 w-3 text-green-500" />
|
||||
<span className="text-green-500">↓ {formatBytes(vm.netin)}</span>
|
||||
<span className="text-green-500">↓ {formatBytes(vm.netin, true)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Network className="h-3 w-3 text-blue-500" />
|
||||
<span className="text-blue-500">↑ {formatBytes(vm.netout)}</span>
|
||||
<span className="text-blue-500">↑ {formatBytes(vm.netout, true)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1041,11 +1044,15 @@ export function VirtualMachines() {
|
||||
setEditedNotes("")
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-4xl h-[95vh] sm:h-[90vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogContent
|
||||
className="max-w-4xl h-[95vh] sm:h-[90vh] flex flex-col p-0 overflow-hidden"
|
||||
key={selectedVM?.vmid || "no-vm"}
|
||||
>
|
||||
{currentView === "main" ? (
|
||||
<>
|
||||
<DialogHeader className="pb-4 border-b border-border px-6 pt-6">
|
||||
<DialogTitle className="flex flex-col gap-3">
|
||||
{/* Desktop layout: Uptime now appears after status badge */}
|
||||
<div className="hidden sm:flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5 flex-shrink-0" />
|
||||
@@ -1062,15 +1069,16 @@ export function VirtualMachines() {
|
||||
<Badge variant="outline" className={`${getStatusColor(selectedVM.status)} flex-shrink-0`}>
|
||||
{selectedVM.status.toUpperCase()}
|
||||
</Badge>
|
||||
{selectedVM.status === "running" && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Uptime: {formatUptime(selectedVM.uptime)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedVM.status === "running" && (
|
||||
<span className="text-sm text-muted-foreground ml-auto">
|
||||
Uptime: {formatUptime(selectedVM.uptime)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Mobile layout unchanged */}
|
||||
<div className="sm:hidden flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5 flex-shrink-0" />
|
||||
@@ -1101,7 +1109,7 @@ export function VirtualMachines() {
|
||||
<div className="space-y-6">
|
||||
{selectedVM && (
|
||||
<>
|
||||
<div>
|
||||
<div key={`metrics-${selectedVM.vmid}`}>
|
||||
<Card
|
||||
className="cursor-pointer rounded-lg border border-black/10 dark:border-white/10 sm:border-border max-sm:bg-black/5 max-sm:dark:bg-white/5 sm:bg-card sm:hover:bg-black/5 sm:dark:hover:bg-white/5 transition-colors group"
|
||||
onClick={handleMetricsClick}
|
||||
@@ -1171,11 +1179,11 @@ export function VirtualMachines() {
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-green-500 flex items-center gap-1">
|
||||
<span>↓</span>
|
||||
<span>{((selectedVM.netin || 0) / 1024 ** 2).toFixed(2)} MB</span>
|
||||
<span>{formatNetworkTraffic(selectedVM.netin || 0, networkUnit)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-blue-500 flex items-center gap-1">
|
||||
<span>↑</span>
|
||||
<span>{((selectedVM.netout || 0) / 1024 ** 2).toFixed(2)} MB</span>
|
||||
<span>{formatNetworkTraffic(selectedVM.netout || 0, networkUnit)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1192,7 +1200,7 @@ export function VirtualMachines() {
|
||||
<div className="text-center py-8 text-muted-foreground">Loading configuration...</div>
|
||||
) : vmDetails?.config ? (
|
||||
<>
|
||||
<Card className="border border-border bg-card/50">
|
||||
<Card className="border border-border bg-card/50" key={`config-${selectedVM.vmid}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
@@ -1230,7 +1238,8 @@ export function VirtualMachines() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3 mr-1" />+ Info
|
||||
<ChevronDown className="h-3 w-3 mr-1" />
|
||||
+ Info
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -1258,26 +1267,25 @@ export function VirtualMachines() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* IP Addresses with proper keys */}
|
||||
{selectedVM?.type === "lxc" && vmDetails?.lxc_ip_info && (
|
||||
<div className="mt-4 lg:mt-6 pt-4 lg:pt-6 border-t border-border">
|
||||
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
IP Addresses
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Real IPs (green, without "Real" label) */}
|
||||
{vmDetails.lxc_ip_info.real_ips.map((ip, index) => (
|
||||
<Badge
|
||||
key={`real-${index}`}
|
||||
key={`real-ip-${selectedVM.vmid}-${ip.replace(/[.:/]/g, "-")}-${index}`}
|
||||
variant="outline"
|
||||
className="bg-green-500/10 text-green-500 border-green-500/20"
|
||||
>
|
||||
{ip}
|
||||
</Badge>
|
||||
))}
|
||||
{/* Docker bridge IPs (yellow, with "Bridge" label) */}
|
||||
{vmDetails.lxc_ip_info.docker_ips.map((ip, index) => (
|
||||
<Badge
|
||||
key={`docker-${index}`}
|
||||
key={`docker-ip-${selectedVM.vmid}-${ip.replace(/[.:/]/g, "-")}-${index}`}
|
||||
variant="outline"
|
||||
className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
|
||||
>
|
||||
@@ -1387,7 +1395,7 @@ export function VirtualMachines() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GPU Passthrough */}
|
||||
{/* GPU Passthrough with proper keys */}
|
||||
{vmDetails.hardware_info.gpu_passthrough &&
|
||||
vmDetails.hardware_info.gpu_passthrough.length > 0 && (
|
||||
<div>
|
||||
@@ -1395,7 +1403,7 @@ export function VirtualMachines() {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{vmDetails.hardware_info.gpu_passthrough.map((gpu, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
key={`gpu-${selectedVM.vmid}-${index}-${gpu.replace(/[^a-zA-Z0-9]/g, "-").substring(0, 30)}`}
|
||||
variant="outline"
|
||||
className={
|
||||
gpu.includes("NVIDIA")
|
||||
@@ -1410,7 +1418,7 @@ export function VirtualMachines() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other Hardware Devices */}
|
||||
{/* Hardware Devices with proper keys */}
|
||||
{vmDetails.hardware_info.devices &&
|
||||
vmDetails.hardware_info.devices.length > 0 && (
|
||||
<div>
|
||||
@@ -1418,7 +1426,7 @@ export function VirtualMachines() {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{vmDetails.hardware_info.devices.map((device, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
key={`device-${selectedVM.vmid}-${index}-${device.replace(/[^a-zA-Z0-9]/g, "-").substring(0, 30)}`}
|
||||
variant="outline"
|
||||
className="bg-blue-500/10 text-blue-500 border-blue-500/20"
|
||||
>
|
||||
@@ -1540,7 +1548,7 @@ export function VirtualMachines() {
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{vmDetails.config.rootfs && (
|
||||
<div>
|
||||
<div key="rootfs">
|
||||
<div className="text-xs text-muted-foreground mb-1">Root Filesystem</div>
|
||||
<div className="font-medium text-foreground text-sm break-all font-mono bg-muted/50 p-2 rounded">
|
||||
{vmDetails.config.rootfs}
|
||||
@@ -1548,15 +1556,16 @@ export function VirtualMachines() {
|
||||
</div>
|
||||
)}
|
||||
{vmDetails.config.scsihw && (
|
||||
<div>
|
||||
<div key="scsihw">
|
||||
<div className="text-xs text-muted-foreground mb-1">SCSI Controller</div>
|
||||
<div className="font-medium text-foreground">{vmDetails.config.scsihw}</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Disk Storage with proper keys */}
|
||||
{Object.keys(vmDetails.config)
|
||||
.filter((key) => key.match(/^(scsi|sata|ide|virtio)\d+$/))
|
||||
.map((diskKey) => (
|
||||
<div key={diskKey}>
|
||||
<div key={`disk-${selectedVM.vmid}-${diskKey}`}>
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
{diskKey.toUpperCase().replace(/(\d+)/, " $1")}
|
||||
</div>
|
||||
@@ -1566,7 +1575,7 @@ export function VirtualMachines() {
|
||||
</div>
|
||||
))}
|
||||
{vmDetails.config.efidisk0 && (
|
||||
<div>
|
||||
<div key="efidisk0">
|
||||
<div className="text-xs text-muted-foreground mb-1">EFI Disk</div>
|
||||
<div className="font-medium text-foreground text-sm break-all font-mono bg-muted/50 p-2 rounded">
|
||||
{vmDetails.config.efidisk0}
|
||||
@@ -1574,18 +1583,18 @@ export function VirtualMachines() {
|
||||
</div>
|
||||
)}
|
||||
{vmDetails.config.tpmstate0 && (
|
||||
<div>
|
||||
<div key="tpmstate0">
|
||||
<div className="text-xs text-muted-foreground mb-1">TPM State</div>
|
||||
<div className="font-medium text-foreground text-sm break-all font-mono bg-muted/50 p-2 rounded">
|
||||
{vmDetails.config.tpmstate0}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Mount points for LXC */}
|
||||
{/* Mount Points with proper keys */}
|
||||
{Object.keys(vmDetails.config)
|
||||
.filter((key) => key.match(/^mp\d+$/))
|
||||
.map((mpKey) => (
|
||||
<div key={mpKey}>
|
||||
<div key={`mp-${selectedVM.vmid}-${mpKey}`}>
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
Mount Point {mpKey.replace("mp", "")}
|
||||
</div>
|
||||
@@ -1603,10 +1612,11 @@ export function VirtualMachines() {
|
||||
Network
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{/* Network Interfaces with proper keys */}
|
||||
{Object.keys(vmDetails.config)
|
||||
.filter((key) => key.match(/^net\d+$/))
|
||||
.map((netKey) => (
|
||||
<div key={netKey}>
|
||||
<div key={`net-${selectedVM.vmid}-${netKey}`}>
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
Network Interface {netKey.replace("net", "")}
|
||||
</div>
|
||||
@@ -1644,7 +1654,7 @@ export function VirtualMachines() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PCI Devices Section */}
|
||||
{/* PCI Devices with proper keys */}
|
||||
{Object.keys(vmDetails.config).some((key) => key.match(/^hostpci\d+$/)) && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
@@ -1654,7 +1664,7 @@ export function VirtualMachines() {
|
||||
{Object.keys(vmDetails.config)
|
||||
.filter((key) => key.match(/^hostpci\d+$/))
|
||||
.map((pciKey) => (
|
||||
<div key={pciKey}>
|
||||
<div key={`pci-${selectedVM.vmid}-${pciKey}`}>
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
{pciKey.toUpperCase().replace(/(\d+)/, " $1")}
|
||||
</div>
|
||||
@@ -1667,7 +1677,7 @@ export function VirtualMachines() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* USB Devices Section */}
|
||||
{/* USB Devices with proper keys */}
|
||||
{Object.keys(vmDetails.config).some((key) => key.match(/^usb\d+$/)) && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
@@ -1677,7 +1687,7 @@ export function VirtualMachines() {
|
||||
{Object.keys(vmDetails.config)
|
||||
.filter((key) => key.match(/^usb\d+$/))
|
||||
.map((usbKey) => (
|
||||
<div key={usbKey}>
|
||||
<div key={`usb-${selectedVM.vmid}-${usbKey}`}>
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
{usbKey.toUpperCase().replace(/(\d+)/, " $1")}
|
||||
</div>
|
||||
@@ -1690,7 +1700,7 @@ export function VirtualMachines() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Serial Devices Section */}
|
||||
{/* Serial Ports with proper keys */}
|
||||
{Object.keys(vmDetails.config).some((key) => key.match(/^serial\d+$/)) && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
@@ -1700,7 +1710,7 @@ export function VirtualMachines() {
|
||||
{Object.keys(vmDetails.config)
|
||||
.filter((key) => key.match(/^serial\d+$/))
|
||||
.map((serialKey) => (
|
||||
<div key={serialKey}>
|
||||
<div key={`serial-${selectedVM.vmid}-${serialKey}`}>
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
{serialKey.toUpperCase().replace(/(\d+)/, " $1")}
|
||||
</div>
|
||||
@@ -1712,91 +1722,6 @@ export function VirtualMachines() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options Section */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
Options
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{vmDetails.config.onboot !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Start on Boot</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
vmDetails.config.onboot
|
||||
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20"
|
||||
}
|
||||
>
|
||||
{vmDetails.config.onboot ? "Yes" : "No"}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{vmDetails.config.ostype && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">OS Type</div>
|
||||
<div className="font-medium text-foreground">{vmDetails.config.ostype}</div>
|
||||
</div>
|
||||
)}
|
||||
{vmDetails.config.arch && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Architecture</div>
|
||||
<div className="font-medium text-foreground">{vmDetails.config.arch}</div>
|
||||
</div>
|
||||
)}
|
||||
{vmDetails.config.boot && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Boot Order</div>
|
||||
<div className="font-medium text-foreground">{vmDetails.config.boot}</div>
|
||||
</div>
|
||||
)}
|
||||
{vmDetails.config.features && (
|
||||
<div className="col-span-2 lg:grid-cols-3">
|
||||
<div className="text-xs text-muted-foreground mb-1">Features</div>
|
||||
<div className="font-medium text-foreground text-sm">
|
||||
{vmDetails.config.features}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Section */}
|
||||
{(vmDetails.config.vmgenid || vmDetails.config.smbios1 || vmDetails.config.meta) && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
Advanced
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{vmDetails.config.vmgenid && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">VM Generation ID</div>
|
||||
<div className="font-medium text-muted-foreground text-sm font-mono">
|
||||
{vmDetails.config.vmgenid}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{vmDetails.config.smbios1 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">SMBIOS</div>
|
||||
<div className="font-medium text-muted-foreground text-sm font-mono break-all">
|
||||
{vmDetails.config.smbios1}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{vmDetails.config.meta && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Metadata</div>
|
||||
<div className="font-medium text-muted-foreground text-sm font-mono">
|
||||
{vmDetails.config.meta}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -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,126 @@
|
||||
/**
|
||||
* 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") {
|
||||
console.log("[v0] getApiBaseUrl: Running on server (SSR)")
|
||||
return ""
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location
|
||||
|
||||
console.log("[v0] getApiBaseUrl - protocol:", protocol, "hostname:", hostname, "port:", port)
|
||||
|
||||
// If accessing via standard ports (80/443) or no port, assume we're behind a proxy
|
||||
// In this case, use relative URLs so the proxy handles routing
|
||||
const isStandardPort = port === "" || port === "80" || port === "443"
|
||||
|
||||
console.log("[v0] getApiBaseUrl - isStandardPort:", isStandardPort)
|
||||
|
||||
if (isStandardPort) {
|
||||
// Behind a proxy - use relative URL
|
||||
console.log("[v0] getApiBaseUrl: Detected proxy access, using relative URLs")
|
||||
return ""
|
||||
} else {
|
||||
// Direct access - use explicit API port
|
||||
const baseUrl = `${protocol}//${hostname}:${API_PORT}`
|
||||
console.log("[v0] getApiBaseUrl: Direct access detected, using:", baseUrl)
|
||||
return baseUrl
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
const token = localStorage.getItem("proxmenux-auth-token")
|
||||
console.log(
|
||||
"[v0] getAuthToken called:",
|
||||
token ? `Token found (length: ${token.length})` : "No token found in localStorage",
|
||||
)
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`
|
||||
console.log("[v0] fetchApi:", endpoint, "- Authorization header ADDED")
|
||||
} else {
|
||||
console.log("[v0] fetchApi:", endpoint, "- NO TOKEN - Request will fail if endpoint is protected")
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
})
|
||||
|
||||
console.log("[v0] fetchApi:", endpoint, "- Response status:", response.status)
|
||||
|
||||
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}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
} catch (error) {
|
||||
console.error("[v0] fetchApi error for", endpoint, ":", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -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.0.2",
|
||||
"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,504 @@
|
||||
"""
|
||||
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
|
||||
}
|
||||
"""
|
||||
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": []
|
||||
}
|
||||
|
||||
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", [])
|
||||
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": []
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
if not JWT_AVAILABLE or not token:
|
||||
return None
|
||||
|
||||
try:
|
||||
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 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"] = []
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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:
|
||||
return False, None, True, "2FA code required"
|
||||
|
||||
# Verify TOTP token or backup code
|
||||
success, message = verify_totp(username, totp_token, use_backup=len(totp_token) == 9) # Backup codes are formatted XXXX-XXXX
|
||||
if not success:
|
||||
return False, None, True, message
|
||||
|
||||
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,17 @@ 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"
|
||||
|
||||
echo "📋 Adding translation support..."
|
||||
cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF'
|
||||
@@ -274,16 +285,31 @@ 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)
|
||||
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
|
||||
|
||||
cat > "$APP_DIR/usr/lib/python3/dist-packages/cgi.py" << 'PYEOF'
|
||||
from typing import Tuple, Dict
|
||||
try:
|
||||
@@ -321,10 +347,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 +383,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 +408,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 +415,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 +471,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,278 @@
|
||||
"""
|
||||
Flask Authentication Routes
|
||||
Provides REST API endpoints for authentication management
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
import auth_manager
|
||||
import jwt
|
||||
import datetime
|
||||
|
||||
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({"error": str(e)}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/setup', methods=['POST'])
|
||||
def auth_setup():
|
||||
"""Set up authentication with username and password"""
|
||||
try:
|
||||
data = request.json
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
|
||||
success, message = auth_manager.setup_auth(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/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:
|
||||
return jsonify({"success": False, "requires_totp": True, "message": message}), 200
|
||||
else:
|
||||
return jsonify({"success": False, "message": message}), 401
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/enable', methods=['POST'])
|
||||
def auth_enable():
|
||||
"""Enable authentication"""
|
||||
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')
|
||||
|
||||
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
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
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 an error manually (user dismissed it)"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data or 'error_key' not in data:
|
||||
return jsonify({'error': 'error_key is required'}), 400
|
||||
|
||||
error_key = data['error_key']
|
||||
health_persistence.acknowledge_error(error_key)
|
||||
return jsonify({'success': True, 'message': 'Error acknowledged'})
|
||||
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
|
||||
@@ -0,0 +1,75 @@
|
||||
from flask import Blueprint, jsonify
|
||||
import json
|
||||
import os
|
||||
|
||||
proxmenux_bp = Blueprint('proxmenux', __name__)
|
||||
|
||||
# Tool descriptions mapping
|
||||
TOOL_DESCRIPTIONS = {
|
||||
'lvm_repair': 'LVM PV Headers Repair',
|
||||
'repo_cleanup': 'Repository Cleanup',
|
||||
'subscription_banner': 'Subscription Banner Removal',
|
||||
'time_sync': 'Time Synchronization',
|
||||
'apt_languages': 'APT Language Skip',
|
||||
'journald': 'Journald Optimization',
|
||||
'logrotate': 'Logrotate Optimization',
|
||||
'system_limits': 'System Limits Increase',
|
||||
'entropy': 'Entropy Generation (haveged)',
|
||||
'memory_settings': 'Memory Settings Optimization',
|
||||
'kernel_panic': 'Kernel Panic Configuration',
|
||||
'apt_ipv4': 'APT IPv4 Force',
|
||||
'kexec': 'kexec for quick reboots',
|
||||
'network_optimization': 'Network Optimizations',
|
||||
'bashrc_custom': 'Bashrc Customization',
|
||||
'figurine': 'Figurine',
|
||||
'fastfetch': 'Fastfetch',
|
||||
'log2ram': 'Log2ram (SSD Protection)',
|
||||
'amd_fixes': 'AMD CPU (Ryzen/EPYC) fixes',
|
||||
'persistent_network': 'Setting persistent network interfaces'
|
||||
}
|
||||
|
||||
@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
|
||||
tools = []
|
||||
for tool_key, enabled in data.items():
|
||||
if enabled: # Only include enabled tools
|
||||
tools.append({
|
||||
'key': tool_key,
|
||||
'name': TOOL_DESCRIPTIONS.get(tool_key, tool_key.replace('_', ' ').title()),
|
||||
'enabled': enabled
|
||||
})
|
||||
|
||||
# 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
|
||||
@@ -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()
|
||||
+699
-139
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,465 @@
|
||||
#!/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) and msg.get('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
@@ -0,0 +1,367 @@
|
||||
"""
|
||||
Health Monitor Persistence Module
|
||||
Manages persistent error tracking across AppImage updates using SQLite.
|
||||
Stores errors in /root/.config/proxmenux-monitor/health_monitor.db
|
||||
|
||||
Features:
|
||||
- Persistent error storage (survives AppImage updates)
|
||||
- Smart error resolution (auto-clear when VM starts, or after 48h)
|
||||
- Event system for future Telegram notifications
|
||||
- Manual acknowledgment support
|
||||
|
||||
Author: MacRimi
|
||||
Version: 1.0
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional
|
||||
from pathlib import Path
|
||||
|
||||
class HealthPersistence:
|
||||
"""Manages persistent health error tracking"""
|
||||
|
||||
# Error retention periods (seconds)
|
||||
VM_ERROR_RETENTION = 48 * 3600 # 48 hours
|
||||
LOG_ERROR_RETENTION = 24 * 3600 # 24 hours
|
||||
DISK_ERROR_RETENTION = 48 * 3600 # 48 hours
|
||||
UPDATES_SUPPRESSION = 180 * 24 * 3600 # 180 days (6 months)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize persistence with database in config directory"""
|
||||
self.data_dir = Path('/root/.config/proxmenux-monitor')
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.db_path = self.data_dir / 'health_monitor.db'
|
||||
self._init_database()
|
||||
|
||||
def _init_database(self):
|
||||
"""Initialize SQLite database with required tables"""
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Errors table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS errors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
error_key TEXT UNIQUE NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
details TEXT,
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
resolved_at TEXT,
|
||||
acknowledged INTEGER DEFAULT 0,
|
||||
notification_sent INTEGER DEFAULT 0
|
||||
)
|
||||
''')
|
||||
|
||||
# Events table (for future Telegram notifications)
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_type TEXT NOT NULL,
|
||||
error_key TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
data TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# Indexes for performance
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_error_key ON errors(error_key)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_category ON errors(category)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_resolved ON errors(resolved_at)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_events_error ON events(error_key)')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def record_error(self, error_key: str, category: str, severity: str,
|
||||
reason: str, details: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Record or update an error.
|
||||
Returns event info (new_error, updated, etc.)
|
||||
"""
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
details_json = json.dumps(details) if details else None
|
||||
|
||||
cursor.execute('''
|
||||
SELECT acknowledged, resolved_at
|
||||
FROM errors
|
||||
WHERE error_key = ? AND acknowledged = 1
|
||||
''', (error_key,))
|
||||
ack_check = cursor.fetchone()
|
||||
|
||||
if ack_check and ack_check[1]: # Has resolved_at timestamp
|
||||
try:
|
||||
resolved_dt = datetime.fromisoformat(ack_check[1])
|
||||
hours_since_ack = (datetime.now() - resolved_dt).total_seconds() / 3600
|
||||
|
||||
if category == 'updates':
|
||||
# Updates: suppress for 180 days (6 months)
|
||||
suppression_hours = self.UPDATES_SUPPRESSION / 3600
|
||||
else:
|
||||
# Other errors: suppress for 24 hours
|
||||
suppression_hours = 24
|
||||
|
||||
if hours_since_ack < suppression_hours:
|
||||
# Skip re-adding recently acknowledged errors
|
||||
conn.close()
|
||||
return {'type': 'skipped_acknowledged', 'needs_notification': False}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cursor.execute('''
|
||||
SELECT id, first_seen, notification_sent, acknowledged, resolved_at
|
||||
FROM errors WHERE error_key = ?
|
||||
''', (error_key,))
|
||||
existing = cursor.fetchone()
|
||||
|
||||
event_info = {'type': 'updated', 'needs_notification': False}
|
||||
|
||||
if existing:
|
||||
error_id, first_seen, notif_sent, acknowledged, resolved_at = existing
|
||||
|
||||
if acknowledged == 1:
|
||||
conn.close()
|
||||
return {'type': 'skipped_acknowledged', 'needs_notification': False}
|
||||
|
||||
# Update existing error (only if NOT acknowledged)
|
||||
cursor.execute('''
|
||||
UPDATE errors
|
||||
SET last_seen = ?, severity = ?, reason = ?, details = ?
|
||||
WHERE error_key = ? AND acknowledged = 0
|
||||
''', (now, severity, reason, details_json, error_key))
|
||||
|
||||
# Check if severity escalated
|
||||
cursor.execute('SELECT severity FROM errors WHERE error_key = ?', (error_key,))
|
||||
old_severity_row = cursor.fetchone()
|
||||
if old_severity_row:
|
||||
old_severity = old_severity_row[0]
|
||||
if old_severity == 'WARNING' and severity == 'CRITICAL':
|
||||
event_info['type'] = 'escalated'
|
||||
event_info['needs_notification'] = True
|
||||
else:
|
||||
# Insert new error
|
||||
cursor.execute('''
|
||||
INSERT INTO errors
|
||||
(error_key, category, severity, reason, details, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', (error_key, category, severity, reason, details_json, now, now))
|
||||
|
||||
event_info['type'] = 'new'
|
||||
event_info['needs_notification'] = True
|
||||
|
||||
# Record event
|
||||
self._record_event(cursor, event_info['type'], error_key,
|
||||
{'severity': severity, 'reason': reason})
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return event_info
|
||||
|
||||
def resolve_error(self, error_key: str, reason: str = 'auto-resolved'):
|
||||
"""Mark an error as resolved"""
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE errors
|
||||
SET resolved_at = ?
|
||||
WHERE error_key = ? AND resolved_at IS NULL
|
||||
''', (now, error_key))
|
||||
|
||||
if cursor.rowcount > 0:
|
||||
self._record_event(cursor, 'resolved', error_key, {'reason': reason})
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def acknowledge_error(self, error_key: str):
|
||||
"""
|
||||
Manually acknowledge an error (won't notify again or re-appear for 24h).
|
||||
Also marks as resolved so it disappears from active errors.
|
||||
"""
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE errors
|
||||
SET acknowledged = 1, resolved_at = ?
|
||||
WHERE error_key = ?
|
||||
''', (now, error_key))
|
||||
|
||||
self._record_event(cursor, 'acknowledged', error_key, {})
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_active_errors(self, category: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Get all active (unresolved) errors, optionally filtered by category"""
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
if category:
|
||||
cursor.execute('''
|
||||
SELECT * FROM errors
|
||||
WHERE resolved_at IS NULL AND category = ?
|
||||
ORDER BY severity DESC, last_seen DESC
|
||||
''', (category,))
|
||||
else:
|
||||
cursor.execute('''
|
||||
SELECT * FROM errors
|
||||
WHERE resolved_at IS NULL
|
||||
ORDER BY severity DESC, last_seen DESC
|
||||
''')
|
||||
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
errors = []
|
||||
for row in rows:
|
||||
error_dict = dict(row)
|
||||
if error_dict.get('details'):
|
||||
error_dict['details'] = json.loads(error_dict['details'])
|
||||
errors.append(error_dict)
|
||||
|
||||
return errors
|
||||
|
||||
def cleanup_old_errors(self):
|
||||
"""Clean up old resolved errors and auto-resolve stale errors"""
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
# Delete resolved errors older than 7 days
|
||||
cutoff_resolved = (now - timedelta(days=7)).isoformat()
|
||||
cursor.execute('DELETE FROM errors WHERE resolved_at < ?', (cutoff_resolved,))
|
||||
|
||||
# Auto-resolve VM/CT errors older than 48h
|
||||
cutoff_vm = (now - timedelta(seconds=self.VM_ERROR_RETENTION)).isoformat()
|
||||
cursor.execute('''
|
||||
UPDATE errors
|
||||
SET resolved_at = ?
|
||||
WHERE category = 'vms'
|
||||
AND resolved_at IS NULL
|
||||
AND first_seen < ?
|
||||
AND acknowledged = 0
|
||||
''', (now.isoformat(), cutoff_vm))
|
||||
|
||||
# Auto-resolve log errors older than 24h
|
||||
cutoff_logs = (now - timedelta(seconds=self.LOG_ERROR_RETENTION)).isoformat()
|
||||
cursor.execute('''
|
||||
UPDATE errors
|
||||
SET resolved_at = ?
|
||||
WHERE category = 'logs'
|
||||
AND resolved_at IS NULL
|
||||
AND first_seen < ?
|
||||
AND acknowledged = 0
|
||||
''', (now.isoformat(), cutoff_logs))
|
||||
|
||||
# Delete old events (>30 days)
|
||||
cutoff_events = (now - timedelta(days=30)).isoformat()
|
||||
cursor.execute('DELETE FROM events WHERE timestamp < ?', (cutoff_events,))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def check_vm_running(self, vm_id: str) -> bool:
|
||||
"""
|
||||
Check if a VM/CT is running and resolve error if so.
|
||||
Returns True if running and error was resolved.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
# Check qm status for VMs
|
||||
result = subprocess.run(
|
||||
['qm', 'status', vm_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2
|
||||
)
|
||||
|
||||
if result.returncode == 0 and 'running' in result.stdout.lower():
|
||||
self.resolve_error(f'vm_{vm_id}', 'VM started')
|
||||
return True
|
||||
|
||||
# Check pct status for containers
|
||||
result = subprocess.run(
|
||||
['pct', 'status', vm_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2
|
||||
)
|
||||
|
||||
if result.returncode == 0 and 'running' in result.stdout.lower():
|
||||
self.resolve_error(f'ct_{vm_id}', 'Container started')
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _record_event(self, cursor, event_type: str, error_key: str, data: Dict):
|
||||
"""Internal: Record an event"""
|
||||
cursor.execute('''
|
||||
INSERT INTO events (event_type, error_key, timestamp, data)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (event_type, error_key, datetime.now().isoformat(), json.dumps(data)))
|
||||
|
||||
def get_unnotified_errors(self) -> List[Dict[str, Any]]:
|
||||
"""Get errors that need Telegram notification"""
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT * FROM errors
|
||||
WHERE notification_sent = 0
|
||||
AND resolved_at IS NULL
|
||||
AND acknowledged = 0
|
||||
ORDER BY severity DESC, first_seen ASC
|
||||
''')
|
||||
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
errors = []
|
||||
for row in rows:
|
||||
error_dict = dict(row)
|
||||
if error_dict.get('details'):
|
||||
error_dict['details'] = json.loads(error_dict['details'])
|
||||
errors.append(error_dict)
|
||||
|
||||
return errors
|
||||
|
||||
def mark_notified(self, error_key: str):
|
||||
"""Mark error as notified"""
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE errors
|
||||
SET notification_sent = 1
|
||||
WHERE error_key = ?
|
||||
''', (error_key,))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# Global instance
|
||||
health_persistence = HealthPersistence()
|
||||
@@ -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,202 @@
|
||||
#!/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
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
|
||||
class ProxmoxStorageMonitor:
|
||||
"""Monitor Proxmox storage configuration and status"""
|
||||
|
||||
def __init__(self):
|
||||
self.configured_storages: Dict[str, Dict[str, Any]] = {}
|
||||
self._load_configured_storages()
|
||||
|
||||
def _get_node_name(self) -> str:
|
||||
"""Get current Proxmox node 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:
|
||||
return hostname
|
||||
if nodes:
|
||||
return nodes[0].get('node', hostname)
|
||||
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
|
||||
|
||||
Returns:
|
||||
{
|
||||
'available': [...],
|
||||
'unavailable': [...]
|
||||
}
|
||||
"""
|
||||
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
|
||||
})
|
||||
|
||||
return {
|
||||
'available': available_storages,
|
||||
'unavailable': unavailable_storages
|
||||
}
|
||||
|
||||
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) -> None:
|
||||
"""Reload storage configuration from Proxmox"""
|
||||
self.configured_storages.clear()
|
||||
self._load_configured_storages()
|
||||
|
||||
|
||||
# Global instance
|
||||
proxmox_storage_monitor = ProxmoxStorageMonitor()
|
||||
@@ -1,3 +1,5 @@
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
|
||||
export interface Temperature {
|
||||
name: string
|
||||
original_name?: string
|
||||
@@ -33,6 +35,13 @@ export interface StorageDevice {
|
||||
rotation_rate?: number | string
|
||||
form_factor?: string
|
||||
sata_version?: string
|
||||
pcie_gen?: string // e.g., "PCIe 4.0"
|
||||
pcie_width?: string // e.g., "x4"
|
||||
pcie_max_gen?: string // Maximum supported PCIe generation
|
||||
pcie_max_width?: string // Maximum supported PCIe lanes
|
||||
sas_version?: string // e.g., "SAS-3"
|
||||
sas_speed?: string // e.g., "12Gb/s"
|
||||
link_speed?: string // Generic link speed info
|
||||
}
|
||||
|
||||
export interface PCIDevice {
|
||||
@@ -201,4 +210,8 @@ export interface HardwareData {
|
||||
ups?: UPS | UPS[]
|
||||
}
|
||||
|
||||
export const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||
export const fetcher = async (url: string) => {
|
||||
// Extract just the endpoint from the URL if it's a full URL
|
||||
const endpoint = url.startsWith("http") ? new URL(url).pathname : url
|
||||
return fetchApi(endpoint)
|
||||
}
|
||||
|
||||
+87
-1
@@ -1,3 +1,88 @@
|
||||
## 2026-03-14
|
||||
|
||||
### New version v1.1.9 — *Helper Scripts Catalog Rebuilt*
|
||||
|
||||
### Changed
|
||||
|
||||
- **Helper Scripts Menu — Full Catalog Rebuild**
|
||||
The Helper Scripts catalog has been completely rebuilt to adapt to the new data architecture of the [Community Scripts](https://community-scripts.github.io/ProxmoxVE/) project.
|
||||
|
||||
The previous implementation relied on a `metadata.json` file that no longer exists in the upstream repository. The catalog now connects directly to the **PocketBase API** (`db.community-scripts.org`), which is the new official data source for the project.
|
||||
|
||||
A new GitHub Actions workflow generates a local `helpers_cache.json` index that replaces the old metadata dependency. This new cache is richer, more structured, and includes:
|
||||
- Script type, slug, description, notes, and default credentials
|
||||
- OS variants per script (e.g. Debian, Alpine) — each shown as a separate selectable option in the menu
|
||||
- Direct GitHub URL and **Mirror URL** (`git.community-scripts.org`) for every script
|
||||
- Category names embedded directly in the cache — no external requests needed to build the menu
|
||||
- Additional metadata: default port, website, logo, update support, ARM availability
|
||||
|
||||
Scripts that support multiple OS variants (e.g. Docker with Alpine and Debian) now correctly show **one entry per OS**, each with its own GitHub and Mirror download option — restoring the behavior that existed before the upstream migration.
|
||||
|
||||
---
|
||||
|
||||
### 🎖 Special Acknowledgment
|
||||
|
||||
This update would not have been possible without the openness and collaboration of the **Community Scripts** maintainers.
|
||||
|
||||
When the upstream metadata structure changed and broke the ProxMenux catalog, the maintainers responded quickly, explained the new architecture in detail, and provided all the information needed to rebuild the integration cleanly.
|
||||
|
||||
Special thanks to:
|
||||
|
||||
- **MickLeskCanbiZ ([@MickLesk](https://github.com/MickLesk))** — for documenting the new script path structure by type and slug, and for the clear and direct technical guidance.
|
||||
- **Michel Roegl-Brunner ([@michelroegl-brunner](https://github.com/michelroegl-brunner))** — for explaining the new PocketBase collections structure (`script_scripts`, `script_categories`).
|
||||
|
||||
The Helper Scripts project is an extraordinary resource for the Proxmox community. The scripts belong entirely to their authors and maintainers — ProxMenux simply offers a guided way to discover and launch them. All credit goes to the community behind [community-scripts/ProxmoxVE](https://github.com/community-scripts/ProxmoxVE).
|
||||
|
||||
## 2025-09-18
|
||||
|
||||
### New version v1.1.8 — *ProxMenux Offline Mode*
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Added
|
||||
|
||||
- **Offline Execution Mode (no GitHub dependency)**
|
||||
All ProxMenux core scripts now run **entirely locally**, without requiring live requests to GitHub (`raw.githubusercontent.com`).
|
||||
This change provides:
|
||||
- Greater stability during execution
|
||||
- No interruptions due to network timeouts or regional GitHub blocks
|
||||
- Support for **offline or isolated environments**
|
||||
|
||||
⚠️ This update resolves recent issues where users in certain regions were unable to run scripts due to CDN or TLS filtering errors while downloading `.sh` files from GitHub raw URLs.
|
||||
|
||||
**🎖 Special Acknowledgment: @cod378**
|
||||
This offline conversion has been made possible thanks to the extraordinary work of **@cod378**,
|
||||
who redesigned the entire internal logic of the installer and updater, refactored the file management system,
|
||||
and implemented the new fully local execution workflow.
|
||||
Without his collaboration, dedication, and technical contribution, this transformation would not have been possible.
|
||||
|
||||
- **ProxMenux Monitor v1.0.1**
|
||||
This update brings a major leap in the **ProxMenux Monitor** interface.
|
||||
New features and improvements:
|
||||
- `Proxy Support`: Access ProxMenux through reverse proxies with full functionality
|
||||
- `Authentication System`: Secure your dashboard with password protection
|
||||
- `Two-Factor Authentication (2FA)`: Optional TOTP support for enhanced security
|
||||
- `PCIe Link Speed Detection`: View NVMe connection speeds and detect performance bottlenecks
|
||||
- `Enhanced Storage Display`: Auto-formats disk sizes (GB → TB when appropriate)
|
||||
- `SATA/SAS Interface Info`: Detect and show storage type (SATA, SAS, NVMe, etc.)
|
||||
- `Health Monitoring System`: Built-in system health check with dismissible alerts
|
||||
- Improved rendering across browsers and better performance
|
||||
|
||||
- **Helper Scripts Menu (Mirror Support)**
|
||||
The `Helper Scripts` menu now:
|
||||
- Detects **mirror URLs** and shows alternative download options when available
|
||||
- Lists available OS versions when a helper script is version-dependent (e.g. template installers)
|
||||
|
||||
---
|
||||
|
||||
### Fixed
|
||||
|
||||
- Minor fixes and refinements throughout the codebase to ensure full offline compatibility and a smoother user experience.
|
||||
|
||||
|
||||
|
||||
## 2025-09-04
|
||||
|
||||
### New version v1.1.7
|
||||
@@ -9,8 +94,9 @@
|
||||
|
||||
ProxMenux Monitor is designed to support future updates where **actions can be triggered without using the terminal**, and managed through a **user-friendly interface** accessible across multiple formats and devices.
|
||||
|
||||

|
||||
Access it at: **http://your-server-ip:8008**
|
||||
|
||||

|
||||
- **New Banner Removal Method**
|
||||
A new function to disable the Proxmox subscription message with improved safety:
|
||||
- Creates a full backup before modifying any files
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
MIT License
|
||||
ProxMenux - An Interactive Menu for Proxmox VE Management
|
||||
Copyright (c) 2025 MacRimi
|
||||
|
||||
Copyright (c) 2024 MacRimi
|
||||
======================================================================
|
||||
LICENSE: GNU General Public License v3.0 (GPL-3.0)
|
||||
======================================================================
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
ProxMenux is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Under this license:
|
||||
1. Attribution: You must give appropriate credit to the original author (MacRimi).
|
||||
2. Copyleft: If you remix, transform, or build upon ProxMenux, you must
|
||||
distribute your contributions under the same GPL-3.0 license.
|
||||
3. Source Code: Anyone distributing a modified version must make the
|
||||
source code available.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
======================================================================
|
||||
|
||||
DISCLAIMER:
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. IN NO EVENT SHALL
|
||||
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING
|
||||
FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
@@ -57,20 +57,114 @@ Then, follow the on-screen options to manage your Proxmox server efficiently.
|
||||
|
||||
---
|
||||
|
||||
## 📌 System Requirements
|
||||
🖥 **Compatible with:**
|
||||
- Proxmox VE 8.x and 9.x
|
||||
|
||||
📦 **Dependencies:**
|
||||
- `bash`, `curl`, `wget`, `jq`, `whiptail`, `python3-venv` (These dependencies are installed automatically during setup.)
|
||||
- **Translations are handled in a Python virtual environment using `googletrans-env`.**
|
||||
## 🧪 Beta Program
|
||||
|
||||
Want to try the latest features before the official release and help shape the final version?
|
||||
|
||||
The **ProxMenux Beta Program** gives early access to new functionality — including the newest builds of ProxMenux Monitor — directly from the `develop` branch. Beta builds may contain bugs or incomplete features. Your feedback is what helps fix them before the stable release.
|
||||
|
||||
**Install the beta version:**
|
||||
|
||||
```bash
|
||||
bash -c "$(wget -qLO - https://raw.githubusercontent.com/MacRimi/ProxMenux/develop/install_proxmenux_beta.sh)"
|
||||
```
|
||||
|
||||
**What to expect:**
|
||||
|
||||
- You'll get new features and Monitor builds before anyone else
|
||||
- Some things may not work perfectly — that's expected and normal
|
||||
- When a stable release is published, ProxMenux will notify you on the next `menu` launch and offer to switch automatically
|
||||
|
||||
**How to report issues:**
|
||||
|
||||
Open a [GitHub Issue](https://github.com/MacRimi/ProxMenux/issues) and include:
|
||||
- What you did and what you expected to happen
|
||||
- Any error messages shown on screen
|
||||
- Logs from the Monitor if relevant:
|
||||
|
||||
```bash
|
||||
journalctl -u proxmenux-monitor -n 50
|
||||
```
|
||||
|
||||
> 💙 Thank you for being part of the beta program. Your help makes ProxMenux better for everyone.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 🖥️ ProxMenux Monitor
|
||||
|
||||
ProxMenux Monitor is an integrated web dashboard that provides real-time visibility into your Proxmox infrastructure — accessible from any browser on your network, without needing a terminal.
|
||||
|
||||
**What it offers:**
|
||||
|
||||
- Real-time monitoring of CPU, RAM, disk usage and network traffic
|
||||
- Overview of running VMs and LXC containers with status indicators
|
||||
- Login authentication to protect access
|
||||
- Two-Factor Authentication (2FA) with TOTP support
|
||||
- Reverse proxy support (Nginx / Traefik)
|
||||
- Designed to work across desktop and mobile devices
|
||||
|
||||
**Access:**
|
||||
|
||||
Once installed, the dashboard is available at:
|
||||
|
||||
```
|
||||
http://<your-proxmox-ip>:8008
|
||||
```
|
||||
|
||||
The Monitor is installed automatically as part of the standard ProxMenux installation and runs as a systemd service (`proxmenux-monitor.service`) that starts automatically on boot.
|
||||
|
||||
**Useful commands:**
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
systemctl status proxmenux-monitor
|
||||
|
||||
# View logs
|
||||
journalctl -u proxmenux-monitor -n 50
|
||||
|
||||
# Restart the service
|
||||
systemctl restart proxmenux-monitor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 🔧 Dependencies
|
||||
|
||||
The following dependencies are installed automatically during setup:
|
||||
|
||||
| Package | Purpose |
|
||||
|---|---|
|
||||
| `dialog` | Interactive terminal menus |
|
||||
| `curl` | Downloads and connectivity checks |
|
||||
| `jq` | JSON processing |
|
||||
| `git` | Repository cloning and updates |
|
||||
| `python3` + `python3-venv` | Translation support *(Translation version only)* |
|
||||
| `googletrans` | Google Translate library *(Translation version only)* |
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
## ⭐ Support the Project!
|
||||
If you find **ProxMenux** useful, consider giving it a ⭐ on GitHub to help others discover it!
|
||||
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions, bug reports and feature suggestions are welcome!
|
||||
|
||||
- 🐛 [Report a bug](https://github.com/MacRimi/ProxMenux/issues/new)
|
||||
- 💡 [Suggest a feature](https://github.com/MacRimi/ProxMenux/discussions)
|
||||
- 🔀 [Submit a pull request](https://github.com/MacRimi/ProxMenux/pulls)
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#MacRimi/ProxMenux&Date)
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
|
||||
---
|
||||
|
||||
# **Análisis Completo del proyecto ProxMenux**
|
||||
|
||||
## **1. Estructura General del Proyecto**
|
||||
|
||||
### **Archivos Principales**
|
||||
- **[install_proxmenux.sh](cci:7://file:///home/debian/src/ProxMenuxOffline/install_proxmenux.sh:0:0-0:0)**: Script de instalación principal (723 líneas)
|
||||
- **[menu](cci:7://file:///home/debian/src/ProxMenuxOffline/menu:0:0-0:0)**: Script principal que se instala como comando del sistema (93 líneas)
|
||||
- **[version.txt](cci:7://file:///home/debian/src/ProxMenuxOffline/version.txt:0:0-0:0)**: Control de versiones (actual: 1.1.7)
|
||||
|
||||
### **Directorios Principales**
|
||||
```
|
||||
ProxMenuxOffline/
|
||||
├── scripts/ # 122 archivos de scripts bash
|
||||
│ ├── menus/ # 13 scripts de menús
|
||||
│ ├── lxc/ # 6 scripts para contenedores LXC
|
||||
│ ├── vm/ # 13 scripts para máquinas virtuales
|
||||
│ ├── storage/ # 9 scripts de almacenamiento
|
||||
│ ├── share/ # 12 scripts para compartir recursos
|
||||
│ ├── utilities/ # 6 utilidades del sistema
|
||||
│ ├── global/ # 10 funciones comunes
|
||||
│ ├── backup_restore/ # 6 scripts de respaldo
|
||||
│ ├── post_install/ # 3 scripts post-instalación
|
||||
│ └── gpu_tpu/ # Scripts para hardware gráfico
|
||||
├── web/ # 136 archivos - Dashboard Next.js
|
||||
├── AppImage/ # 54 archivos - ProxMenux Monitor
|
||||
├── json/ # Archivos de caché de traducciones
|
||||
├── lang/ # Archivos de idioma
|
||||
├── guides/ # 5 guías de usuario
|
||||
└── images/ # 7 imágenes del proyecto
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **2. Flujo de Instalación**
|
||||
|
||||
### **Script: [install_proxmenux.sh](cci:7://file:///home/debian/src/ProxMenuxOffline/install_proxmenux.sh:0:0-0:0)**
|
||||
|
||||
**Fase 1: Inicialización**
|
||||
- Verifica permisos root (línea 716-719)
|
||||
- Carga [utils.sh](cci:7://file:///home/debian/src/ProxMenuxOffline/scripts/utils.sh:0:0-0:0) desde GitHub (línea 54-57)
|
||||
- Limpia archivos corruptos de configuración (línea 59-68)
|
||||
|
||||
**Fase 2: Detección de Instalación Existente**
|
||||
- Función [check_existing_installation()](cci:1://file:///home/debian/src/ProxMenuxOffline/install_proxmenux.sh:70:0-106:1) (línea 71-107)
|
||||
- Detecta 4 tipos: `none`, `normal`, `translation`, `unknown`
|
||||
- Verifica entorno virtual Python en `/opt/googletrans-env`
|
||||
- Verifica configuración de idioma en `/usr/local/share/proxmenux/config.json`
|
||||
|
||||
**Fase 3: Selección de Versión**
|
||||
- **Versión Normal** (opción 1):
|
||||
- Dependencias: `dialog`, `curl`, `jq`
|
||||
- Solo inglés
|
||||
- Más ligera y rápida
|
||||
|
||||
- **Versión con Traducción** (opción 2):
|
||||
- Dependencias adicionales: `python3`, `python3-venv`, `python3-pip`
|
||||
- Instala `googletrans==4.0.0-rc1` en entorno virtual
|
||||
- Soporte multiidioma: en, es, fr, de, it, pt
|
||||
- **Nota**: No compatible con Proxmox VE 9+ (línea 639-658)
|
||||
|
||||
**Fase 4: Instalación Normal** ([install_normal_version()](cci:1://file:///home/debian/src/ProxMenuxOffline/install_proxmenux.sh:402:0-484:1))
|
||||
1. Instala dependencias básicas
|
||||
2. Crea directorios:
|
||||
- `/usr/local/bin` (ejecutables)
|
||||
- `/usr/local/share/proxmenux` (archivos del sistema)
|
||||
3. Descarga desde GitHub:
|
||||
- `utils.sh` → `/usr/local/share/proxmenux/utils.sh`
|
||||
- `menu` → `/usr/local/bin/menu`
|
||||
- `version.txt` → `/usr/local/share/proxmenux/version.txt`
|
||||
4. Instala ProxMenux Monitor (AppImage)
|
||||
|
||||
**Fase 5: Instalación con Traducción** (`install_translation_version()`)
|
||||
- Pasos adicionales:
|
||||
- Selector de idioma interactivo (línea 234-273)
|
||||
- Crea entorno virtual Python en `/opt/googletrans-env`
|
||||
- Instala googletrans con pip
|
||||
- Descarga `cache.json` con traducciones precargadas
|
||||
- Sistema de caché para reducir llamadas a la API de traducción
|
||||
|
||||
**Fase 6: ProxMenux Monitor**
|
||||
- Descarga AppImage desde GitHub (línea 317-360)
|
||||
- Verifica checksum SHA256 (línea 333-351)
|
||||
- Crea servicio systemd `/etc/systemd/system/proxmenux-monitor.service`
|
||||
- Puerto por defecto: 8008
|
||||
- Se ejecuta como usuario root
|
||||
- Auto-inicio en boot
|
||||
|
||||
---
|
||||
|
||||
## **3. Funcionamiento del Comando `menu`**
|
||||
|
||||
### **Script Principal: `/usr/local/bin/menu`**
|
||||
|
||||
**Flujo de Ejecución:**
|
||||
|
||||
1. **Carga de Configuración** (línea 33-44):
|
||||
```bash
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
source "$UTILS_FILE"
|
||||
```
|
||||
|
||||
2. **Sistema de Traducción** (línea 89-92):
|
||||
- Carga idioma desde `config.json`
|
||||
- Inicializa caché de traducciones
|
||||
- Función `translate()` en `utils.sh`
|
||||
|
||||
3. **Verificación de Actualizaciones** (línea 48-80):
|
||||
- Compara versión local vs remota
|
||||
- Prompt interactivo para actualizar
|
||||
- Descarga y ejecuta nuevo `install_proxmenux.sh` si hay actualización
|
||||
|
||||
4. **Ejecución del Menú Principal** (línea 84-86):
|
||||
```bash
|
||||
exec bash <(curl -fsSL "$REPO_URL/scripts/menus/main_menu.sh")
|
||||
```
|
||||
|
||||
**Importante**: El comando `menu` **NO ejecuta scripts locales**, siempre descarga desde GitHub.
|
||||
|
||||
---
|
||||
|
||||
## **4. Sistema de Menús**
|
||||
|
||||
### **Menú Principal: `scripts/menus/main_menu.sh`**
|
||||
|
||||
**Compatibilidad PVE 9** (línea 26-64):
|
||||
- Detecta versión de Proxmox
|
||||
- Si PVE 9+ y tiene traducciones instaladas → fuerza reinstalación en versión normal
|
||||
- Previene errores de compatibilidad
|
||||
|
||||
**Opciones del Menú** (línea 97-111):
|
||||
```
|
||||
1. Settings post-install Proxmox → menu_post_install.sh
|
||||
2. Hardware: GPUs and Coral-TPU → hw_grafics_menu.sh
|
||||
3. Create VM from template → create_vm_menu.sh
|
||||
4. Disk and Storage Manager → storage_menu.sh
|
||||
5. Mount and Share Manager → share_menu.sh
|
||||
6. Proxmox VE Helper Scripts → menu_Helper_Scripts.sh
|
||||
7. Network Management → network_menu.sh
|
||||
8. Utilities and Tools → utilities_menu.sh
|
||||
h. Help and Info Commands → help_info_menu.sh
|
||||
s. Settings → config_menu.sh
|
||||
0. Exit
|
||||
```
|
||||
|
||||
**Patrón de Ejecución**:
|
||||
```bash
|
||||
exec bash <(curl -s "$REPO_URL/scripts/menus/submenu.sh")
|
||||
```
|
||||
|
||||
Todos los menús descargan y ejecutan scripts desde GitHub en tiempo real.
|
||||
|
||||
---
|
||||
|
||||
## **5. Scripts Locales vs Remotos**
|
||||
|
||||
### **Estado Actual**
|
||||
- **Scripts locales**: Están presentes en el repositorio (122 archivos)
|
||||
- **Ejecución**: Siempre desde GitHub mediante `curl`
|
||||
- **Ventaja actual**: Actualizaciones automáticas sin reinstalar
|
||||
- **Desventaja**: Requiere conexión a internet constante
|
||||
|
||||
### **Scripts Principales Disponibles Localmente**
|
||||
|
||||
**Gestión de VMs** (`scripts/vm/`):
|
||||
- `create_vm.sh` - Crear VMs
|
||||
- `synology.sh` (39KB) - Instalación Synology DSM
|
||||
- `zimaos.sh` (40KB) - Instalación ZimaOS
|
||||
- `uupdump_creator.sh` - Creador de ISOs Windows
|
||||
- `select_windows_iso.sh`, `select_linux_iso.sh`, `select_nas_iso.sh`
|
||||
|
||||
**Gestión de LXC** (`scripts/lxc/`):
|
||||
- `lxc-manual-guide.sh` - Guía manual
|
||||
- `lxc-privileged-to-unprivileged.sh`
|
||||
- `lxc-unprivileged-to-privileged.sh`
|
||||
|
||||
**Almacenamiento** (`scripts/storage/`):
|
||||
- `disk-passthrough.sh` - Passthrough disco a VM
|
||||
- `disk-passthrough_ct.sh` - Passthrough disco a LXC (22KB)
|
||||
- `import-disk-image.sh` - Importar imágenes
|
||||
- `format-disk.sh`, `mount-disk-on-host.sh`
|
||||
|
||||
**Compartir Recursos** (`scripts/share/`):
|
||||
- `lxc-mount-manager_minimal.sh` (35KB) - Gestión mount points
|
||||
- `nfs_host.sh` (35KB) - Servidor NFS en host
|
||||
- `samba_host.sh` (52KB) - Servidor Samba en host
|
||||
- `nfs_client.sh`, `samba_client.sh` - Clientes en LXC
|
||||
- `local-shared-manager.sh` - Directorios compartidos locales
|
||||
|
||||
**Post-Instalación** (`scripts/post_install/`):
|
||||
- `auto_post_install.sh` (29KB) - Automatizado sin interacción
|
||||
- `customizable_post_install.sh` (148KB) - Personalizable
|
||||
- `uninstall-tools.sh` (34KB) - Desinstalador
|
||||
|
||||
**Utilidades** (`scripts/utilities/`):
|
||||
- `upgrade_pve8_to_pve9.sh` (35KB) - Upgrade PVE 8→9
|
||||
- `system_utils.sh` (20KB) - Instalador de utilidades
|
||||
- `proxmox_update.sh` - Actualización de Proxmox
|
||||
|
||||
**Red** (`scripts/menus/network_menu.sh`):
|
||||
- 43KB de funcionalidades de red
|
||||
- Optimizaciones para LXC+NFS
|
||||
|
||||
**Global** (`scripts/global/`):
|
||||
- `update-pve.sh`, `update-pve8.sh`, `update-pve9_2.sh`
|
||||
- `remove-banner-pve8.sh`, `remove-banner-pve9.sh`
|
||||
- `share-common.func` (30KB) - Funciones compartidas
|
||||
|
||||
---
|
||||
|
||||
## **6. Sistema de Utilidades: `utils.sh`**
|
||||
|
||||
### **Funciones Principales**
|
||||
|
||||
**Interfaz Visual** (línea 50-71):
|
||||
- Definición de colores ANSI
|
||||
- Códigos de estilo para terminal
|
||||
- Spinner animado (línea 75-88)
|
||||
|
||||
**Mensajes Estandarizados**:
|
||||
- `msg_info()` - Info con spinner
|
||||
- `msg_ok()` - Éxito (checkmark verde)
|
||||
- `msg_error()` - Error (rojo)
|
||||
- `msg_warn()` - Advertencia (amarillo)
|
||||
- `msg_title()` - Títulos
|
||||
- `type_text()` - Efecto máquina de escribir
|
||||
|
||||
**Sistema de Traducción** (línea 232-305):
|
||||
```bash
|
||||
translate() {
|
||||
# Si idioma es "en" → retorna texto original
|
||||
# Busca en caché local (cache.json)
|
||||
# Si no existe → llama a googletrans vía Python
|
||||
# Guarda en caché para futuras traducciones
|
||||
# Limpia prefijos de contexto
|
||||
}
|
||||
```
|
||||
|
||||
**Contexto de Traducción** (línea 48):
|
||||
```bash
|
||||
TRANSLATION_CONTEXT="Context: Technical message for Proxmox and IT. Translate:"
|
||||
```
|
||||
|
||||
**Logo ASCII** (línea 314-400):
|
||||
- Dos versiones: terminal noVNC y SSH
|
||||
- Detección automática del entorno
|
||||
- Diseño en ASCII art con colores
|
||||
|
||||
---
|
||||
|
||||
## **7. ProxMenux Monitor**
|
||||
|
||||
### **Componente Web (AppImage)**
|
||||
|
||||
**Tecnología**:
|
||||
- **Frontend**: Next.js 14, React 18, TypeScript
|
||||
- **UI**: Radix UI + shadcn/ui + Tailwind CSS
|
||||
- **Gráficos**: Recharts
|
||||
- **Backend**: Flask (Python) para recolección de datos del sistema
|
||||
- **Empaquetado**: AppImage (10.3 MB)
|
||||
|
||||
**Características**:
|
||||
- Dashboard en tiempo real
|
||||
- Monitoreo de CPU, RAM, temperatura
|
||||
- Estado de VMs y LXC containers
|
||||
- Gestión de almacenamiento visual
|
||||
- Estadísticas de red
|
||||
- Logs del sistema
|
||||
- Tema oscuro/claro
|
||||
- Responsive design
|
||||
- Puerto: 8008
|
||||
|
||||
**Servicio Systemd**:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=ProxMenux Monitor - Web Dashboard
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/usr/local/share/proxmenux
|
||||
ExecStart=/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
Environment="PORT=8008"
|
||||
```
|
||||
|
||||
**Estado**: Se instala automáticamente en ambas versiones (normal y traducción)
|
||||
|
||||
---
|
||||
|
||||
## **8. Sistema de Configuración**
|
||||
|
||||
### **Archivos de Configuración**
|
||||
|
||||
**`/usr/local/share/proxmenux/config.json`**:
|
||||
- Estado de instalación de componentes
|
||||
- Idioma seleccionado
|
||||
- Timestamps de instalación
|
||||
- Estados: `installed`, `already_installed`, `failed`
|
||||
|
||||
**Componentes Rastreados** (línea 201):
|
||||
```json
|
||||
{
|
||||
"dialog": {"status": "installed", "timestamp": "..."},
|
||||
"curl": {"status": "already_installed", "timestamp": "..."},
|
||||
"jq": {"status": "installed", "timestamp": "..."},
|
||||
"python3": {"status": "installed", "timestamp": "..."},
|
||||
"virtual_environment": {"status": "created", "timestamp": "..."},
|
||||
"googletrans": {"status": "installed", "timestamp": "..."},
|
||||
"proxmenux_monitor": {"status": "installed", "timestamp": "..."},
|
||||
"language": "es"
|
||||
}
|
||||
```
|
||||
|
||||
**`/usr/local/share/proxmenux/cache.json`**:
|
||||
- Traducciones cacheadas (100 KB)
|
||||
- Formato: `{"texto_original": {"es": "traducción", "fr": "traduction"}}`
|
||||
- Reduce llamadas a Google Translate API
|
||||
|
||||
**`/usr/local/share/proxmenux/installed_tools.json`**:
|
||||
- Registro de herramientas post-instalación
|
||||
- Usado por el desinstalador
|
||||
|
||||
---
|
||||
|
||||
## **9. Funcionalidades Destacadas**
|
||||
|
||||
### **Post-Instalación Automatizada**
|
||||
- **Optimizaciones de repositorios**: Limpia duplicados, configura repos gratuitos
|
||||
- **Eliminación de banner de suscripción**: Con respaldo y reversión
|
||||
- **Optimización de memoria y kernel**: Ajustes según RAM disponible
|
||||
- **Log2RAM**: Instalación automática en SSD/NVMe
|
||||
- **Network tuning**: Optimización de stack de red
|
||||
- **Límites del sistema**: Aumenta límites de archivos y procesos
|
||||
- **Configuración de journald**: Ajustada para Log2RAM
|
||||
- **Entropía**: Mejora generación de números aleatorios
|
||||
- **Aliases bash**: Personalización del entorno
|
||||
|
||||
### **Gestión de Compartición de Recursos**
|
||||
**Enfoque**: Mount Points LXC (Host ↔ Container)
|
||||
- Detección automática de tipo de filesystem
|
||||
- Mapeo UID/GID para contenedores unprivileged
|
||||
- Visualización de mount points existentes
|
||||
- Eliminación segura con verificación
|
||||
|
||||
**Configuraciones disponibles**:
|
||||
- NFS: Host, Client LXC, Server LXC
|
||||
- Samba: Host, Client LXC, Server LXC
|
||||
- Directorios locales compartidos
|
||||
|
||||
### **Hardware Especializado**
|
||||
- **Coral TPU**: Instalación de drivers compatible con PVE 8 y 9
|
||||
- **GPUs**: Passthrough y configuración para VMs y LXC
|
||||
- **iGPU**: Configuración para contenedores LXC
|
||||
|
||||
### **Upgrade PVE 8 → 9**
|
||||
- Script de 35 KB con verificaciones exhaustivas
|
||||
- Guía manual interactiva
|
||||
- Checker de compatibilidad
|
||||
|
||||
---
|
||||
|
||||
## **10. Arquitectura de Ejecución**
|
||||
|
||||
### **Patrón de Descarga Dinámica**
|
||||
|
||||
**Todos los scripts siguen este patrón**:
|
||||
```bash
|
||||
exec bash <(curl -s "$REPO_URL/scripts/path/to/script.sh")
|
||||
```
|
||||
|
||||
**Ventajas**:
|
||||
- ✅ Usuarios siempre tienen la última versión
|
||||
- ✅ No requiere reinstalación para actualizaciones
|
||||
- ✅ Hotfixes inmediatos
|
||||
- ✅ Control centralizado de versiones
|
||||
|
||||
**Consideraciones**:
|
||||
- ⚠️ Requiere internet en cada ejecución
|
||||
- ⚠️ Dependencia de disponibilidad de GitHub
|
||||
- ⚠️ No funciona offline
|
||||
- ⚠️ Los scripts locales del repo no se usan directamente
|
||||
|
||||
### **Sistema de Versionado**
|
||||
- `version.txt` en repo: versión remota
|
||||
- `/usr/local/share/proxmenux/version.txt`: versión local instalada
|
||||
- Check en cada ejecución del comando `menu`
|
||||
- Prompt para actualizar si hay nueva versión
|
||||
|
||||
---
|
||||
|
||||
## **11. Flujo de Navegación**
|
||||
|
||||
```
|
||||
Comando: menu
|
||||
↓
|
||||
Verifica actualizaciones
|
||||
↓
|
||||
Carga utils.sh y traducciones
|
||||
↓
|
||||
Descarga main_menu.sh desde GitHub
|
||||
↓
|
||||
Usuario selecciona opción
|
||||
↓
|
||||
Descarga submenu correspondiente desde GitHub
|
||||
↓
|
||||
Usuario selecciona acción
|
||||
↓
|
||||
Descarga y ejecuta script específico desde GitHub
|
||||
↓
|
||||
Retorna al menú anterior
|
||||
```
|
||||
|
||||
**Ejemplo de navegación**:
|
||||
```
|
||||
menu → main_menu.sh
|
||||
→ opción 5: share_menu.sh
|
||||
→ opción 4: lxc-mount-manager_minimal.sh (35KB)
|
||||
→ Ejecuta acciones
|
||||
→ Retorna a share_menu.sh
|
||||
→ opción 0: Retorna a main_menu.sh
|
||||
→ opción 0: Exit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **12. Integración con Comunidad**
|
||||
|
||||
### **Scripts de la Comunidad Integrados**
|
||||
|
||||
**Proxmox VE Helper-Scripts**:
|
||||
- Post-install script oficial
|
||||
- Ejecutado desde: `https://github.com/community-scripts/ProxmoxVE`
|
||||
|
||||
**Xshok-proxmox** (fork):
|
||||
- Post-install alternativo
|
||||
- Descarga desde fork de MacRimi
|
||||
|
||||
**Elementos compartidos**:
|
||||
- Funciones de `utils.sh` basadas en Helper-Scripts
|
||||
- Misma filosofía de mensajes estandarizados
|
||||
- Licencia MIT compatible
|
||||
|
||||
---
|
||||
|
||||
## **13. Sistema de Desinstalación**
|
||||
|
||||
### **Función: `uninstall_proxmenux()`** (línea 109-161)
|
||||
|
||||
**Proceso**:
|
||||
1. Confirmación interactiva (whiptail)
|
||||
2. Desinstala googletrans y entorno virtual Python
|
||||
3. Selector de dependencias a eliminar (python3, python3-venv, pip)
|
||||
4. Elimina `/usr/local/bin/menu`
|
||||
5. Elimina `/usr/local/share/proxmenux/`
|
||||
6. Restaura `.bashrc` desde backup
|
||||
7. Restaura `/etc/motd` desde backup
|
||||
|
||||
**Tool-specific uninstaller**: `scripts/post_install/uninstall-tools.sh`
|
||||
- Lee `installed_tools.json`
|
||||
- Permite desinstalar herramientas individualmente
|
||||
- Restaura configuraciones originales
|
||||
|
||||
---
|
||||
|
||||
## **14. Estructura de Archivos JSON**
|
||||
|
||||
### **`json/cache.json`** (100 KB)
|
||||
Traducciones precargadas para acelerar el sistema
|
||||
|
||||
### **`json/helpers_cache.json`** (273 KB)
|
||||
Caché extendido, probablemente para Helper Scripts
|
||||
|
||||
### **`lang/cache.json`** (5.5 KB)
|
||||
Caché de idiomas específico
|
||||
|
||||
### **`lang/en.lang`** y **`lang/es.lang`**
|
||||
Archivos de idioma estáticos (4-5 KB cada uno)
|
||||
|
||||
---
|
||||
|
||||
## **15. Resumen de Componentes**
|
||||
|
||||
| Componente | Ubicación | Función |
|
||||
|------------|-----------|---------|
|
||||
| **Instalador** | `install_proxmenux.sh` | Instalación inicial y actualizaciones |
|
||||
| **Comando principal** | `/usr/local/bin/menu` | Punto de entrada del usuario |
|
||||
| **Utilidades** | `/usr/local/share/proxmenux/utils.sh` | Funciones compartidas |
|
||||
| **Configuración** | `/usr/local/share/proxmenux/config.json` | Estado del sistema |
|
||||
| **Caché traducciones** | `/usr/local/share/proxmenux/cache.json` | Traducciones cacheadas |
|
||||
| **Entorno Python** | `/opt/googletrans-env/` | Traducción (solo versión translation) |
|
||||
| **Monitor** | `/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage` | Dashboard web |
|
||||
| **Servicio Monitor** | `/etc/systemd/system/proxmenux-monitor.service` | Servicio systemd |
|
||||
| **Scripts** | GitHub (descarga dinámica) | Todos los scripts funcionales |
|
||||
|
||||
---
|
||||
|
||||
## **Conclusión**
|
||||
|
||||
ProxMenuxOffline es un **sistema modular de gestión de Proxmox VE** que utiliza una arquitectura híbrida:
|
||||
|
||||
- **Núcleo local**: Comando `menu`, utilidades, sistema de configuración
|
||||
- **Scripts remotos**: Toda la funcionalidad se descarga dinámicamente desde GitHub
|
||||
- **Dashboard web**: AppImage independiente con Next.js + Flask
|
||||
- **Sistema de traducción**: Opcional, basado en Python + googletrans + caché
|
||||
|
||||
El proyecto tiene **122 scripts bash** en el repositorio local que **podrían ejecutarse localmente**, pero actualmente **todos se descargan desde GitHub en tiempo de ejecución**. Esta arquitectura prioriza mantener a los usuarios actualizados sobre la ejecución offline.
|
||||
@@ -0,0 +1,699 @@
|
||||
# Scripts a Modificar para Ejecución 100% Local
|
||||
|
||||
**Fecha**: 2025-11-01
|
||||
**Objetivo**: Eliminar dependencias de GitHub y permitir ejecución completamente local
|
||||
**Repositorio**: ProxMenuxDotDeb
|
||||
|
||||
---
|
||||
|
||||
## Resumen Ejecutivo
|
||||
|
||||
Para que ProxMenux funcione 100% localmente sin depender de GitHub, se deben modificar **47 archivos** en total:
|
||||
|
||||
- **2 archivos principales** (instalador y comando menu)
|
||||
- **13 scripts de menús** (sistema de navegación)
|
||||
- **32 scripts funcionales** (operaciones específicas)
|
||||
|
||||
**Cambios principales**:
|
||||
1. Cambiar `REPO_URL` de GitHub a rutas locales del sistema
|
||||
2. Reemplazar descargas `curl` por ejecución de scripts locales
|
||||
3. Copiar todos los scripts a `/usr/local/share/proxmenux/scripts/` durante instalación
|
||||
|
||||
---
|
||||
|
||||
## 1. Archivos Principales (CRÍTICOS) ⚠️
|
||||
|
||||
### 1.1. `install_proxmenux.sh` (Raíz del repositorio)
|
||||
|
||||
**Líneas a modificar**:
|
||||
- **Línea 37**: `REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"`
|
||||
- **Línea 38**: `UTILS_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/scripts/utils.sh"`
|
||||
- **Línea 54-57**: Carga de `utils.sh` con curl
|
||||
- **Línea 459-476**: Descarga de archivos con wget (versión normal)
|
||||
- **Línea 583-603**: Descarga de archivos con wget (versión traducción)
|
||||
|
||||
**Cambios necesarios**:
|
||||
```bash
|
||||
# Cambiar URLs a rutas locales
|
||||
REPO_URL="/usr/local/share/proxmenux"
|
||||
UTILS_URL="./scripts/utils.sh"
|
||||
|
||||
# Reemplazar wget por cp
|
||||
# En lugar de descargar, copiar archivos locales del repositorio
|
||||
```
|
||||
|
||||
**Impacto**: 🔴 CRÍTICO - Sin esto, la instalación falla completamente
|
||||
|
||||
---
|
||||
|
||||
### 1.2. `menu` (Raíz del repositorio)
|
||||
|
||||
**Líneas a modificar**:
|
||||
- **Línea 34**: `REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"`
|
||||
- **Línea 52**: Verificación de actualizaciones (curl remoto)
|
||||
- **Línea 72**: Descarga de instalador actualizado
|
||||
- **Línea 85**: `exec bash <(curl -fsSL "$REPO_URL/scripts/menus/main_menu.sh")`
|
||||
|
||||
**Cambios necesarios**:
|
||||
```bash
|
||||
# Cambiar a ruta local
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
|
||||
# Ejecutar localmente
|
||||
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
|
||||
```
|
||||
|
||||
**Impacto**: 🔴 CRÍTICO - Es el punto de entrada del usuario
|
||||
|
||||
---
|
||||
|
||||
## 2. Scripts de Menús (13 archivos)
|
||||
|
||||
### 2.1. `scripts/menus/main_menu.sh` ⭐
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 14**: `REPO_URL`
|
||||
- **Línea 57**: curl para reinstalación PVE9
|
||||
- **Líneas 125-135**: Todas las opciones del menú (12 líneas)
|
||||
|
||||
**Comandos a reemplazar**:
|
||||
```bash
|
||||
# Todas estas líneas:
|
||||
exec bash <(curl -s "$REPO_URL/scripts/menus/menu_post_install.sh")
|
||||
exec bash <(curl -s "$REPO_URL/scripts/menus/hw_grafics_menu.sh")
|
||||
exec bash <(curl -s "$REPO_URL/scripts/menus/create_vm_menu.sh")
|
||||
exec bash <(curl -s "$REPO_URL/scripts/menus/storage_menu.sh")
|
||||
exec bash <(curl -s "$REPO_URL/scripts/menus/share_menu.sh")
|
||||
exec bash <(curl -s "$REPO_URL/scripts/menus/menu_Helper_Scripts.sh")
|
||||
exec bash <(curl -s "$REPO_URL/scripts/menus/network_menu.sh")
|
||||
exec bash <(curl -s "$REPO_URL/scripts/menus/utilities_menu.sh")
|
||||
bash <(curl -s "$REPO_URL/scripts/help_info_menu.sh")
|
||||
exec bash <(curl -s "$REPO_URL/scripts/menus/config_menu.sh")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2. `scripts/menus/menu_post_install.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 12**: `REPO_URL`
|
||||
- **Línea 73**: `bash <(curl -s $REPO_URL/scripts/post_install/auto_post_install.sh)`
|
||||
- **Línea 171**: `exec bash <(curl -s "$REPO_URL/scripts/menus/main_menu.sh")`
|
||||
|
||||
**Nota**: Mantener URLs remotas para scripts de comunidad externa (líneas 90-91)
|
||||
|
||||
---
|
||||
|
||||
### 2.3. `scripts/menus/config_menu.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 13**: `REPO_URL`
|
||||
- No tiene llamadas curl ✅
|
||||
|
||||
---
|
||||
|
||||
### 2.4. `scripts/menus/create_vm_menu.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 13**: `REPO_URL`
|
||||
- Múltiples `exec bash <(curl -s ...)` en opciones del menú
|
||||
|
||||
---
|
||||
|
||||
### 2.5. `scripts/menus/hw_grafics_menu.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 13**: `REPO_URL`
|
||||
- **Líneas 38, 44, 50, 55, 56**: Llamadas curl
|
||||
|
||||
**Comandos a reemplazar**:
|
||||
```bash
|
||||
bash <(curl -s "$REPO_URL/scripts/configure_igpu_lxc.sh")
|
||||
bash <(curl -s "$REPO_URL/scripts/install_coral_lxc.sh")
|
||||
bash <(curl -s "$REPO_URL/scripts/gpu_tpu/install_coral_pve9.sh")
|
||||
exec bash <(curl -s "$REPO_URL/scripts/menus/main_menu.sh")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.6. `scripts/menus/lxc_menu.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 13**: `REPO_URL`
|
||||
- Todos los `exec bash <(curl ...)`
|
||||
|
||||
---
|
||||
|
||||
### 2.7. `scripts/menus/menu_Helper_Scripts.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 13**: `REPO_URL`
|
||||
- **Línea 296**: `exec bash <(curl -s ...)`
|
||||
|
||||
**Nota**: Mantener URLs de Helper-Scripts externos (comunidad)
|
||||
|
||||
---
|
||||
|
||||
### 2.8. `scripts/menus/network_menu.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 13**: `REPO_URL`
|
||||
- **Línea 1085**: `exec bash <(curl -s "$REPO_URL/scripts/menus/main_menu.sh")`
|
||||
|
||||
---
|
||||
|
||||
### 2.9. `scripts/menus/share_menu.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 13**: `REPO_URL`
|
||||
- **Líneas 46, 55-82, 85**: 11 llamadas curl
|
||||
|
||||
**Comandos a reemplazar**:
|
||||
```bash
|
||||
bash <(curl -s "$REPO_URL/scripts/share/nfs_host.sh")
|
||||
bash <(curl -s "$REPO_URL/scripts/share/samba_host.sh")
|
||||
bash <(curl -s "$REPO_URL/scripts/share/local-shared-manager.sh")
|
||||
bash <(curl -s "$REPO_URL/scripts/share/lxc-mount-manager_minimal.sh")
|
||||
bash <(curl -s "$REPO_URL/scripts/share/nfs_client.sh")
|
||||
bash <(curl -s "$REPO_URL/scripts/share/samba_client.sh")
|
||||
bash <(curl -s "$REPO_URL/scripts/share/nfs_lxc_server.sh")
|
||||
bash <(curl -s "$REPO_URL/scripts/share/samba_lxc_server.sh")
|
||||
bash <(curl -s "$REPO_URL/scripts/share/commands_share.sh")
|
||||
exec bash <(curl -s "$REPO_URL/scripts/menus/main_menu.sh") # 2 veces
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.10. `scripts/menus/storage_menu.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 15**: `REPO_URL`
|
||||
- **Líneas 39, 42, 45, 48, 51**: 5 llamadas curl
|
||||
|
||||
**Comandos a reemplazar**:
|
||||
```bash
|
||||
bash <(curl -s "$REPO_URL/scripts/storage/disk-passthrough.sh")
|
||||
bash <(curl -s "$REPO_URL/scripts/storage/disk-passthrough_ct.sh")
|
||||
bash <(curl -s "$REPO_URL/scripts/storage/import-disk-image.sh")
|
||||
exec bash <(curl -s "$REPO_URL/scripts/menus/main_menu.sh") # 2 veces
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.11. `scripts/menus/utilities_menu.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 15**: `REPO_URL`
|
||||
- **Líneas 39, 45, 67, 74, 79, 80**: 6 llamadas curl
|
||||
|
||||
**Comandos a reemplazar**:
|
||||
```bash
|
||||
bash <(curl -s "$REPO_URL/scripts/utilities/uup_dump_iso_creator.sh")
|
||||
bash <(curl -s "$REPO_URL/scripts/utilities/system_utils.sh")
|
||||
bash <(curl -s "$REPO_URL/scripts/utilities/proxmox_update.sh")
|
||||
bash <(curl -s "$REPO_URL/scripts/utilities/upgrade_pve8_to_pve9.sh")
|
||||
exec bash <(curl -s "$REPO_URL/scripts/menus/main_menu.sh") # 2 veces
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.12. `scripts/menus/main_menu_.sh`
|
||||
|
||||
**Modificaciones**: Igual que `main_menu.sh` (archivo alternativo/backup)
|
||||
|
||||
---
|
||||
|
||||
### 2.13. `scripts/menus/sm.sh`
|
||||
|
||||
**Modificaciones**: Igual que `share_menu.sh` (archivo alternativo)
|
||||
|
||||
---
|
||||
|
||||
## 3. Scripts Post-Instalación (3 archivos)
|
||||
|
||||
### 3.1. `scripts/post_install/auto_post_install.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 39**: `REPO_URL`
|
||||
- **Línea 110**: `bash <(curl -fsSL "$REPO_URL/scripts/global/update-pve9_2.sh")`
|
||||
- **Línea 113**: `bash <(curl -fsSL "$REPO_URL/scripts/global/update-pve8.sh")`
|
||||
- **Línea 150**: `bash <(curl -fsSL "$REPO_URL/scripts/global/remove-banner-pve-v3.sh")`
|
||||
- **Línea 157**: `bash <(curl -fsSL "$REPO_URL/scripts/global/remove-banner-pve8.sh")`
|
||||
|
||||
---
|
||||
|
||||
### 3.2. `scripts/post_install/customizable_post_install.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 39**: `REPO_URL`
|
||||
- **Línea 197**: `bash <(curl -fsSL "$REPO_URL/scripts/global/update-pve9_2.sh")`
|
||||
- **Línea 200**: `bash <(curl -fsSL "$REPO_URL/scripts/global/update-pve8.sh")`
|
||||
- **Línea 2905**: `bash <(curl -fsSL "$REPO_URL/scripts/global/remove-banner-pve-v3.sh")`
|
||||
- **Línea 2908**: `bash <(curl -fsSL "$REPO_URL/scripts/global/remove-banner-pve8.sh")`
|
||||
|
||||
---
|
||||
|
||||
### 3.3. `scripts/post_install/uninstall-tools.sh`
|
||||
|
||||
**Modificaciones**: Solo lectura de configs locales ✅
|
||||
|
||||
---
|
||||
|
||||
## 4. Scripts de VMs (8 archivos)
|
||||
|
||||
### 4.1. `scripts/vm/create_vm.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 29**: `REPO_URL`
|
||||
- **Líneas 30-32**: `VM_REPO`, `ISO_REPO`, `MENU_REPO`
|
||||
|
||||
---
|
||||
|
||||
### 4.2. `scripts/vm/select_linux_iso.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 28**: `REPO_URL`
|
||||
- **Línea 222**: `exec bash <(curl -s "$REPO_URL/scripts/vm/create_vm.sh")`
|
||||
|
||||
---
|
||||
|
||||
### 4.3. `scripts/vm/select_windows_iso.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 27**: `REPO_URL`
|
||||
- **Línea 28**: `UUP_REPO`
|
||||
|
||||
---
|
||||
|
||||
### 4.4. `scripts/vm/select_nas_iso.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 31**: `REPO_URL`
|
||||
- **Línea 65**: `bash <(curl -s "$REPO_URL/scripts/vm/synology.sh")`
|
||||
- **Línea 106**: `bash <(curl -s "$REPO_URL/scripts/vm/zimaos.sh")`
|
||||
|
||||
---
|
||||
|
||||
### 4.5. `scripts/vm/synology.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 32**: `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 4.6. `scripts/vm/synology_.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 32**: `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 4.7. `scripts/vm/zimaos.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- Verificar si tiene `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 4.8. `scripts/vm/vm_creator.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 497**: `bash <(curl -fsSL "$REPO_URL/scripts/menus/create_vm_menu.sh")`
|
||||
|
||||
---
|
||||
|
||||
## 5. Scripts de LXC (4 archivos)
|
||||
|
||||
### 5.1. `scripts/lxc/lxc-manual-guide.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 14**: `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 5.2. `scripts/lxc/lxc-privileged-to-unprivileged.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 18**: `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 5.3. `scripts/lxc/lxc-unprivileged-to-privileged.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 19**: `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 5.4. `scripts/lxc/lxc-mount-manager_minimal.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- Verificar si tiene `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
## 6. Scripts de Compartir Recursos (9 archivos)
|
||||
|
||||
### 6.1. `scripts/share/nfs_host.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 16**: `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 6.2. `scripts/share/nfs_client.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 16**: `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 6.3. `scripts/share/nfs_lxc_server.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 16**: `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 6.4. `scripts/share/samba_host.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 16**: `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 6.5. `scripts/share/samba_client.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 18**: `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 6.6. `scripts/share/samba_lxc_server.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 16**: `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 6.7. `scripts/share/local-shared-manager.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 13**: `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 6.8. `scripts/share/lxc-mount-manager_minimal.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- Verificar `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 6.9. `scripts/share/commands_share.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 14**: `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
## 7. Scripts de Almacenamiento (3 archivos)
|
||||
|
||||
### 7.1. `scripts/storage/disk-passthrough.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- Verificar `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 7.2. `scripts/storage/disk-passthrough_ct.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- Verificar `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 7.3. `scripts/storage/import-disk-image.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 30**: `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
## 8. Scripts de Utilidades (4 archivos)
|
||||
|
||||
### 8.1. `scripts/utilities/upgrade_pve8_to_pve9.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- Verificar `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 8.2. `scripts/utilities/system_utils.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- Verificar `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 8.3. `scripts/utilities/proxmox_update.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- Verificar `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 8.4. `scripts/utilities/uup_dump_iso_creator.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- Verificar `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
## 9. Scripts Globales (3 archivos)
|
||||
|
||||
### 9.1. `scripts/global/update-pve.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 32**: `source <(curl -s "$REPO_URL/scripts/global/common-functions.sh")`
|
||||
|
||||
**Cambiar a**:
|
||||
```bash
|
||||
source "$LOCAL_SCRIPTS/global/common-functions.sh"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9.2. `scripts/global/update-pve8.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 32**: `source <(curl -s "$REPO_URL/scripts/global/common-functions.sh")`
|
||||
|
||||
---
|
||||
|
||||
### 9.3. `scripts/global/update-pve9_2.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 32**: `source <(curl -s "$REPO_URL/scripts/global/common-functions.sh")`
|
||||
|
||||
---
|
||||
|
||||
## 10. Scripts de Hardware (2 archivos)
|
||||
|
||||
### 10.1. `scripts/configure_igpu_lxc.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 19**: `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
### 10.2. `scripts/install_coral_lxc.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 25**: `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
## 11. Scripts de Red (2 archivos)
|
||||
|
||||
### 11.1. `scripts/repair_network.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 204**: `exec bash <(curl -s "$REPO_URL/scripts/menus/main_menu.sh")`
|
||||
- **Línea 205**: `exec bash <(curl -s "$REPO_URL/scripts/menus/main_menu.sh")`
|
||||
|
||||
---
|
||||
|
||||
### 11.2. `scripts/telegram-notifier.sh`
|
||||
|
||||
**Modificaciones**:
|
||||
- **Línea 5**: `REPO_URL`
|
||||
|
||||
---
|
||||
|
||||
## 12. Scripts Duplicados/Alternos (en `scripts/auto_post_install.sh`)
|
||||
|
||||
**Modificaciones**: Igual que `scripts/post_install/auto_post_install.sh`
|
||||
|
||||
---
|
||||
|
||||
## Tabla Resumen
|
||||
|
||||
| Categoría | Archivos | Modificaciones Principales |
|
||||
|-----------|----------|---------------------------|
|
||||
| **Principales** | 2 | REPO_URL + curl → rutas locales |
|
||||
| **Menús** | 13 | REPO_URL + exec bash curl |
|
||||
| **Post-Install** | 3 | bash curl a scripts global |
|
||||
| **VMs** | 8 | REPO_URL + llamadas remotas |
|
||||
| **LXC** | 4 | REPO_URL |
|
||||
| **Share** | 9 | REPO_URL |
|
||||
| **Storage** | 3 | REPO_URL |
|
||||
| **Utilities** | 4 | REPO_URL |
|
||||
| **Global** | 3 | source curl |
|
||||
| **Hardware** | 2 | REPO_URL |
|
||||
| **Red** | 2 | exec bash curl |
|
||||
| **TOTAL** | **47** | **~150-200 líneas** |
|
||||
|
||||
---
|
||||
|
||||
## Plan de Implementación Recomendado
|
||||
|
||||
### Paso 1: Preparación
|
||||
```bash
|
||||
# Crear backup
|
||||
cp -r . ../ProxMenuxDotDeb_backup
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Documentar información relevante del proyecto en directorio "docs"
|
||||
# --------------------------------------------------------------------
|
||||
```
|
||||
|
||||
### Paso 2: Modificación Automática Global
|
||||
```bash
|
||||
# Script de conversión masiva
|
||||
find . -name "*.sh" -o -name "menu" | xargs sed -i \
|
||||
's|REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"|LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"|g'
|
||||
|
||||
# Cambiar referencias
|
||||
find . -name "*.sh" -o -name "menu" | xargs sed -i \
|
||||
's|\$REPO_URL/scripts|\$LOCAL_SCRIPTS|g'
|
||||
|
||||
# Cambiar bash curl
|
||||
find . -name "*.sh" -o -name "menu" | xargs sed -i -E \
|
||||
's|bash <\(curl -[sfSL]+ "\$REPO_URL/([^"]+)"|bash "\$LOCAL_SCRIPTS/\1"|g'
|
||||
|
||||
# Cambiar exec bash curl
|
||||
find . -name "*.sh" -o -name "menu" | xargs sed -i -E \
|
||||
's|exec bash <\(curl -[sfSL]+ "\$REPO_URL/([^"]+)"|exec bash "\$LOCAL_SCRIPTS/\1"|g'
|
||||
|
||||
# Cambiar source curl
|
||||
find . -name "*.sh" | xargs sed -i -E \
|
||||
's|source <\(curl -[sfSL]+ "\$REPO_URL/([^"]+)"|source "\$LOCAL_SCRIPTS/\1"|g'
|
||||
```
|
||||
|
||||
### Paso 3: Modificar install_proxmenux.sh manualmente
|
||||
|
||||
Cambiar secciones de descarga wget por copias locales:
|
||||
```bash
|
||||
# En lugar de:
|
||||
wget -qO "$dest" "$url"
|
||||
|
||||
# Usar:
|
||||
cp "./scripts/utils.sh" "$UTILS_FILE"
|
||||
cp "./menu" "$INSTALL_DIR/$MENU_SCRIPT"
|
||||
cp "./version.txt" "$LOCAL_VERSION_FILE"
|
||||
```
|
||||
|
||||
Agregar copia de todos los scripts:
|
||||
```bash
|
||||
msg_info "Copying local scripts..."
|
||||
mkdir -p "$BASE_DIR/scripts"
|
||||
cp -r "./scripts/"* "$BASE_DIR/scripts/"
|
||||
chmod -R +x "$BASE_DIR/scripts/"
|
||||
```
|
||||
|
||||
### Paso 4: Modificar comando menu
|
||||
|
||||
Comentar o modificar verificación de actualizaciones remotas.
|
||||
|
||||
### Paso 5: Validación
|
||||
```bash
|
||||
# Verificar que no queden referencias remotas
|
||||
grep -r "githubusercontent.com" . --include="*.sh" --include="menu"
|
||||
|
||||
# Verificar llamadas curl
|
||||
grep -r "curl.*REPO_URL" . --include="*.sh" --include="menu"
|
||||
|
||||
# Contar archivos modificados
|
||||
grep -r "LOCAL_SCRIPTS=" . --include="*.sh" --include="menu" | wc -l
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Estructura Post-Modificación
|
||||
|
||||
```
|
||||
/usr/local/share/proxmenux/
|
||||
├── utils.sh
|
||||
├── config.json
|
||||
├── cache.json
|
||||
├── version.txt
|
||||
├── ProxMenux-Monitor.AppImage
|
||||
└── scripts/ # ⭐ NUEVO
|
||||
├── menus/
|
||||
│ ├── main_menu.sh
|
||||
│ ├── menu_post_install.sh
|
||||
│ └── ...
|
||||
├── post_install/
|
||||
├── vm/
|
||||
├── lxc/
|
||||
├── storage/
|
||||
├── share/
|
||||
├── utilities/
|
||||
├── global/
|
||||
└── gpu_tpu/
|
||||
|
||||
/usr/local/bin/
|
||||
└── menu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Consideraciones Especiales
|
||||
|
||||
### Scripts Externos de la Comunidad
|
||||
Mantener URLs remotas para:
|
||||
- Proxmox VE Helper-Scripts (community-scripts)
|
||||
- xshok-proxmox scripts
|
||||
|
||||
### ProxMenux Monitor
|
||||
El AppImage se mantiene descargable desde GitHub durante la instalación inicial (10 MB).
|
||||
|
||||
### Sistema de Actualizaciones
|
||||
Opciones:
|
||||
1. Deshabilitar completamente
|
||||
2. Mostrar mensaje para ejecutar `install_proxmenux.sh` manualmente
|
||||
3. Sistema híbrido (check opcional remoto)
|
||||
|
||||
---
|
||||
|
||||
## Checklist de Validación
|
||||
|
||||
- [ok] Backup completo del repositorio
|
||||
- [ok] Conversión automática ejecutada
|
||||
- [ok] `install_proxmenux.sh` modificado
|
||||
- [ok] `menu` modificado
|
||||
- [ip] Scripts de menús verificados
|
||||
- [ ] Sin referencias a githubusercontent.com
|
||||
- [ ] Sin llamadas curl a REPO_URL
|
||||
- [ ] Instalación local funcional
|
||||
- [ ] Navegación por todos los menús OK
|
||||
- [ ] Ejecución offline confirmada
|
||||
|
||||
---
|
||||
|
||||
**Total de archivos a modificar**: 47
|
||||
**Líneas estimadas**: ~150-200
|
||||
**Tiempo estimado**: 2-4 horas
|
||||
**Riesgo**: Medio (requiere testing)
|
||||
**Beneficio**: Sistema completamente offline
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 323 KiB |
Regular → Executable
+523
-152
@@ -1,41 +1,46 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenux - A menu-driven script for Proxmox VE management
|
||||
# ProxMenux - A menu-driven toolkit for Proxmox VE management
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT (https://raw.githubusercontent.com/MacRimi/ProxMenux/main/LICENSE)
|
||||
# Version : 1.3
|
||||
# Last Updated: 04/07/2025
|
||||
# Author : MacRimi
|
||||
# Contributors : cod378
|
||||
# Subproject : ProxMenux Monitor (System Health & Web Dashboard)
|
||||
# Copyright : (c) 2024-2025 MacRimi
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.4
|
||||
# Last Updated : 12/11/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script installs and configures ProxMenux, a menu-driven
|
||||
# tool for managing Proxmox VE.
|
||||
# toolkit for managing and optimizing Proxmox VE servers.
|
||||
#
|
||||
# - Ensures the script is run with root privileges.
|
||||
# - Displays an installation confirmation prompt.
|
||||
# - Installs required dependencies:
|
||||
# - whiptail (for interactive terminal menus)
|
||||
# - curl (for downloading remote files)
|
||||
# - jq (for handling JSON data)
|
||||
# - Python 3 and virtual environment (for translations)
|
||||
# - Configures the Python virtual environment and installs googletrans.
|
||||
# - Creates necessary directories for storing ProxMenux data.
|
||||
# - Downloads required files from GitHub, including:
|
||||
# - Cache file (`cache.json`) for translation caching.
|
||||
# - Utility script (`utils.sh`) for core functions.
|
||||
# - Main script (`menu.sh`) to launch ProxMenux.
|
||||
# - Sets correct permissions for execution.
|
||||
# - Displays final instructions on how to start ProxMenux.
|
||||
# • whiptail (interactive terminal menus)
|
||||
# • curl (downloads and connectivity checks)
|
||||
# • jq (JSON parsing)
|
||||
# • Python 3 + venv (for translation support)
|
||||
# - Creates the ProxMenux base directories and configuration files:
|
||||
# • $BASE_DIR/config.json
|
||||
# • $BASE_DIR/cache.json
|
||||
# - Copies local project files into the target paths (offline mode by default):
|
||||
# • scripts/* → $BASE_DIR/scripts/
|
||||
# • utils.sh → $BASE_DIR/scripts/utils.sh
|
||||
# • menu → $INSTALL_DIR/menu (main launcher)
|
||||
# • install_proxmenux.sh → $BASE_DIR/install_proxmenux.sh
|
||||
# - Sets correct permissions for all executables.
|
||||
# - Displays the final instruction on how to start ProxMenux ("menu").
|
||||
#
|
||||
# This installer ensures a smooth setup process and prepares
|
||||
# the system for running ProxMenux efficiently.
|
||||
# Notes:
|
||||
# - This installer supports both offline and online setups.
|
||||
# - ProxMenux Monitor can be installed later as an optional module
|
||||
# to provide real-time system monitoring and a web dashboard.
|
||||
# ==========================================================
|
||||
|
||||
# Configuration ============================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
UTILS_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/scripts/utils.sh"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
CONFIG_FILE="$BASE_DIR/config.json"
|
||||
@@ -45,17 +50,222 @@ LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
|
||||
MENU_SCRIPT="menu"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
MONITOR_APPIMAGE_URL="https://github.com/MacRimi/ProxMenux/raw/refs/heads/main/AppImage/ProxMenux-1.0.0.AppImage"
|
||||
MONITOR_SHA256_URL="https://github.com/MacRimi/ProxMenux/raw/refs/heads/main/AppImage/ProxMenux-Monitor.AppImage.sha256"
|
||||
MONITOR_INSTALL_PATH="$BASE_DIR/ProxMenux-Monitor.AppImage"
|
||||
MONITOR_INSTALL_DIR="$BASE_DIR"
|
||||
MONITOR_SERVICE_FILE="/etc/systemd/system/proxmenux-monitor.service"
|
||||
MONITOR_PORT=8008
|
||||
|
||||
if ! source <(curl -sSf "$UTILS_URL"); then
|
||||
echo "Error: Could not load utils.sh from $UTILS_URL"
|
||||
exit 1
|
||||
# Offline installer envs
|
||||
REPO_URL="https://github.com/MacRimi/ProxMenux.git"
|
||||
TEMP_DIR="/tmp/proxmenux-install-$$"
|
||||
|
||||
# Load utility functions
|
||||
NEON_PURPLE_BLUE="\033[38;5;99m"
|
||||
WHITE="\033[38;5;15m"
|
||||
RESET="\033[0m"
|
||||
DARK_GRAY="\033[38;5;244m"
|
||||
ORANGE="\033[38;5;208m"
|
||||
YW="\033[33m"
|
||||
YWB="\033[1;33m"
|
||||
GN="\033[1;92m"
|
||||
RD="\033[01;31m"
|
||||
CL="\033[m"
|
||||
BL="\033[36m"
|
||||
DGN="\e[32m"
|
||||
BGN="\e[1;32m"
|
||||
DEF="\e[1;36m"
|
||||
CUS="\e[38;5;214m"
|
||||
BOLD="\033[1m"
|
||||
BFR="\\r\\033[K"
|
||||
HOLD="-"
|
||||
BOR=" | "
|
||||
CM="${GN}✓ ${CL}"
|
||||
TAB=" "
|
||||
|
||||
|
||||
# Create and display spinner
|
||||
spinner() {
|
||||
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
|
||||
local spin_i=0
|
||||
local interval=0.1
|
||||
printf "\e[?25l"
|
||||
|
||||
local color="${YW}"
|
||||
|
||||
while true; do
|
||||
printf "\r ${color}%s${CL}" "${frames[spin_i]}"
|
||||
spin_i=$(( (spin_i + 1) % ${#frames[@]} ))
|
||||
sleep "$interval"
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
# Function to simulate typing effect
|
||||
type_text() {
|
||||
local text="$1"
|
||||
local delay=0.05
|
||||
for ((i=0; i<${#text}; i++)); do
|
||||
echo -n "${text:$i:1}"
|
||||
sleep $delay
|
||||
done
|
||||
echo
|
||||
}
|
||||
|
||||
|
||||
# Display info message with spinner
|
||||
msg_info() {
|
||||
local msg="$1"
|
||||
echo -ne "${TAB}${YW}${HOLD}${msg}"
|
||||
spinner &
|
||||
SPINNER_PID=$!
|
||||
}
|
||||
|
||||
|
||||
# Display info2 message
|
||||
msg_info2() {
|
||||
local msg="$1"
|
||||
echo -e "${TAB}${BOLD}${YW}${HOLD}${msg}${CL}"
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Display title script
|
||||
msg_title() {
|
||||
local msg="$1"
|
||||
echo -e "\n"
|
||||
echo -e "${TAB}${BOLD}${HOLD}${BOR}${msg}${BOR}${HOLD}${CL}"
|
||||
echo -e "\n"
|
||||
}
|
||||
|
||||
|
||||
# Display warning or highlighted information message
|
||||
msg_warn() {
|
||||
if [ -n "$SPINNER_PID" ] && ps -p $SPINNER_PID > /dev/null; then
|
||||
kill $SPINNER_PID > /dev/null
|
||||
fi
|
||||
printf "\e[?25h"
|
||||
local msg="$1"
|
||||
echo -e "${BFR}${TAB}${CL} ${YWB}${msg}${CL}"
|
||||
}
|
||||
|
||||
|
||||
# Display success message
|
||||
msg_ok() {
|
||||
if [ -n "$SPINNER_PID" ] && ps -p $SPINNER_PID > /dev/null; then
|
||||
kill $SPINNER_PID > /dev/null
|
||||
fi
|
||||
printf "\e[?25h"
|
||||
local msg="$1"
|
||||
echo -e "${BFR}${TAB}${CM}${GN}${msg}${CL}"
|
||||
}
|
||||
|
||||
|
||||
# Display error message
|
||||
msg_error() {
|
||||
if [ -n "$SPINNER_PID" ] && ps -p $SPINNER_PID > /dev/null; then
|
||||
kill $SPINNER_PID > /dev/null
|
||||
fi
|
||||
printf "\e[?25h"
|
||||
local msg="$1"
|
||||
echo -e "${BFR}${TAB}${RD}[ERROR] ${msg}${CL}"
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
show_proxmenux_logo() {
|
||||
clear
|
||||
|
||||
if [[ -z "$SSH_TTY" && -z "$(who am i | awk '{print $NF}' | grep -E '([0-9]{1,3}\.){3}[0-9]{1,3}')" ]]; then
|
||||
|
||||
# Logo for terminal noVNC
|
||||
|
||||
LOGO=$(cat << "EOF"
|
||||
\e[0m\e[38;2;61;61;61m▆\e[38;2;60;60;60m▄\e[38;2;54;54;54m▂\e[0m \e[38;2;0;0;0m \e[0m \e[38;2;54;54;54m▂\e[38;2;60;60;60m▄\e[38;2;61;61;61m▆\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[38;2;61;61;61;48;2;37;37;37m▇\e[0m\e[38;2;60;60;60m▅\e[38;2;56;56;56m▃\e[38;2;37;37;37m▁ \e[38;2;36;36;36m▁\e[38;2;56;56;56m▃\e[38;2;60;60;60m▅\e[38;2;61;61;61;48;2;37;37;37m▇\e[48;2;62;62;62m \e[0m\e[7m\e[38;2;60;60;60m▁\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[7m\e[38;2;61;61;61m▂\e[0m\e[38;2;62;62;62;48;2;61;61;61m┈\e[48;2;62;62;62m \e[48;2;61;61;61m┈\e[0m\e[38;2;60;60;60m▆\e[38;2;57;57;57m▄\e[38;2;48;48;48m▂\e[0m \e[38;2;47;47;47m▂\e[38;2;57;57;57m▄\e[38;2;60;60;60m▆\e[38;2;62;62;62;48;2;61;61;61m┈\e[48;2;62;62;62m \e[48;2;61;61;61m┈\e[0m\e[7m\e[38;2;60;60;60m▂\e[38;2;57;57;57m▄\e[38;2;47;47;47m▆\e[0m \e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏\e[7m\e[38;2;39;39;39m▇\e[38;2;57;57;57m▅\e[38;2;60;60;60m▃\e[0m\e[38;2;40;40;40;48;2;61;61;61m▁\e[48;2;62;62;62m \e[38;2;54;54;54;48;2;61;61;61m┊\e[48;2;62;62;62m \e[38;2;39;39;39;48;2;61;61;61m▁\e[0m\e[7m\e[38;2;60;60;60m▃\e[38;2;57;57;57m▅\e[38;2;38;38;38m▇\e[0m \e[38;2;193;60;2m▃\e[38;2;217;67;2m▅\e[38;2;225;70;2m▇\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏\e[0m \e[38;2;203;63;2m▄\e[38;2;147;45;1m▂\e[0m \e[7m\e[38;2;55;55;55m▆\e[38;2;60;60;60m▄\e[38;2;61;61;61m▂\e[38;2;60;60;60m▄\e[38;2;55;55;55m▆\e[0m \e[38;2;144;44;1m▂\e[38;2;202;62;2m▄\e[38;2;219;68;2m▆\e[38;2;231;72;3;48;2;226;70;2m┈\e[48;2;231;72;3m \e[48;2;225;70;2m▉\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏\e[7m\e[38;2;121;37;1m▉\e[0m\e[38;2;0;0;0;48;2;231;72;3m \e[0m\e[38;2;221;68;2m▇\e[38;2;208;64;2m▅\e[38;2;212;66;2m▂\e[38;2;123;37;0m▁\e[38;2;211;65;2m▂\e[38;2;207;64;2m▅\e[38;2;220;68;2m▇\e[48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m┈\e[0m\e[7m\e[38;2;221;68;2m▂\e[0m\e[38;2;44;13;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m▉\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏\e[0m \e[7m\e[38;2;190;59;2m▅\e[38;2;216;67;2m▃\e[38;2;225;70;2m▁\e[0m\e[38;2;95;29;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;230;71;2m┈\e[48;2;231;72;3m \e[0m\e[7m\e[38;2;225;70;2m▁\e[38;2;216;67;2m▃\e[38;2;191;59;2m▅\e[0m \e[38;2;0;0;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m▉\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏ \e[0m \e[7m\e[38;2;172;53;1m▆\e[38;2;213;66;2m▄\e[38;2;219;68;2m▂\e[38;2;213;66;2m▄\e[38;2;174;54;2m▆\e[0m \e[38;2;0;0;0m \e[0m \e[38;2;0;0;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m▉\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏ \e[0m \e[38;2;0;0;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m▉\e[0m
|
||||
\e[7m\e[38;2;52;52;52m▆\e[38;2;59;59;59m▄\e[38;2;61;61;61m▂\e[0m\e[38;2;31;31;31m▏ \e[0m \e[7m\e[38;2;228;71;2m▂\e[38;2;221;69;2m▄\e[38;2;196;60;2m▆\e[0m
|
||||
EOF
|
||||
)
|
||||
|
||||
|
||||
TEXT=(
|
||||
""
|
||||
""
|
||||
"${BOLD}ProxMenux${RESET}"
|
||||
""
|
||||
"${BOLD}${NEON_PURPLE_BLUE}An Interactive Menu for${RESET}"
|
||||
"${BOLD}${NEON_PURPLE_BLUE}Proxmox VE management${RESET}"
|
||||
""
|
||||
""
|
||||
""
|
||||
""
|
||||
)
|
||||
|
||||
|
||||
mapfile -t logo_lines <<< "$LOGO"
|
||||
|
||||
for i in {0..9}; do
|
||||
echo -e "${TAB}${logo_lines[i]} ${WHITE}│${RESET} ${TEXT[i]}"
|
||||
done
|
||||
echo -e
|
||||
|
||||
else
|
||||
|
||||
|
||||
# Logo for terminal SSH
|
||||
TEXT=(
|
||||
""
|
||||
""
|
||||
""
|
||||
""
|
||||
"${BOLD}ProxMenux${RESET}"
|
||||
""
|
||||
"${BOLD}${NEON_PURPLE_BLUE}An Interactive Menu for${RESET}"
|
||||
"${BOLD}${NEON_PURPLE_BLUE}Proxmox VE management${RESET}"
|
||||
""
|
||||
""
|
||||
""
|
||||
""
|
||||
""
|
||||
""
|
||||
)
|
||||
|
||||
LOGO=(
|
||||
"${DARK_GRAY}░░░░ ░░░░${RESET}"
|
||||
"${DARK_GRAY}░░░░░░░ ░░░░░░ ${RESET}"
|
||||
"${DARK_GRAY}░░░░░░░░░░░ ░░░░░░░ ${RESET}"
|
||||
"${DARK_GRAY}░░░░ ░░░░░░ ░░░░░░ ${ORANGE}░░${RESET}"
|
||||
"${DARK_GRAY}░░░░ ░░░░░░░ ${ORANGE}░░▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ░░░ ${ORANGE}░▒▒▒▒▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ${ORANGE}▒▒▒░ ░▒▒▒▒▒▒▒▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ${ORANGE}░▒▒▒▒▒ ▒▒▒▒▒░░ ▒▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ${ORANGE}░░▒▒▒▒▒▒▒░░ ▒▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ${ORANGE}░░░ ▒▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ${ORANGE}▒▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ${ORANGE}▒▒▒░${RESET}"
|
||||
"${DARK_GRAY} ░░ ${ORANGE}░░ ${RESET}"
|
||||
)
|
||||
|
||||
for i in {0..12}; do
|
||||
echo -e "${TAB}${LOGO[i]} │${RESET} ${TEXT[i]}"
|
||||
done
|
||||
echo -e
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
cleanup_corrupted_files() {
|
||||
if [ -f "$CONFIG_FILE" ] && ! jq empty "$CONFIG_FILE" >/dev/null 2>&1; then
|
||||
echo "Cleaning up corrupted configuration file..."
|
||||
@@ -67,6 +277,17 @@ cleanup_corrupted_files() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Cleanup function
|
||||
cleanup() {
|
||||
if [ -d "$TEMP_DIR" ]; then
|
||||
rm -rf "$TEMP_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
# Set trap to ensure cleanup on exit
|
||||
trap cleanup EXIT
|
||||
|
||||
|
||||
# ==========================================================
|
||||
check_existing_installation() {
|
||||
local has_venv=false
|
||||
@@ -118,6 +339,27 @@ uninstall_proxmenux() {
|
||||
|
||||
echo "Uninstalling ProxMenux..."
|
||||
|
||||
if systemctl is-active --quiet proxmenux-monitor.service; then
|
||||
echo "Stopping ProxMenux Monitor service..."
|
||||
systemctl stop proxmenux-monitor.service
|
||||
fi
|
||||
|
||||
if systemctl is-enabled --quiet proxmenux-monitor.service 2>/dev/null; then
|
||||
echo "Disabling ProxMenux Monitor service..."
|
||||
systemctl disable proxmenux-monitor.service
|
||||
fi
|
||||
|
||||
if [ -f "$MONITOR_SERVICE_FILE" ]; then
|
||||
echo "Removing ProxMenux Monitor service file..."
|
||||
rm -f "$MONITOR_SERVICE_FILE"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
|
||||
if [ -d "$MONITOR_INSTALL_DIR" ]; then
|
||||
echo "Removing ProxMenux Monitor directory..."
|
||||
rm -rf "$MONITOR_INSTALL_DIR"
|
||||
fi
|
||||
|
||||
if [ -f "$VENV_PATH/bin/activate" ]; then
|
||||
echo "Removing googletrans and virtual environment..."
|
||||
source "$VENV_PATH/bin/activate"
|
||||
@@ -198,7 +440,7 @@ update_config() {
|
||||
local status="$2"
|
||||
local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
local tracked_components=("dialog" "curl" "jq" "python3" "python3-venv" "python3-pip" "virtual_environment" "pip" "googletrans" "proxmenux_monitor")
|
||||
local tracked_components=("dialog" "curl" "jq" "git" "python3" "python3-venv" "python3-pip" "virtual_environment" "pip" "googletrans" "proxmenux_monitor")
|
||||
|
||||
if [[ " ${tracked_components[@]} " =~ " ${component} " ]]; then
|
||||
mkdir -p "$(dirname "$CONFIG_FILE")"
|
||||
@@ -314,55 +556,108 @@ get_server_ip() {
|
||||
echo "$ip"
|
||||
}
|
||||
|
||||
detect_latest_appimage() {
|
||||
local appimage_dir="$TEMP_DIR/AppImage"
|
||||
|
||||
if [ ! -d "$appimage_dir" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local latest_appimage=$(find "$appimage_dir" -name "ProxMenux-*.AppImage" -type f | sort -V | tail -1)
|
||||
|
||||
if [ -z "$latest_appimage" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$latest_appimage"
|
||||
return 0
|
||||
}
|
||||
|
||||
get_appimage_version() {
|
||||
local appimage_path="$1"
|
||||
local filename=$(basename "$appimage_path")
|
||||
|
||||
local version=$(echo "$filename" | grep -oP 'ProxMenux-\K[0-9]+\.[0-9]+\.[0-9]+')
|
||||
|
||||
echo "$version"
|
||||
}
|
||||
|
||||
install_proxmenux_monitor() {
|
||||
# Check if URL is accessible
|
||||
if ! wget --spider -q "$MONITOR_APPIMAGE_URL" 2>/dev/null; then
|
||||
msg_warn "ProxMenux Monitor AppImage not available at: $MONITOR_APPIMAGE_URL"
|
||||
msg_info "The monitor will be available in future releases."
|
||||
local appimage_source=$(detect_latest_appimage)
|
||||
|
||||
if [ -z "$appimage_source" ] || [ ! -f "$appimage_source" ]; then
|
||||
msg_error "ProxMenux Monitor AppImage not found in $TEMP_DIR/AppImage/"
|
||||
msg_warn "Please ensure the AppImage directory exists with ProxMenux-*.AppImage files."
|
||||
update_config "proxmenux_monitor" "appimage_not_found"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Download AppImage silently
|
||||
if ! wget -q -O "$MONITOR_INSTALL_PATH" "$MONITOR_APPIMAGE_URL" 2>&1; then
|
||||
msg_warn "Failed to download ProxMenux Monitor from GitHub."
|
||||
msg_info "You can install it manually later when available."
|
||||
return 1
|
||||
local appimage_version=$(get_appimage_version "$appimage_source")
|
||||
|
||||
if systemctl is-active --quiet proxmenux-monitor.service; then
|
||||
systemctl stop proxmenux-monitor.service
|
||||
fi
|
||||
|
||||
# Download SHA256 checksum silently
|
||||
local sha256_file="/tmp/proxmenux-monitor.sha256"
|
||||
if ! wget -q -O "$sha256_file" "$MONITOR_SHA256_URL" 2>/dev/null; then
|
||||
msg_warn "SHA256 checksum file not available. Skipping verification."
|
||||
msg_info "AppImage downloaded but integrity cannot be verified."
|
||||
rm -f "$sha256_file"
|
||||
else
|
||||
# Verify SHA256 silently
|
||||
local expected_hash=$(cat "$sha256_file" | awk '{print $1}')
|
||||
local actual_hash=$(sha256sum "$MONITOR_INSTALL_PATH" | awk '{print $1}')
|
||||
local service_exists=false
|
||||
if [ -f "$MONITOR_SERVICE_FILE" ]; then
|
||||
service_exists=true
|
||||
fi
|
||||
|
||||
local sha256_file="$TEMP_DIR/AppImage/ProxMenux-Monitor.AppImage.sha256"
|
||||
|
||||
if [ -f "$sha256_file" ]; then
|
||||
msg_info "Verifying AppImage integrity..."
|
||||
local expected_hash=$(cat "$sha256_file" | grep -Eo '^[a-f0-9]+' | tr -d '\n')
|
||||
local actual_hash=$(sha256sum "$appimage_source" | awk '{print $1}')
|
||||
|
||||
if [ "$expected_hash" != "$actual_hash" ]; then
|
||||
msg_error "SHA256 verification failed! AppImage may be corrupted."
|
||||
msg_info "Expected: $expected_hash"
|
||||
msg_info "Got: $actual_hash"
|
||||
rm -f "$MONITOR_INSTALL_PATH" "$sha256_file"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$sha256_file"
|
||||
msg_ok "SHA256 verification passed."
|
||||
else
|
||||
msg_warn "SHA256 checksum not available. Skipping verification."
|
||||
fi
|
||||
|
||||
# Make executable
|
||||
chmod +x "$MONITOR_INSTALL_PATH"
|
||||
msg_info "Installing ProxMenux Monitor..."
|
||||
mkdir -p "$MONITOR_INSTALL_DIR"
|
||||
|
||||
# Show single success message at the end
|
||||
msg_ok "ProxMenux Monitor installed and activated successfully."
|
||||
local target_path="$MONITOR_INSTALL_DIR/ProxMenux-Monitor.AppImage"
|
||||
cp "$appimage_source" "$target_path"
|
||||
chmod +x "$target_path"
|
||||
|
||||
return 0
|
||||
msg_ok "ProxMenux Monitor v$appimage_version installed."
|
||||
|
||||
if [ "$service_exists" = false ]; then
|
||||
return 0 # New installation - service needs to be created
|
||||
else
|
||||
|
||||
systemctl start proxmenux-monitor.service
|
||||
sleep 2
|
||||
|
||||
if systemctl is-active --quiet proxmenux-monitor.service; then
|
||||
|
||||
update_config "proxmenux_monitor" "updated"
|
||||
return 2 # Update successful
|
||||
else
|
||||
msg_warn "Service failed to restart. Check: journalctl -u proxmenux-monitor"
|
||||
update_config "proxmenux_monitor" "failed"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
create_monitor_service() {
|
||||
msg_info "Creating ProxMenux Monitor service..."
|
||||
|
||||
cat > "$MONITOR_SERVICE_FILE" << EOF
|
||||
local exec_path="$MONITOR_INSTALL_DIR/ProxMenux-Monitor.AppImage"
|
||||
|
||||
if [ -f "$TEMP_DIR/systemd/proxmenux-monitor.service" ]; then
|
||||
sed "s|ExecStart=.*|ExecStart=$exec_path|g" \
|
||||
"$TEMP_DIR/systemd/proxmenux-monitor.service" > "$MONITOR_SERVICE_FILE"
|
||||
msg_ok "Using service file from repository."
|
||||
else
|
||||
cat > "$MONITOR_SERVICE_FILE" << EOF
|
||||
[Unit]
|
||||
Description=ProxMenux Monitor - Web Dashboard
|
||||
After=network.target
|
||||
@@ -370,8 +665,8 @@ After=network.target
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=$BASE_DIR
|
||||
ExecStart=$MONITOR_INSTALL_PATH
|
||||
WorkingDirectory=$MONITOR_INSTALL_DIR
|
||||
ExecStart=$exec_path
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
Environment="PORT=$MONITOR_PORT"
|
||||
@@ -379,55 +674,73 @@ Environment="PORT=$MONITOR_PORT"
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
msg_ok "Created default service file."
|
||||
fi
|
||||
|
||||
# Reload systemd, enable and start service
|
||||
systemctl daemon-reload
|
||||
systemctl enable proxmenux-monitor.service > /dev/null 2>&1
|
||||
systemctl start proxmenux-monitor.service > /dev/null 2>&1
|
||||
|
||||
# Wait a moment for service to start
|
||||
sleep 2
|
||||
sleep 3
|
||||
|
||||
# Check if service is running
|
||||
if systemctl is-active --quiet proxmenux-monitor.service; then
|
||||
msg_ok "ProxMenux Monitor service started successfully."
|
||||
update_config "proxmenux_monitor" "installed"
|
||||
return 0
|
||||
else
|
||||
msg_warn "ProxMenux Monitor service failed to start. Check logs with: journalctl -u proxmenux-monitor"
|
||||
msg_warn "ProxMenux Monitor service failed to start."
|
||||
msg_info "Check logs with: journalctl -u proxmenux-monitor -n 20"
|
||||
msg_info "Check status with: systemctl status proxmenux-monitor"
|
||||
update_config "proxmenux_monitor" "failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
####################################################
|
||||
install_normal_version() {
|
||||
local total_steps=4 # Increased from 3 to 4 for monitor installation
|
||||
local total_steps=5
|
||||
local current_step=1
|
||||
|
||||
show_progress $current_step $total_steps "Installing basic dependencies"
|
||||
show_progress $current_step $total_steps "Installing basic dependencies."
|
||||
|
||||
if ! dpkg -l | grep -qw "jq"; then
|
||||
msg_info "Installing jq..."
|
||||
if ! command -v jq > /dev/null 2>&1; then
|
||||
apt-get update > /dev/null 2>&1
|
||||
if apt-get install -y jq > /dev/null 2>&1; then
|
||||
msg_ok "jq installed successfully."
|
||||
|
||||
if apt-get install -y jq > /dev/null 2>&1 && command -v jq > /dev/null 2>&1; then
|
||||
update_config "jq" "installed"
|
||||
else
|
||||
msg_error "Failed to install jq. Please install it manually."
|
||||
update_config "jq" "failed"
|
||||
return 1
|
||||
local jq_url="https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64"
|
||||
if wget -q -O /usr/local/bin/jq "$jq_url" 2>/dev/null && chmod +x /usr/local/bin/jq; then
|
||||
if command -v jq > /dev/null 2>&1; then
|
||||
update_config "jq" "installed_from_github"
|
||||
else
|
||||
msg_error "Failed to install jq. Please install it manually."
|
||||
update_config "jq" "failed"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
msg_error "Failed to install jq from both APT and GitHub. Please install it manually."
|
||||
update_config "jq" "failed"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
msg_ok "jq is already installed."
|
||||
update_config "jq" "already_installed"
|
||||
fi
|
||||
|
||||
BASIC_DEPS=("dialog" "curl")
|
||||
|
||||
|
||||
|
||||
|
||||
BASIC_DEPS=("dialog" "curl" "git")
|
||||
|
||||
if [ -z "${APT_UPDATED:-}" ]; then
|
||||
apt-get update -y > /dev/null 2>&1 || true
|
||||
APT_UPDATED=1
|
||||
fi
|
||||
|
||||
for pkg in "${BASIC_DEPS[@]}"; do
|
||||
if ! dpkg -l | grep -qw "$pkg"; then
|
||||
msg_info "Installing $pkg..."
|
||||
if apt-get install -y "$pkg" > /dev/null 2>&1; then
|
||||
msg_ok "$pkg installed successfully."
|
||||
update_config "$pkg" "installed"
|
||||
else
|
||||
msg_error "Failed to install $pkg. Please install it manually."
|
||||
@@ -435,11 +748,58 @@ install_normal_version() {
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
msg_ok "$pkg is already installed."
|
||||
update_config "$pkg" "already_installed"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
|
||||
if ! command -v git > /dev/null 2>&1; then
|
||||
msg_info "Installing git (required to clone the ProxMenux repository)."
|
||||
|
||||
|
||||
if [ -z "${APT_UPDATED:-}" ]; then
|
||||
apt-get update -y > /dev/null 2>&1 || true
|
||||
APT_UPDATED=1
|
||||
fi
|
||||
|
||||
if ! apt-get install -y git > /dev/null 2>&1; then
|
||||
msg_error "Failed to install git. Please run 'apt-get install git' manually and rerun the installer."
|
||||
update_config "git" "failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
if ! command -v git > /dev/null 2>&1; then
|
||||
msg_error "Git is still not available after installation. Aborting to avoid a broken setup."
|
||||
update_config "git" "failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
update_config "git" "installed"
|
||||
else
|
||||
update_config "git" "already_installed"
|
||||
fi
|
||||
|
||||
msg_ok "jq, dialog, curl and git installed successfully."
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
((current_step++))
|
||||
|
||||
show_progress $current_step $total_steps "Install ProxMenux repository"
|
||||
msg_info "Cloning ProxMenux repositoryy."
|
||||
if ! git clone --depth 1 "$REPO_URL" "$TEMP_DIR" 2>/dev/null; then
|
||||
msg_error "Failed to clone repository from $REPO_URL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "Repository cloned successfully."
|
||||
|
||||
cd "$TEMP_DIR"
|
||||
|
||||
((current_step++))
|
||||
|
||||
show_progress $current_step $total_steps "Creating directories and configuration"
|
||||
@@ -454,39 +814,36 @@ install_normal_version() {
|
||||
msg_ok "Directories and configuration created."
|
||||
((current_step++))
|
||||
|
||||
show_progress $current_step $total_steps "Downloading necessary files"
|
||||
|
||||
FILES=(
|
||||
"$UTILS_FILE $REPO_URL/scripts/utils.sh"
|
||||
"$INSTALL_DIR/$MENU_SCRIPT $REPO_URL/$MENU_SCRIPT"
|
||||
"$LOCAL_VERSION_FILE $REPO_URL/version.txt"
|
||||
)
|
||||
|
||||
for file in "${FILES[@]}"; do
|
||||
IFS=" " read -r dest url <<< "$file"
|
||||
msg_info "Downloading ${dest##*/}..."
|
||||
sleep 2
|
||||
if wget -qO "$dest" "$url"; then
|
||||
msg_ok "${dest##*/} downloaded successfully."
|
||||
else
|
||||
msg_error "Failed to download ${dest##*/}. Check your Internet connection."
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
show_progress $current_step $total_steps "Copying necessary files"
|
||||
|
||||
cp "./scripts/utils.sh" "$UTILS_FILE"
|
||||
cp "./menu" "$INSTALL_DIR/$MENU_SCRIPT"
|
||||
cp "./version.txt" "$LOCAL_VERSION_FILE"
|
||||
cp "./install_proxmenux.sh" "$BASE_DIR/install_proxmenux.sh"
|
||||
|
||||
mkdir -p "$BASE_DIR/scripts"
|
||||
cp -r "./scripts/"* "$BASE_DIR/scripts/"
|
||||
chmod -R +x "$BASE_DIR/scripts/"
|
||||
chmod +x "$BASE_DIR/install_proxmenux.sh"
|
||||
msg_ok "Necessary files created."
|
||||
|
||||
chmod +x "$INSTALL_DIR/$MENU_SCRIPT"
|
||||
|
||||
((current_step++))
|
||||
show_progress $current_step $total_steps "Installing ProxMenux Monitor"
|
||||
|
||||
if install_proxmenux_monitor; then
|
||||
install_proxmenux_monitor
|
||||
local monitor_status=$?
|
||||
|
||||
if [ $monitor_status -eq 0 ]; then
|
||||
create_monitor_service
|
||||
fi
|
||||
|
||||
msg_ok "ProxMenux Normal Version installation completed successfully."
|
||||
}
|
||||
|
||||
####################################################
|
||||
install_translation_version() {
|
||||
local total_steps=5 # Increased from 4 to 5 for monitor installation
|
||||
local total_steps=5
|
||||
local current_step=1
|
||||
|
||||
show_progress $current_step $total_steps "Language selection"
|
||||
@@ -495,28 +852,35 @@ install_translation_version() {
|
||||
|
||||
show_progress $current_step $total_steps "Installing system dependencies"
|
||||
|
||||
if ! dpkg -l | grep -qw "jq"; then
|
||||
msg_info "Installing jq..."
|
||||
if ! command -v jq > /dev/null 2>&1; then
|
||||
apt-get update > /dev/null 2>&1
|
||||
if apt-get install -y jq > /dev/null 2>&1; then
|
||||
msg_ok "jq installed successfully."
|
||||
|
||||
if apt-get install -y jq > /dev/null 2>&1 && command -v jq > /dev/null 2>&1; then
|
||||
update_config "jq" "installed"
|
||||
else
|
||||
msg_error "Failed to install jq. Please install it manually."
|
||||
update_config "jq" "failed"
|
||||
return 1
|
||||
local jq_url="https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64"
|
||||
if wget -q -O /usr/local/bin/jq "$jq_url" 2>/dev/null && chmod +x /usr/local/bin/jq; then
|
||||
if command -v jq > /dev/null 2>&1; then
|
||||
update_config "jq" "installed_from_github"
|
||||
else
|
||||
msg_error "Failed to install jq. Please install it manually."
|
||||
update_config "jq" "failed"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
msg_error "Failed to install jq from both APT and GitHub. Please install it manually."
|
||||
update_config "jq" "failed"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
msg_ok "jq is already installed."
|
||||
update_config "jq" "already_installed"
|
||||
fi
|
||||
|
||||
DEPS=("dialog" "curl" "python3" "python3-venv" "python3-pip")
|
||||
DEPS=("dialog" "curl" "git" "python3" "python3-venv" "python3-pip")
|
||||
for pkg in "${DEPS[@]}"; do
|
||||
if ! dpkg -l | grep -qw "$pkg"; then
|
||||
msg_info "Installing $pkg..."
|
||||
if apt-get install -y "$pkg" > /dev/null 2>&1; then
|
||||
msg_ok "$pkg installed successfully."
|
||||
update_config "$pkg" "installed"
|
||||
else
|
||||
msg_error "Failed to install $pkg. Please install it manually."
|
||||
@@ -524,36 +888,32 @@ install_translation_version() {
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
msg_ok "$pkg is already installed."
|
||||
update_config "$pkg" "already_installed"
|
||||
fi
|
||||
done
|
||||
|
||||
msg_ok "jq, dialog, curl, git, python3, python3-venv and python3-pip installed successfully."
|
||||
|
||||
((current_step++))
|
||||
|
||||
show_progress $current_step $total_steps "Setting up translation environment"
|
||||
|
||||
if [ ! -d "$VENV_PATH" ] || [ ! -f "$VENV_PATH/bin/activate" ]; then
|
||||
msg_info "Creating the virtual environment..."
|
||||
python3 -m venv --system-site-packages "$VENV_PATH" > /dev/null 2>&1
|
||||
if [ ! -f "$VENV_PATH/bin/activate" ]; then
|
||||
msg_error "Failed to create virtual environment. Please check your Python installation."
|
||||
update_config "virtual_environment" "failed"
|
||||
return 1
|
||||
else
|
||||
msg_ok "Virtual environment created successfully."
|
||||
update_config "virtual_environment" "created"
|
||||
fi
|
||||
else
|
||||
msg_ok "Virtual environment already exists."
|
||||
update_config "virtual_environment" "already_exists"
|
||||
fi
|
||||
|
||||
source "$VENV_PATH/bin/activate"
|
||||
|
||||
msg_info "Upgrading pip..."
|
||||
if pip install --upgrade pip > /dev/null 2>&1; then
|
||||
msg_ok "Pip upgraded successfully."
|
||||
update_config "pip" "upgraded"
|
||||
else
|
||||
msg_error "Failed to upgrade pip."
|
||||
@@ -561,9 +921,7 @@ install_translation_version() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
msg_info "Installing googletrans..."
|
||||
if pip install --break-system-packages --no-cache-dir googletrans==4.0.0-rc1 > /dev/null 2>&1; then
|
||||
msg_ok "Googletrans installed successfully."
|
||||
update_config "googletrans" "installed"
|
||||
else
|
||||
msg_error "Failed to install googletrans. Please check your internet connection."
|
||||
@@ -573,46 +931,54 @@ install_translation_version() {
|
||||
fi
|
||||
|
||||
deactivate
|
||||
|
||||
show_progress $current_step $total_steps "Cloning ProxMenux repository"
|
||||
if ! git clone --depth 1 "$REPO_URL" "$TEMP_DIR" 2>/dev/null; then
|
||||
msg_error "Failed to clone repository from $REPO_URL"
|
||||
exit 1
|
||||
fi
|
||||
msg_ok "Repository cloned successfully."
|
||||
|
||||
cd "$TEMP_DIR"
|
||||
|
||||
((current_step++))
|
||||
|
||||
show_progress $current_step $total_steps "Downloading necessary files"
|
||||
show_progress $current_step $total_steps "Copying necessary files"
|
||||
|
||||
mkdir -p "$BASE_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
FILES=(
|
||||
"$CACHE_FILE $REPO_URL/json/cache.json"
|
||||
"$UTILS_FILE $REPO_URL/scripts/utils.sh"
|
||||
"$INSTALL_DIR/$MENU_SCRIPT $REPO_URL/$MENU_SCRIPT"
|
||||
"$LOCAL_VERSION_FILE $REPO_URL/version.txt"
|
||||
)
|
||||
cp "./json/cache.json" "$CACHE_FILE"
|
||||
msg_ok "Cache file copied with translations."
|
||||
|
||||
for file in "${FILES[@]}"; do
|
||||
IFS=" " read -r dest url <<< "$file"
|
||||
msg_info "Downloading ${dest##*/}..."
|
||||
sleep 2
|
||||
if wget -qO "$dest" "$url"; then
|
||||
msg_ok "${dest##*/} downloaded successfully."
|
||||
if [[ "$dest" == "$CACHE_FILE" ]]; then
|
||||
msg_ok "Cache file updated with latest translations."
|
||||
fi
|
||||
else
|
||||
msg_error "Failed to download ${dest##*/}. Check your Internet connection."
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
cp "./scripts/utils.sh" "$UTILS_FILE"
|
||||
cp "./menu" "$INSTALL_DIR/$MENU_SCRIPT"
|
||||
cp "./version.txt" "$LOCAL_VERSION_FILE"
|
||||
cp "./install_proxmenux.sh" "$BASE_DIR/install_proxmenux.sh"
|
||||
|
||||
mkdir -p "$BASE_DIR/scripts"
|
||||
cp -r "./scripts/"* "$BASE_DIR/scripts/"
|
||||
chmod -R +x "$BASE_DIR/scripts/"
|
||||
chmod +x "$BASE_DIR/install_proxmenux.sh"
|
||||
msg_ok "Necessary files created."
|
||||
|
||||
chmod +x "$INSTALL_DIR/$MENU_SCRIPT"
|
||||
|
||||
((current_step++))
|
||||
show_progress $current_step $total_steps "Installing ProxMenux Monitor"
|
||||
|
||||
if install_proxmenux_monitor; then
|
||||
install_proxmenux_monitor
|
||||
local monitor_status=$?
|
||||
|
||||
if [ $monitor_status -eq 0 ]; then
|
||||
create_monitor_service
|
||||
elif [ $monitor_status -eq 2 ]; then
|
||||
msg_ok "ProxMenux Monitor updated successfully."
|
||||
fi
|
||||
|
||||
msg_ok "ProxMenux Translation Version installation completed successfully."
|
||||
}
|
||||
|
||||
####################################################
|
||||
show_installation_options() {
|
||||
local current_install_type
|
||||
current_install_type=$(check_existing_installation)
|
||||
@@ -663,7 +1029,6 @@ show_installation_options() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# For new installations, show confirmation with details
|
||||
if [ "$current_install_type" = "none" ]; then
|
||||
if ! show_installation_confirmation "$INSTALL_TYPE"; then
|
||||
show_proxmenux_logo
|
||||
@@ -679,7 +1044,7 @@ show_installation_options() {
|
||||
fi
|
||||
}
|
||||
|
||||
install_proxmenu() {
|
||||
install_proxmenux() {
|
||||
show_installation_options
|
||||
|
||||
case "$INSTALL_TYPE" in
|
||||
@@ -698,19 +1063,25 @@ install_proxmenu() {
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
msg_title "$(translate "ProxMenux has been installed successfully")"
|
||||
msg_title "ProxMenux has been installed successfully"
|
||||
|
||||
if systemctl is-active --quiet proxmenux-monitor.service; then
|
||||
local server_ip=$(get_server_ip)
|
||||
echo -e "${GN}🌐 $(translate "ProxMenux Monitor activated")${CL}: ${BL}http://${server_ip}:${MONITOR_PORT}${CL}"
|
||||
echo -e "${GN}🌐 ProxMenux Monitor activated${CL}: ${BL}http://${server_ip}:${MONITOR_PORT}${CL}"
|
||||
echo
|
||||
fi
|
||||
|
||||
echo -ne "${GN}"
|
||||
type_text "$(translate "To run ProxMenux, simply execute this command in the console or terminal:")"
|
||||
type_text "To run ProxMenux, simply execute this command in the console or terminal:"
|
||||
echo -e "${YWB} menu${CL}"
|
||||
echo
|
||||
# -------
|
||||
exit 0
|
||||
}
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
@@ -719,4 +1090,4 @@ if [ "$(id -u)" -ne 0 ]; then
|
||||
fi
|
||||
|
||||
cleanup_corrupted_files
|
||||
install_proxmenu
|
||||
install_proxmenux
|
||||
|
||||
+13530
-1264
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
@@ -5,14 +5,13 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT (https://raw.githubusercontent.com/MacRimi/ProxMenux/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 04/07/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script serves as the main entry point for ProxMenux,
|
||||
# a menu-driven tool designed for Proxmox VE management.
|
||||
#
|
||||
# - Displays the ProxMenux logo on startup.
|
||||
# - Loads necessary configurations and language settings.
|
||||
# - Checks for available updates and installs them if confirmed.
|
||||
@@ -29,10 +28,10 @@
|
||||
# for managing Proxmox VE using ProxMenux.
|
||||
# ==========================================================
|
||||
|
||||
|
||||
# Configuration ============================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
LOCAL_SCRIPTS="$BASE_DIR/scripts"
|
||||
CONFIG_FILE="$BASE_DIR/config.json"
|
||||
CACHE_FILE="$BASE_DIR/cache.json"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
@@ -40,51 +39,65 @@ LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
: "${LOCAL_SCRIPTS:=/usr/local/share/proxmenux/scripts}"
|
||||
|
||||
# =========================================================
|
||||
|
||||
check_updates() {
|
||||
local INSTALL_SCRIPT="$BASE_DIR/install_proxmenux.sh"
|
||||
local VERSION_URL INSTALL_URL INSTALL_SCRIPT
|
||||
local REMOTE_VERSION LOCAL_VERSION
|
||||
|
||||
local REMOTE_VERSION
|
||||
REMOTE_VERSION=$(curl -fsSL "$REPO_URL/version.txt" | head -n 1)
|
||||
|
||||
VERSION_URL="$REPO_URL/version.txt"
|
||||
INSTALL_URL="$REPO_URL/install_proxmenux.sh"
|
||||
INSTALL_SCRIPT="$BASE_DIR/install_proxmenux.sh"
|
||||
|
||||
if [ -z "$REMOTE_VERSION" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
||||
local LOCAL_VERSION
|
||||
LOCAL_VERSION=$(head -n 1 "$LOCAL_VERSION_FILE")
|
||||
|
||||
[[ ! -f "$LOCAL_VERSION_FILE" ]] && return 0
|
||||
|
||||
[ "$LOCAL_VERSION" = "$REMOTE_VERSION" ] && return 0
|
||||
|
||||
REMOTE_VERSION="$(curl -fsSL "$VERSION_URL" 2>/dev/null | head -n 1)"
|
||||
[[ -z "$REMOTE_VERSION" ]] && return 0
|
||||
|
||||
if whiptail --title "$(translate "Update Available")" \
|
||||
--yesno "$(translate "New version available") ($REMOTE_VERSION)\n\n$(translate "Do you want to update now?")" \
|
||||
|
||||
LOCAL_VERSION="$(head -n 1 "$LOCAL_VERSION_FILE" 2>/dev/null)"
|
||||
[[ -z "$LOCAL_VERSION" ]] && return 0
|
||||
|
||||
|
||||
[[ "$LOCAL_VERSION" = "$REMOTE_VERSION" ]] && return 0
|
||||
|
||||
|
||||
if whiptail --title "$(translate 'Update Available')" \
|
||||
--yesno "$(translate 'New version available') ($REMOTE_VERSION)\n\n$(translate 'Do you want to update now?')" \
|
||||
10 60 --defaultno; then
|
||||
msg_warn "$(translate "Starting ProxMenux update...")"
|
||||
|
||||
if wget -qO "$INSTALL_SCRIPT" "$REPO_URL/install_proxmenux.sh"; then
|
||||
msg_warn "$(translate 'Starting ProxMenux update...')"
|
||||
|
||||
|
||||
if curl -fsSL "$INSTALL_URL" -o "$INSTALL_SCRIPT"; then
|
||||
chmod +x "$INSTALL_SCRIPT"
|
||||
|
||||
source "$INSTALL_SCRIPT"
|
||||
|
||||
bash "$INSTALL_SCRIPT" --update
|
||||
|
||||
return 0
|
||||
|
||||
fi
|
||||
else
|
||||
msg_warn "$(translate "Update postponed. You can update later from the menu.")"
|
||||
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
main_menu() {
|
||||
exec bash <(curl -fsSL "$REPO_URL/scripts/menus/main_menu.sh")
|
||||
}
|
||||
local MAIN_MENU="$LOCAL_SCRIPTS/menus/main_menu.sh"
|
||||
|
||||
exec bash "$MAIN_MENU"
|
||||
}
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT (https://raw.githubusercontent.com/MacRimi/ProxMenux/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 06/07/2025
|
||||
# ==========================================================
|
||||
@@ -31,12 +31,12 @@
|
||||
# - Translation support: Multi-language compatible through ProxMenux framework
|
||||
# - Rollback compatibility: All optimizations can be reversed using the uninstall script
|
||||
#
|
||||
# This script is based on the post-install script cutotomizable
|
||||
# This script is based on the post-install script customizable
|
||||
# ==========================================================
|
||||
|
||||
|
||||
# Configuration
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
@@ -99,7 +99,7 @@ lvm_repair_check() {
|
||||
done
|
||||
|
||||
msg_ok "$(translate "LVM PV headers check completed")"
|
||||
|
||||
register_tool "lvm_repair" true
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
@@ -257,7 +257,7 @@ apt_upgrade() {
|
||||
if [ "$total_packages" -eq 0 ]; then
|
||||
total_packages=1
|
||||
fi
|
||||
msg_ok "$(translate "Packages upgrade successfull")"
|
||||
msg_ok "$(translate "Packages upgrade successful")"
|
||||
tput civis
|
||||
tput sc
|
||||
|
||||
@@ -748,8 +748,9 @@ install_log2ram_auto() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Detect RAM
|
||||
RAM_SIZE_GB=$(free -g | awk '/^Mem:/{print $2}')
|
||||
# Detect RAM (in MB first for better accuracy)
|
||||
RAM_SIZE_MB=$(free -m | awk '/^Mem:/{print $2}')
|
||||
RAM_SIZE_GB=$((RAM_SIZE_MB / 1024))
|
||||
[[ -z "$RAM_SIZE_GB" || "$RAM_SIZE_GB" -eq 0 ]] && RAM_SIZE_GB=4
|
||||
|
||||
if (( RAM_SIZE_GB <= 8 )); then
|
||||
@@ -773,7 +774,13 @@ install_log2ram_auto() {
|
||||
cat << 'EOF' > /usr/local/bin/log2ram-check.sh
|
||||
#!/bin/bash
|
||||
CONF_FILE="/etc/log2ram.conf"
|
||||
LIMIT_KB=$(grep '^SIZE=' "$CONF_FILE" | cut -d'=' -f2 | tr -d 'M')000
|
||||
SIZE_VALUE=$(grep '^SIZE=' "$CONF_FILE" | cut -d'=' -f2)
|
||||
# Convert to KB: handle M (megabytes) and G (gigabytes)
|
||||
if [[ "$SIZE_VALUE" == *"G" ]]; then
|
||||
LIMIT_KB=$(($(echo "$SIZE_VALUE" | tr -d 'G') * 1024 * 1024))
|
||||
else
|
||||
LIMIT_KB=$(($(echo "$SIZE_VALUE" | tr -d 'M') * 1024))
|
||||
fi
|
||||
USED_KB=$(df /var/log --output=used | tail -1)
|
||||
THRESHOLD=$(( LIMIT_KB * 90 / 100 ))
|
||||
if (( USED_KB > THRESHOLD )); then
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Configuration ============================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
@@ -16,7 +16,7 @@ initialize_cache
|
||||
|
||||
get_external_backup_mount_point() {
|
||||
local BACKUP_MOUNT_FILE="/usr/local/share/proxmenux/last_backup_mount.txt"
|
||||
local STORAGE_REPO="$REPO_URL/scripts/backup_restore"
|
||||
local STORAGE_REPO="$LOCAL_SCRIPTS/backup_restore"
|
||||
local MOUNT_POINT
|
||||
|
||||
if [[ -f "$BACKUP_MOUNT_FILE" ]]; then
|
||||
@@ -36,7 +36,7 @@ get_external_backup_mount_point() {
|
||||
echo "$MOUNT_POINT"
|
||||
return 0
|
||||
else
|
||||
source <(curl -s "$STORAGE_REPO/mount_disk_host_bk.sh")
|
||||
source "$STORAGE_REPO/mount_disk_host_bk.sh"
|
||||
MOUNT_POINT=$(mount_disk_host_bk)
|
||||
[[ -z "$MOUNT_POINT" ]] && msg_error "$(translate "No disk mounted.")" && return 1
|
||||
echo "$MOUNT_POINT"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Configuration ============================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
@@ -16,7 +16,7 @@ initialize_cache
|
||||
|
||||
get_external_backup_mount_point() {
|
||||
local BACKUP_MOUNT_FILE="/usr/local/share/proxmenux/last_backup_mount.txt"
|
||||
local STORAGE_REPO="$REPO_URL/scripts/backup_restore"
|
||||
local STORAGE_REPO="$LOCAL_SCRIPTS/backup_restore"
|
||||
local MOUNT_POINT
|
||||
|
||||
if [[ -f "$BACKUP_MOUNT_FILE" ]]; then
|
||||
@@ -36,7 +36,7 @@ get_external_backup_mount_point() {
|
||||
echo "$MOUNT_POINT"
|
||||
return 0
|
||||
else
|
||||
source <(curl -s "$STORAGE_REPO/mount_disk_host_bk.sh")
|
||||
source "$STORAGE_REPO/mount_disk_host_bk.sh"
|
||||
MOUNT_POINT=$(mount_disk_host_bk)
|
||||
[[ -z "$MOUNT_POINT" ]] && msg_error "$(translate "No disk mounted.")" && return 1
|
||||
echo "$MOUNT_POINT"
|
||||
@@ -1058,4 +1058,4 @@ read -r
|
||||
# ===============================
|
||||
|
||||
|
||||
host_backup_menu
|
||||
host_backup_menu
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Configuration ============================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
@@ -16,7 +16,7 @@ initialize_cache
|
||||
|
||||
get_external_backup_mount_point() {
|
||||
local BACKUP_MOUNT_FILE="/usr/local/share/proxmenux/last_backup_mount.txt"
|
||||
local STORAGE_REPO="$REPO_URL/scripts/backup_restore"
|
||||
local STORAGE_REPO="$LOCAL_SCRIPTS/backup_restore"
|
||||
local MOUNT_POINT
|
||||
|
||||
if [[ -f "$BACKUP_MOUNT_FILE" ]]; then
|
||||
@@ -36,7 +36,7 @@ get_external_backup_mount_point() {
|
||||
echo "$MOUNT_POINT"
|
||||
return 0
|
||||
else
|
||||
source <(curl -s "$STORAGE_REPO/mount_disk_host_bk.sh")
|
||||
source "$STORAGE_REPO/mount_disk_host_bk.sh"
|
||||
MOUNT_POINT=$(mount_disk_host_bk)
|
||||
[[ -z "$MOUNT_POINT" ]] && msg_error "$(translate "No disk mounted.")" && return 1
|
||||
echo "$MOUNT_POINT"
|
||||
@@ -1291,4 +1291,4 @@ read -r
|
||||
# ===============================
|
||||
|
||||
|
||||
host_backup_menu
|
||||
host_backup_menu
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Configuration ============================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
@@ -16,7 +16,7 @@ initialize_cache
|
||||
|
||||
get_external_backup_mount_point() {
|
||||
local BACKUP_MOUNT_FILE="/usr/local/share/proxmenux/last_backup_mount.txt"
|
||||
local STORAGE_REPO="$REPO_URL/scripts/backup_restore"
|
||||
local STORAGE_REPO="$LOCAL_SCRIPTS/backup_restore"
|
||||
local MOUNT_POINT
|
||||
|
||||
if [[ -f "$BACKUP_MOUNT_FILE" ]]; then
|
||||
@@ -36,7 +36,7 @@ get_external_backup_mount_point() {
|
||||
echo "$MOUNT_POINT"
|
||||
return 0
|
||||
else
|
||||
source <(curl -s "$STORAGE_REPO/mount_disk_host_bk.sh")
|
||||
source "$STORAGE_REPO/mount_disk_host_bk.sh"
|
||||
MOUNT_POINT=$(mount_disk_host_bk)
|
||||
[[ -z "$MOUNT_POINT" ]] && msg_error "$(translate "No disk mounted.")" && return 1
|
||||
echo "$MOUNT_POINT"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
# Last Updated: 13/12/2024
|
||||
# ==========================================================
|
||||
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT (https://raw.githubusercontent.com/MacRimi/ProxMenux/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 17/08/2025
|
||||
# ==========================================================
|
||||
@@ -16,7 +16,7 @@
|
||||
# ==========================================================
|
||||
|
||||
# Configuration ============================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT (https://raw.githubusercontent.com/MacRimi/ProxMenux/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# ==========================================================
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
|
||||
# Configuration ============================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT (https://raw.githubusercontent.com/MacRimi/ProxMenux/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# ==========================================================
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
|
||||
# Configuration ============================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT (https://raw.githubusercontent.com/MacRimi/ProxMenux/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 2.0
|
||||
# Last Updated: 07/01/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ==========================================================
|
||||
|
||||
# Configuration
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
@@ -100,8 +100,16 @@ verify_js_integrity() {
|
||||
patch_checked_command() {
|
||||
[ -f "$JS_FILE" ] || return 0
|
||||
|
||||
# Check if already patched
|
||||
grep -q "$MARK" "$JS_FILE" && return 0
|
||||
# Check if already patched - look for our marker
|
||||
if grep -q "$MARK" "$JS_FILE"; then
|
||||
# Verify the patch is actually applied by checking if function is simplified
|
||||
if grep -A 2 "checked_command: function" "$JS_FILE" | grep -q "orig_cmd();"; then
|
||||
return 0
|
||||
else
|
||||
# Marker exists but patch not applied - remove marker and try again
|
||||
sed -i "/$MARK/d" "$JS_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create backup
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
@@ -111,27 +119,105 @@ patch_checked_command() {
|
||||
# Set trap to restore on error
|
||||
trap "cp -a '$backup' '$JS_FILE' 2>/dev/null || true" ERR
|
||||
|
||||
# Use Python to replace the entire checked_command function using brace counting
|
||||
python3 <<'PYTHON_END'
|
||||
import sys
|
||||
|
||||
js_file = "/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
|
||||
|
||||
try:
|
||||
with open(js_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Find the line with checked_command
|
||||
start_line = -1
|
||||
for i, line in enumerate(lines):
|
||||
if 'checked_command: function' in line or 'checked_command:function' in line:
|
||||
start_line = i
|
||||
break
|
||||
|
||||
if start_line == -1:
|
||||
print("checked_command function not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Count braces to find the end of the function
|
||||
brace_count = 0
|
||||
end_line = -1
|
||||
started_counting = False
|
||||
|
||||
for i in range(start_line, len(lines)):
|
||||
line = lines[i]
|
||||
|
||||
# Count opening and closing braces
|
||||
for char in line:
|
||||
if char == '{':
|
||||
brace_count += 1
|
||||
started_counting = True
|
||||
elif char == '}':
|
||||
brace_count -= 1
|
||||
|
||||
# When we reach 0 and we've started counting, we found the end
|
||||
if started_counting and brace_count == 0:
|
||||
# Check if this line ends with "}," which is the function closure
|
||||
if '},' in line or '},\n' in line:
|
||||
end_line = i
|
||||
break
|
||||
|
||||
if end_line == -1:
|
||||
print("Could not find end of checked_command function", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Get the indentation of the original function
|
||||
indent = len(lines[start_line]) - len(lines[start_line].lstrip())
|
||||
indent_str = ' ' * indent
|
||||
|
||||
# Create the replacement function (simple version that just calls orig_cmd)
|
||||
replacement = [
|
||||
f"{indent_str}checked_command: function (orig_cmd) {{\n",
|
||||
f"{indent_str} orig_cmd();\n",
|
||||
f"{indent_str}}},\n"
|
||||
]
|
||||
|
||||
# Replace the function
|
||||
new_lines = lines[:start_line] + replacement + lines[end_line+1:]
|
||||
|
||||
# Write the modified content
|
||||
with open(js_file, 'w', encoding='utf-8') as f:
|
||||
f.writelines(new_lines)
|
||||
|
||||
#print(f"Successfully replaced lines {start_line+1} to {end_line+1}")
|
||||
sys.exit(0)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Python patch error: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
sys.exit(1)
|
||||
PYTHON_END
|
||||
|
||||
local python_result=$?
|
||||
|
||||
if [ $python_result -ne 0 ]; then
|
||||
# Python failed, restore backup
|
||||
cp -a "$backup" "$JS_FILE"
|
||||
trap - ERR
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Verify the patch was applied
|
||||
if ! grep -A 2 "checked_command: function" "$JS_FILE" | grep -q "orig_cmd();"; then
|
||||
cp -a "$backup" "$JS_FILE"
|
||||
trap - ERR
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Add patch marker at the beginning
|
||||
sed -i "1s|^|$MARK\n|" "$JS_FILE"
|
||||
|
||||
# Surgical patch: Change the condition in checked_command function
|
||||
# This changes the if condition to 'if (false)' making the banner never show
|
||||
if grep -q "res\.data\.status\.toLowerCase() !== 'active'" "$JS_FILE"; then
|
||||
# Pattern for newer versions (8.4.5+)
|
||||
sed -i "/checked_command: function/,/},$/s/res === null || res === undefined || !res || res\.data\.status\.toLowerCase() !== 'active'/false/g" "$JS_FILE"
|
||||
elif grep -q "res\.data\.status !== 'Active'" "$JS_FILE"; then
|
||||
# Pattern for older versions
|
||||
sed -i "/checked_command: function/,/},$/s/res === null || res === undefined || !res || res\.data\.status !== 'Active'/false/g" "$JS_FILE"
|
||||
fi
|
||||
|
||||
# Also handle the NoMoreNagging pattern if present
|
||||
if grep -q "res\.data\.status\.toLowerCase() !== 'NoMoreNagging'" "$JS_FILE"; then
|
||||
sed -i "/checked_command: function/,/},$/s/res === null || res === undefined || !res || res\.data\.status\.toLowerCase() !== 'NoMoreNagging'/false/g" "$JS_FILE"
|
||||
fi
|
||||
|
||||
# Verify integrity after patch
|
||||
if ! verify_js_integrity "$JS_FILE"; then
|
||||
cp -a "$backup" "$JS_FILE"
|
||||
trap - ERR
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -217,6 +303,7 @@ EOFAPT
|
||||
|
||||
# Verify APT hook syntax
|
||||
apt-config dump >/dev/null 2>&1 || {
|
||||
msg_warn "APT hook syntax issue, removing..."
|
||||
rm -f "$APT_HOOK"
|
||||
}
|
||||
}
|
||||
@@ -226,7 +313,7 @@ remove_subscription_banner_v3() {
|
||||
local pve_version
|
||||
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1 || echo "unknown")
|
||||
|
||||
msg_info "$(translate "Detected Proxmox VE") ${pve_version} - $(translate "applying banner patch")"
|
||||
msg_info "$(translate "Detected Proxmox VE") ${pve_version} - $(translate "applying minimal banner patch")"
|
||||
|
||||
|
||||
|
||||
@@ -239,16 +326,14 @@ remove_subscription_banner_v3() {
|
||||
local backup_file
|
||||
backup_file=$(create_backup "$JS_FILE")
|
||||
if [ -n "$backup_file" ]; then
|
||||
# msg_ok "$(translate "Desktop UI backup created"): $backup_file"
|
||||
:
|
||||
msg_ok "$(translate "Desktop UI backup created")"
|
||||
fi
|
||||
|
||||
if [ -f "$MOBILE_UI_FILE" ]; then
|
||||
local mobile_backup
|
||||
mobile_backup=$(create_backup "$MOBILE_UI_FILE")
|
||||
if [ -n "$mobile_backup" ]; then
|
||||
# msg_ok "$(translate "Mobile UI backup created"): $mobile_backup"
|
||||
:
|
||||
msg_ok "$(translate "Mobile UI backup created")"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# Remove Subscription Banner - Proxmox VE (v3 - Minimal Intrusive)
|
||||
# ==========================================================
|
||||
# This version makes a surgical change to the checked_command function
|
||||
# by changing the condition to 'if (false)' and commenting out the banner logic.
|
||||
# Also patches the mobile UI to remove the subscription dialog.
|
||||
# ==========================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Source utilities if available
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
# File paths
|
||||
JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
|
||||
GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
|
||||
MIN_JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
|
||||
MOBILE_UI_FILE="/usr/share/pve-yew-mobile-gui/index.html.tpl"
|
||||
BACKUP_DIR="$BASE_DIR/backups"
|
||||
APT_HOOK="/etc/apt/apt.conf.d/no-nag-script"
|
||||
PATCH_BIN="/usr/local/bin/pve-remove-nag-v3.sh"
|
||||
MARK="/* PROXMENUX_NAG_PATCH_V3 */"
|
||||
MOBILE_MARK="<!-- PROXMENUX_MOBILE_NAG_PATCH -->"
|
||||
|
||||
# Ensure tools JSON exists
|
||||
ensure_tools_json() {
|
||||
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
# Register tool in JSON
|
||||
register_tool() {
|
||||
command -v jq >/dev/null 2>&1 || return 0
|
||||
local tool="$1" state="$2"
|
||||
ensure_tools_json
|
||||
jq --arg t "$tool" --argjson v "$state" '.[$t]=$v' "$TOOLS_JSON" \
|
||||
> "$TOOLS_JSON.tmp" && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
# Verify JS file integrity
|
||||
verify_js_integrity() {
|
||||
local file="$1"
|
||||
[ -f "$file" ] || return 1
|
||||
[ -s "$file" ] || return 1
|
||||
grep -Eq 'Ext|function|var|const|let' "$file" || return 1
|
||||
if LC_ALL=C grep -qP '\x00' "$file" 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Create timestamped backup
|
||||
create_backup() {
|
||||
local file="$1"
|
||||
local timestamp
|
||||
timestamp=$(date +%Y%m%d_%H%M%S)
|
||||
local backup_file="$BACKUP_DIR/$(basename "$file").backup.$timestamp"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
if [ -f "$file" ]; then
|
||||
rm -f "$BACKUP_DIR"/"$(basename "$file")".backup.* 2>/dev/null || true
|
||||
|
||||
cp -a "$file" "$backup_file"
|
||||
echo "$backup_file"
|
||||
fi
|
||||
}
|
||||
|
||||
# Create the patch script that will be called by APT hook
|
||||
create_patch_script() {
|
||||
cat > "$PATCH_BIN" <<'EOFPATCH'
|
||||
#!/usr/bin/env bash
|
||||
# ==========================================================
|
||||
# Proxmox Subscription Banner Patch (v3 - Minimal)
|
||||
# ==========================================================
|
||||
set -euo pipefail
|
||||
|
||||
JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
|
||||
GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
|
||||
MIN_JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
|
||||
MOBILE_UI_FILE="/usr/share/pve-yew-mobile-gui/index.html.tpl"
|
||||
BACKUP_DIR="/usr/local/share/proxmenux/backups"
|
||||
MARK="/* PROXMENUX_NAG_PATCH_V3 */"
|
||||
MOBILE_MARK="<!-- PROXMENUX_MOBILE_NAG_PATCH -->"
|
||||
|
||||
verify_js_integrity() {
|
||||
local file="$1"
|
||||
[ -f "$file" ] && [ -s "$file" ] && grep -Eq 'Ext|function' "$file" && ! LC_ALL=C grep -qP '\x00' "$file" 2>/dev/null
|
||||
}
|
||||
|
||||
patch_checked_command() {
|
||||
[ -f "$JS_FILE" ] || return 0
|
||||
|
||||
# Check if already patched
|
||||
grep -q "$MARK" "$JS_FILE" && return 0
|
||||
|
||||
# Create backup
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
local backup="$BACKUP_DIR/$(basename "$JS_FILE").backup.$(date +%Y%m%d_%H%M%S)"
|
||||
cp -a "$JS_FILE" "$backup"
|
||||
|
||||
# Set trap to restore on error
|
||||
trap "cp -a '$backup' '$JS_FILE' 2>/dev/null || true" ERR
|
||||
|
||||
# Add patch marker at the beginning
|
||||
sed -i "1s|^|$MARK\n|" "$JS_FILE"
|
||||
|
||||
# Surgical patch: Change the condition in checked_command function
|
||||
# This changes the if condition to 'if (false)' making the banner never show
|
||||
if grep -q "res\.data\.status\.toLowerCase() !== 'active'" "$JS_FILE"; then
|
||||
# Pattern for newer versions (8.4.5+)
|
||||
sed -i "/checked_command: function/,/},$/s/res === null || res === undefined || !res || res\.data\.status\.toLowerCase() !== 'active'/false/g" "$JS_FILE"
|
||||
elif grep -q "res\.data\.status !== 'Active'" "$JS_FILE"; then
|
||||
# Pattern for older versions
|
||||
sed -i "/checked_command: function/,/},$/s/res === null || res === undefined || !res || res\.data\.status !== 'Active'/false/g" "$JS_FILE"
|
||||
fi
|
||||
|
||||
# Also handle the NoMoreNagging pattern if present
|
||||
if grep -q "res\.data\.status\.toLowerCase() !== 'NoMoreNagging'" "$JS_FILE"; then
|
||||
sed -i "/checked_command: function/,/},$/s/res === null || res === undefined || !res || res\.data\.status\.toLowerCase() !== 'NoMoreNagging'/false/g" "$JS_FILE"
|
||||
fi
|
||||
|
||||
# Verify integrity after patch
|
||||
if ! verify_js_integrity "$JS_FILE"; then
|
||||
cp -a "$backup" "$JS_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Clean up generated files
|
||||
rm -f "$MIN_JS_FILE" "$GZ_FILE" 2>/dev/null || true
|
||||
find /var/cache/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
find /var/lib/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
find /var/cache/nginx/ -type f -delete 2>/dev/null || true
|
||||
|
||||
trap - ERR
|
||||
return 0
|
||||
}
|
||||
|
||||
patch_mobile_ui() {
|
||||
[ -f "$MOBILE_UI_FILE" ] || return 0
|
||||
|
||||
# Check if already patched
|
||||
grep -q "$MOBILE_MARK" "$MOBILE_UI_FILE" && return 0
|
||||
|
||||
# Create backup
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
local backup="$BACKUP_DIR/$(basename "$MOBILE_UI_FILE").backup.$(date +%Y%m%d_%H%M%S)"
|
||||
cp -a "$MOBILE_UI_FILE" "$backup"
|
||||
|
||||
# Set trap to restore on error
|
||||
trap "cp -a '$backup' '$MOBILE_UI_FILE' 2>/dev/null || true" ERR
|
||||
|
||||
# Insert the script before </head> tag
|
||||
sed -i "/<\/head>/i\\
|
||||
$MOBILE_MARK\\
|
||||
<!-- Script to remove subscription banner from mobile UI -->\\
|
||||
<script>\\
|
||||
function removeNoSubDialog() {\\
|
||||
const observer = new MutationObserver(() => {\\
|
||||
const diag = document.querySelector('dialog[aria-label=\"No valid subscription\"]');\\
|
||||
if (diag) {\\
|
||||
diag.remove();\\
|
||||
}\\
|
||||
});\\
|
||||
observer.observe(document.body, { childList: true, subtree: true });\\
|
||||
}\\
|
||||
window.addEventListener('load', () => {\\
|
||||
setTimeout(removeNoSubDialog, 200);\\
|
||||
});\\
|
||||
</script>" "$MOBILE_UI_FILE"
|
||||
|
||||
trap - ERR
|
||||
return 0
|
||||
}
|
||||
|
||||
reload_services() {
|
||||
systemctl is-active --quiet pveproxy 2>/dev/null && {
|
||||
systemctl reload pveproxy 2>/dev/null || systemctl restart pveproxy 2>/dev/null || true
|
||||
}
|
||||
systemctl is-active --quiet nginx 2>/dev/null && {
|
||||
systemctl reload nginx 2>/dev/null || true
|
||||
}
|
||||
systemctl is-active --quiet pvedaemon 2>/dev/null && {
|
||||
systemctl reload pvedaemon 2>/dev/null || true
|
||||
}
|
||||
}
|
||||
|
||||
main() {
|
||||
patch_checked_command || return 1
|
||||
patch_mobile_ui || true
|
||||
reload_services
|
||||
}
|
||||
|
||||
main
|
||||
EOFPATCH
|
||||
|
||||
chmod 755 "$PATCH_BIN"
|
||||
}
|
||||
|
||||
# Create APT hook to reapply patch after updates
|
||||
create_apt_hook() {
|
||||
cat > "$APT_HOOK" <<'EOFAPT'
|
||||
/* ProxMenux: reapply minimal nag patch after upgrades */
|
||||
DPkg::Post-Invoke { "/usr/local/bin/pve-remove-nag-v3.sh || true"; };
|
||||
EOFAPT
|
||||
|
||||
chmod 644 "$APT_HOOK"
|
||||
|
||||
# Verify APT hook syntax
|
||||
apt-config dump >/dev/null 2>&1 || {
|
||||
rm -f "$APT_HOOK"
|
||||
}
|
||||
}
|
||||
|
||||
# Main function to remove subscription banner
|
||||
remove_subscription_banner_v3() {
|
||||
local pve_version
|
||||
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1 || echo "unknown")
|
||||
|
||||
msg_info "$(translate "Detected Proxmox VE") ${pve_version} - $(translate "applying banner patch")"
|
||||
|
||||
|
||||
|
||||
# Remove old APT hooks
|
||||
for f in /etc/apt/apt.conf.d/*nag*; do
|
||||
[[ -e "$f" ]] && rm -f "$f"
|
||||
done
|
||||
|
||||
# Create backup for desktop UI
|
||||
local backup_file
|
||||
backup_file=$(create_backup "$JS_FILE")
|
||||
if [ -n "$backup_file" ]; then
|
||||
# msg_ok "$(translate "Desktop UI backup created"): $backup_file"
|
||||
:
|
||||
fi
|
||||
|
||||
if [ -f "$MOBILE_UI_FILE" ]; then
|
||||
local mobile_backup
|
||||
mobile_backup=$(create_backup "$MOBILE_UI_FILE")
|
||||
if [ -n "$mobile_backup" ]; then
|
||||
# msg_ok "$(translate "Mobile UI backup created"): $mobile_backup"
|
||||
:
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create patch script and APT hook
|
||||
create_patch_script
|
||||
create_apt_hook
|
||||
|
||||
# Apply the patch
|
||||
if ! "$PATCH_BIN"; then
|
||||
msg_error "$(translate "Error applying patch. Backups preserved at"): $BACKUP_DIR"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Register tool as applied
|
||||
register_tool "subscription_banner" true
|
||||
|
||||
msg_ok "$(translate "Subscription banner removed successfully")"
|
||||
msg_ok "$(translate "Desktop and Mobile UI patched")"
|
||||
msg_ok "$(translate "Refresh your browser (Ctrl+Shift+R) to see changes")"
|
||||
|
||||
}
|
||||
|
||||
# Run if executed directly
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
remove_subscription_banner_v3
|
||||
fi
|
||||
@@ -2,7 +2,7 @@
|
||||
# ==========================================================
|
||||
# Remove Subscription Banner - Proxmox VE 8.4.9
|
||||
# ==========================================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# ==========================================================
|
||||
# Remove Subscription Banner - Proxmox VE 9.x
|
||||
# ==========================================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# ==========================================================
|
||||
# Remove Subscription Banner - Proxmox VE 9.x ONLY
|
||||
# ==========================================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ==========================================================
|
||||
|
||||
# Configuration
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
@@ -29,7 +29,7 @@ register_tool() {
|
||||
}
|
||||
|
||||
download_common_functions() {
|
||||
if ! source <(curl -s "$REPO_URL/scripts/global/common-functions.sh"); then
|
||||
if ! source "$LOCAL_SCRIPTS/global/common-functions.sh"; then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ==========================================================
|
||||
|
||||
# Configuration
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
@@ -29,7 +29,7 @@ register_tool() {
|
||||
}
|
||||
|
||||
download_common_functions() {
|
||||
if ! source <(curl -s "$REPO_URL/scripts/global/common-functions.sh"); then
|
||||
if ! source "$LOCAL_SCRIPTS/global/common-functions.sh"; then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# Proxmox VE Update Script - Improved Version
|
||||
# Proxmox VE Update Script - Improved Version (with apt progress)
|
||||
# ==========================================================
|
||||
|
||||
# Configuration
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
||||
APT_ENV="env DEBIAN_FRONTEND=noninteractive LC_ALL=C LANG=C"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
@@ -29,17 +30,20 @@ register_tool() {
|
||||
}
|
||||
|
||||
download_common_functions() {
|
||||
if ! source <(curl -s "$REPO_URL/scripts/global/common-functions.sh"); then
|
||||
if ! source "$LOCAL_SCRIPTS/global/common-functions.sh"; then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
update_pve9() {
|
||||
local pve_version=$(pveversion | awk -F'/' '{print $2}' | cut -d'-' -f1)
|
||||
local start_time=$(date +%s)
|
||||
local pve_version
|
||||
pve_version=$(pveversion | awk -F'/' '{print $2}' | cut -d'-' -f1)
|
||||
local start_time
|
||||
start_time=$(date +%s)
|
||||
local log_file="/var/log/proxmox-update-$(date +%Y%m%d-%H%M%S).log"
|
||||
local changes_made=false
|
||||
local OS_CODENAME="$(grep "VERSION_CODENAME=" /etc/os-release | cut -d"=" -f 2 | xargs)"
|
||||
local OS_CODENAME
|
||||
OS_CODENAME="$(grep "VERSION_CODENAME=" /etc/os-release | cut -d"=" -f 2 | xargs)"
|
||||
local TARGET_CODENAME="trixie"
|
||||
|
||||
local screen_capture="/tmp/proxmenux_screen_capture_$$.txt"
|
||||
@@ -55,7 +59,8 @@ update_pve9() {
|
||||
} | tee -a "$screen_capture"
|
||||
|
||||
|
||||
local available_space=$(df /var/cache/apt/archives | awk 'NR==2 {print int($4/1024)}')
|
||||
local available_space
|
||||
available_space=$(df /var/cache/apt/archives | awk 'NR==2 {print int($4/1024)}')
|
||||
if [ "$available_space" -lt 1024 ]; then
|
||||
msg_error "$(translate "Insufficient disk space. Available: ${available_space}MB")"
|
||||
echo -e
|
||||
@@ -152,23 +157,47 @@ EOF
|
||||
msg_ok "$(translate "Non-free firmware warnings disabled")"
|
||||
fi
|
||||
|
||||
#update_output=$(apt-get update 2>&1)
|
||||
update_output=$(apt-get -o Dpkg::Progress-Fancy=1 update 2>&1)
|
||||
# UPDATE: no progress bar here (dpkg is not involved); capture output to parse errors
|
||||
update_output=$(apt-get update 2>&1)
|
||||
update_exit_code=$?
|
||||
|
||||
if [ $update_exit_code -eq 0 ]; then
|
||||
msg_ok "$(translate "Package lists updated successfully")" | tee -a "$screen_capture"
|
||||
else
|
||||
if echo "$update_output" | grep -q "NO_PUBKEY\|GPG error"; then
|
||||
msg_info "$(translate "Fixing GPG key issues...")"
|
||||
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys $(echo "$update_output" | grep "NO_PUBKEY" | sed 's/.*NO_PUBKEY //' | head -1) 2>/dev/null
|
||||
# Handle common apt errors
|
||||
if echo "$update_output" | grep -Eq "NO_PUBKEY|GPG error"; then
|
||||
|
||||
# Extract first missing key (NO_PUBKEY ABCDEF... pattern)
|
||||
key=$(echo "$update_output" | sed -n 's/.*NO_PUBKEY \([0-9A-F]\{8,40\}\).*/\1/p' | head -1)
|
||||
|
||||
if [ -n "$key" ]; then
|
||||
mkdir -p /etc/apt/keyrings
|
||||
|
||||
if command -v gpg >/dev/null 2>&1; then
|
||||
# Modern approach: receive -> export -> dearmor into /etc/apt/keyrings/<KEY>.gpg
|
||||
if gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key" \
|
||||
&& gpg --batch --export "$key" | gpg --dearmor -o "/etc/apt/keyrings/${key}.gpg"; then
|
||||
msg_ok "$(translate "Imported missing GPG key: $key")"
|
||||
else
|
||||
msg_warn "$(translate "Keyrings method failed; trying apt-key fallback")"
|
||||
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys "$key" >/dev/null 2>&1 || true
|
||||
fi
|
||||
else
|
||||
# Fallback for minimal systems without gpg installed
|
||||
msg_warn "$(translate "gpg not found; trying apt-key fallback")"
|
||||
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys "$key" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Retry update after importing the key
|
||||
if apt-get update > "$log_file" 2>&1; then
|
||||
msg_ok "$(translate "Package lists updated after GPG fix")" | tee -a "$screen_capture"
|
||||
else
|
||||
msg_error "$(translate "Failed to update package lists. Check log: $log_file")"
|
||||
return 1
|
||||
fi
|
||||
elif echo "$update_output" | grep -q "404\|Failed to fetch"; then
|
||||
|
||||
elif echo "$update_output" | grep -Eq "404|Failed to fetch"; then
|
||||
msg_warn "$(translate "Some repositories are not available, continuing with available ones...")"
|
||||
else
|
||||
msg_error "$(translate "Failed to update package lists. Check log: $log_file")"
|
||||
@@ -178,15 +207,28 @@ EOF
|
||||
fi
|
||||
|
||||
if apt policy 2>/dev/null | grep -q "${TARGET_CODENAME}.*pve-no-subscription"; then
|
||||
msg_ok "$(translate "Proxmox VE 9.x repositories verified")" | tee -a "$screen_capture"
|
||||
msg_ok "$(translate "Proxmox VE $pve_version repositories verified")" | tee -a "$screen_capture"
|
||||
else
|
||||
msg_warn "$(translate "Proxmox VE 9.x repositories verification inconclusive, continuing...")"
|
||||
msg_warn "$(translate "Proxmox VE $pve_version repositories verification inconclusive, continuing...")"
|
||||
fi
|
||||
|
||||
local current_pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
local available_pve_version=$(apt-cache policy pve-manager 2>/dev/null | grep -oP 'Candidate: \K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
local upgradable=$(apt list --upgradable 2>/dev/null | grep -c "upgradable")
|
||||
local security_updates=$(apt list --upgradable 2>/dev/null | grep -c "security")
|
||||
local current_pve_version
|
||||
current_pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
local available_pve_version
|
||||
available_pve_version=$(apt-cache policy pve-manager 2>/dev/null | grep -oP 'Candidate: \K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
|
||||
|
||||
local upgradable
|
||||
upgradable=$($APT_ENV apt list --upgradable 2>/dev/null \
|
||||
| sed '1d' \
|
||||
| sed '/^\s*$/d' \
|
||||
| wc -l)
|
||||
|
||||
local security_updates
|
||||
security_updates=$($APT_ENV apt list --upgradable 2>/dev/null \
|
||||
| sed '1d' \
|
||||
| grep -ci '\-security')
|
||||
|
||||
|
||||
show_update_menu() {
|
||||
local current_version="$1"
|
||||
@@ -194,7 +236,8 @@ EOF
|
||||
local upgradable_count="$3"
|
||||
local security_count="$4"
|
||||
|
||||
local menu_text="$(translate "System Update Information")\n\n"
|
||||
local menu_text
|
||||
menu_text="$(translate "System Update Information")\n\n"
|
||||
menu_text+="$(translate "Current PVE Version"): $current_version\n"
|
||||
if [ -n "$target_version" ] && [ "$target_version" != "$current_version" ]; then
|
||||
menu_text+="$(translate "Available PVE Version"): $target_version\n"
|
||||
@@ -224,7 +267,6 @@ EOF
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
cat "$screen_capture"
|
||||
|
||||
|
||||
if [[ $MENU_RESULT -eq 1 ]]; then
|
||||
msg_info2 "$(translate "Update cancelled by user")"
|
||||
apt-get -y autoremove > /dev/null 2>&1 || true
|
||||
@@ -247,20 +289,21 @@ EOF
|
||||
fi
|
||||
|
||||
echo -e
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -y \
|
||||
|
||||
|
||||
DEBIAN_FRONTEND=noninteractive apt -y \
|
||||
-o Dpkg::Options::='--force-confdef' \
|
||||
-o Dpkg::Options::='--force-confold' \
|
||||
dist-upgrade 2>&1 | tee -a "$log_file"
|
||||
|
||||
upgrade_exit_code=${PIPESTATUS[0]}
|
||||
full-upgrade 2> >(tee -a "$log_file" >&2)
|
||||
|
||||
upgrade_exit_code=$?
|
||||
echo -e
|
||||
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
cat "$screen_capture"
|
||||
|
||||
|
||||
if [ $upgrade_exit_code -ne 0 ]; then
|
||||
msg_error "$(translate "System upgrade failed. Check log: $log_file")"
|
||||
rm -f "$screen_capture"
|
||||
@@ -283,7 +326,8 @@ EOF
|
||||
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||
msg_ok "$(translate "Cleanup finished")"
|
||||
|
||||
local end_time=$(date +%s)
|
||||
local end_time
|
||||
end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
local minutes=$((duration / 60))
|
||||
local seconds=$((duration % 60))
|
||||
@@ -294,7 +338,7 @@ EOF
|
||||
echo -e "${TAB}${GN}📦 $(translate "Packages upgraded")${CL}: ${BL}$upgradable${CL}"
|
||||
echo -e "${TAB}${GN}🖥️ $(translate "Proxmox VE")${CL}: ${BL}$available_pve_version (Debian $OS_CODENAME)${CL}"
|
||||
|
||||
msg_ok "$(translate "Proxmox VE 9.x configuration completed.")"
|
||||
msg_ok "$(translate "Proxmox VE configuration completed.")"
|
||||
|
||||
rm -f "$screen_capture"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# Proxmox VE Update Script - Improved Version
|
||||
# ==========================================================
|
||||
|
||||
# Configuration
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
ensure_tools_json() {
|
||||
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
register_tool() {
|
||||
local tool="$1"
|
||||
local state="$2"
|
||||
ensure_tools_json
|
||||
jq --arg t "$tool" --argjson v "$state" '.[$t]=$v' "$TOOLS_JSON" > "$TOOLS_JSON.tmp" && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
download_common_functions() {
|
||||
if ! source <(curl -s "$REPO_URL/scripts/global/common-functions.sh"); then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
update_pve9() {
|
||||
local pve_version=$(pveversion | awk -F'/' '{print $2}' | cut -d'-' -f1)
|
||||
local start_time=$(date +%s)
|
||||
local log_file="/var/log/proxmox-update-$(date +%Y%m%d-%H%M%S).log"
|
||||
local changes_made=false
|
||||
local OS_CODENAME="$(grep "VERSION_CODENAME=" /etc/os-release | cut -d"=" -f 2 | xargs)"
|
||||
local TARGET_CODENAME="trixie"
|
||||
|
||||
local screen_capture="/tmp/proxmenux_screen_capture_$$.txt"
|
||||
|
||||
if [ -z "$OS_CODENAME" ]; then
|
||||
OS_CODENAME=$(lsb_release -cs 2>/dev/null || echo "trixie")
|
||||
fi
|
||||
|
||||
download_common_functions
|
||||
|
||||
{
|
||||
msg_info2 "$(translate "Detected: Proxmox VE $pve_version (Current: $OS_CODENAME, Target: $TARGET_CODENAME)")"
|
||||
} | tee -a "$screen_capture"
|
||||
|
||||
|
||||
local available_space=$(df /var/cache/apt/archives | awk 'NR==2 {print int($4/1024)}')
|
||||
if [ "$available_space" -lt 1024 ]; then
|
||||
msg_error "$(translate "Insufficient disk space. Available: ${available_space}MB")"
|
||||
echo -e
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! ping -c 1 download.proxmox.com >/dev/null 2>&1; then
|
||||
msg_error "$(translate "Cannot reach Proxmox repositories")"
|
||||
echo -e
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
return 1
|
||||
fi
|
||||
|
||||
disable_sources_repo() {
|
||||
local file="$1"
|
||||
if [[ -f "$file" ]]; then
|
||||
sed -i ':a;/^\n*$/{$d;N;ba}' "$file"
|
||||
|
||||
if grep -q "^Enabled:" "$file"; then
|
||||
sed -i 's/^Enabled:.*$/Enabled: false/' "$file"
|
||||
else
|
||||
echo "Enabled: false" >> "$file"
|
||||
fi
|
||||
|
||||
if ! grep -q "^Types: " "$file"; then
|
||||
msg_warn "$(translate "Malformed .sources file detected, removing: $(basename "$file")")"
|
||||
rm -f "$file"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if disable_sources_repo "/etc/apt/sources.list.d/pve-enterprise.sources"; then
|
||||
msg_ok "$(translate "Enterprise Proxmox repository disabled")" | tee -a "$screen_capture"
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
if disable_sources_repo "/etc/apt/sources.list.d/ceph.sources"; then
|
||||
msg_ok "$(translate "Enterprise Proxmox Ceph repository disabled")" | tee -a "$screen_capture"
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
for legacy_file in /etc/apt/sources.list.d/pve-public-repo.list \
|
||||
/etc/apt/sources.list.d/pve-install-repo.list \
|
||||
/etc/apt/sources.list.d/debian.list; do
|
||||
if [[ -f "$legacy_file" ]]; then
|
||||
rm -f "$legacy_file"
|
||||
msg_ok "$(translate "Removed legacy repository: $(basename "$legacy_file")")" | tee -a "$screen_capture"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -f /etc/apt/sources.list.d/debian.sources ]]; then
|
||||
rm -f /etc/apt/sources.list.d/debian.sources
|
||||
msg_ok "$(translate "Old debian.sources file removed to prevent duplication")" | tee -a "$screen_capture"
|
||||
fi
|
||||
|
||||
msg_info "$(translate "Creating Proxmox VE 9.x no-subscription repository...")"
|
||||
cat > /etc/apt/sources.list.d/proxmox.sources << EOF
|
||||
Enabled: true
|
||||
Types: deb
|
||||
URIs: http://download.proxmox.com/debian/pve
|
||||
Suites: ${TARGET_CODENAME}
|
||||
Components: pve-no-subscription
|
||||
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
|
||||
EOF
|
||||
msg_ok "$(translate "Proxmox VE 9.x no-subscription repository created")" | tee -a "$screen_capture"
|
||||
changes_made=true
|
||||
|
||||
msg_info "$(translate "Creating Debian ${TARGET_CODENAME} sources file...")"
|
||||
cat > /etc/apt/sources.list.d/debian.sources << EOF
|
||||
Types: deb
|
||||
URIs: http://deb.debian.org/debian/
|
||||
Suites: ${TARGET_CODENAME} ${TARGET_CODENAME}-updates
|
||||
Components: main contrib non-free non-free-firmware
|
||||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
|
||||
Types: deb
|
||||
URIs: http://security.debian.org/debian-security/
|
||||
Suites: ${TARGET_CODENAME}-security
|
||||
Components: main contrib non-free non-free-firmware
|
||||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
EOF
|
||||
|
||||
msg_ok "$(translate "Debian repositories configured for $TARGET_CODENAME")"
|
||||
|
||||
local firmware_conf="/etc/apt/apt.conf.d/no-firmware-warnings.conf"
|
||||
if [ ! -f "$firmware_conf" ]; then
|
||||
msg_info "$(translate "Disabling non-free firmware warnings...")"
|
||||
echo 'APT::Get::Update::SourceListWarnings::NonFreeFirmware "false";' > "$firmware_conf"
|
||||
msg_ok "$(translate "Non-free firmware warnings disabled")"
|
||||
fi
|
||||
|
||||
#update_output=$(apt-get update 2>&1)
|
||||
update_output=$(apt-get -o Dpkg::Progress-Fancy=1 update 2>&1)
|
||||
update_exit_code=$?
|
||||
|
||||
if [ $update_exit_code -eq 0 ]; then
|
||||
msg_ok "$(translate "Package lists updated successfully")" | tee -a "$screen_capture"
|
||||
else
|
||||
if echo "$update_output" | grep -q "NO_PUBKEY\|GPG error"; then
|
||||
msg_info "$(translate "Fixing GPG key issues...")"
|
||||
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys $(echo "$update_output" | grep "NO_PUBKEY" | sed 's/.*NO_PUBKEY //' | head -1) 2>/dev/null
|
||||
if apt-get update > "$log_file" 2>&1; then
|
||||
msg_ok "$(translate "Package lists updated after GPG fix")" | tee -a "$screen_capture"
|
||||
else
|
||||
msg_error "$(translate "Failed to update package lists. Check log: $log_file")"
|
||||
return 1
|
||||
fi
|
||||
elif echo "$update_output" | grep -q "404\|Failed to fetch"; then
|
||||
msg_warn "$(translate "Some repositories are not available, continuing with available ones...")"
|
||||
else
|
||||
msg_error "$(translate "Failed to update package lists. Check log: $log_file")"
|
||||
echo "Error details: $update_output"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if apt policy 2>/dev/null | grep -q "${TARGET_CODENAME}.*pve-no-subscription"; then
|
||||
msg_ok "$(translate "Proxmox VE 9.x repositories verified")" | tee -a "$screen_capture"
|
||||
else
|
||||
msg_warn "$(translate "Proxmox VE 9.x repositories verification inconclusive, continuing...")"
|
||||
fi
|
||||
|
||||
local current_pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
local available_pve_version=$(apt-cache policy pve-manager 2>/dev/null | grep -oP 'Candidate: \K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
local upgradable=$(apt list --upgradable 2>/dev/null | grep -c "upgradable")
|
||||
local security_updates=$(apt list --upgradable 2>/dev/null | grep -c "security")
|
||||
|
||||
show_update_menu() {
|
||||
local current_version="$1"
|
||||
local target_version="$2"
|
||||
local upgradable_count="$3"
|
||||
local security_count="$4"
|
||||
|
||||
local menu_text="$(translate "System Update Information")\n\n"
|
||||
menu_text+="$(translate "Current PVE Version"): $current_version\n"
|
||||
if [ -n "$target_version" ] && [ "$target_version" != "$current_version" ]; then
|
||||
menu_text+="$(translate "Available PVE Version"): $target_version\n"
|
||||
fi
|
||||
menu_text+="\n$(translate "Package Updates Available"): $upgradable_count\n"
|
||||
menu_text+="$(translate "Security Updates"): $security_count\n\n"
|
||||
|
||||
if [ "$upgradable_count" -eq 0 ]; then
|
||||
menu_text+="$(translate "System is already up to date")"
|
||||
whiptail --title "$(translate "Update Status")" --msgbox "$menu_text" 15 70
|
||||
return 2
|
||||
else
|
||||
menu_text+="$(translate "Do you want to proceed with the system update?")"
|
||||
if whiptail --title "$(translate "Proxmox Update")" --yesno "$menu_text" 18 70; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
show_update_menu "$current_pve_version" "$available_pve_version" "$upgradable" "$security_updates"
|
||||
MENU_RESULT=$?
|
||||
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
cat "$screen_capture"
|
||||
|
||||
|
||||
if [[ $MENU_RESULT -eq 1 ]]; then
|
||||
msg_info2 "$(translate "Update cancelled by user")"
|
||||
apt-get -y autoremove > /dev/null 2>&1 || true
|
||||
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||
rm -f "$screen_capture"
|
||||
return 0
|
||||
elif [[ $MENU_RESULT -eq 2 ]]; then
|
||||
msg_ok "$(translate "System is already up to date. No update needed.")"
|
||||
apt-get -y autoremove > /dev/null 2>&1 || true
|
||||
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||
rm -f "$screen_capture"
|
||||
return 0
|
||||
fi
|
||||
|
||||
msg_info "$(translate "Cleaning up unused time synchronization services...")"
|
||||
if /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' purge ntp openntpd systemd-timesyncd > /dev/null 2>&1; then
|
||||
msg_ok "$(translate "Old time services removed successfully")"
|
||||
else
|
||||
msg_warn "$(translate "Some old time services could not be removed (not installed)")"
|
||||
fi
|
||||
|
||||
echo -e
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -y \
|
||||
-o Dpkg::Options::='--force-confdef' \
|
||||
-o Dpkg::Options::='--force-confold' \
|
||||
dist-upgrade 2>&1 | tee -a "$log_file"
|
||||
|
||||
upgrade_exit_code=${PIPESTATUS[0]}
|
||||
echo -e
|
||||
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
cat "$screen_capture"
|
||||
|
||||
|
||||
if [ $upgrade_exit_code -ne 0 ]; then
|
||||
msg_error "$(translate "System upgrade failed. Check log: $log_file")"
|
||||
rm -f "$screen_capture"
|
||||
return 1
|
||||
fi
|
||||
|
||||
msg_info "$(translate "Installing essential Proxmox packages...")"
|
||||
local additional_packages="zfsutils-linux proxmox-backup-restore-image chrony"
|
||||
|
||||
if /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' install $additional_packages >> "$log_file" 2>&1; then
|
||||
msg_ok "$(translate "Essential Proxmox packages installed")"
|
||||
else
|
||||
msg_warn "$(translate "Some essential Proxmox packages may not have been installed")"
|
||||
fi
|
||||
|
||||
lvm_repair_check
|
||||
cleanup_duplicate_repos
|
||||
|
||||
apt-get -y autoremove > /dev/null 2>&1 || true
|
||||
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||
msg_ok "$(translate "Cleanup finished")"
|
||||
|
||||
local end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
local minutes=$((duration / 60))
|
||||
local seconds=$((duration % 60))
|
||||
|
||||
echo -e "${TAB}${BGN}$(translate "====== PVE UPDATE COMPLETED ======")${CL}"
|
||||
echo -e "${TAB}${GN}⏱️ $(translate "Duration")${CL}: ${BL}${minutes}m ${seconds}s${CL}"
|
||||
echo -e "${TAB}${GN}📄 $(translate "Log file")${CL}: ${BL}$log_file${CL}"
|
||||
echo -e "${TAB}${GN}📦 $(translate "Packages upgraded")${CL}: ${BL}$upgradable${CL}"
|
||||
echo -e "${TAB}${GN}🖥️ $(translate "Proxmox VE")${CL}: ${BL}$available_pve_version (Debian $OS_CODENAME)${CL}"
|
||||
|
||||
msg_ok "$(translate "Proxmox VE 9.x configuration completed.")"
|
||||
|
||||
rm -f "$screen_capture"
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
update_pve9
|
||||
fi
|
||||
@@ -0,0 +1,430 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenux - A menu-driven script for Proxmox VE management
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Revision : @Blaspt (USB passthrough via udev rule with persistent /dev/coral)
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.2
|
||||
# Last Updated: 20/01/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script automates the configuration and installation of
|
||||
# Coral TPU and iGPU support in Proxmox VE containers. It:
|
||||
# - Configures a selected LXC container for hardware acceleration
|
||||
# - Installs and sets up Coral TPU drivers on the Proxmox host
|
||||
# - Installs necessary drivers inside the container
|
||||
# - Manages required system and container restarts
|
||||
#
|
||||
# Supports Coral USB and Coral M.2 (PCIe) devices.
|
||||
# Includes USB passthrough enhancement using persistent udev alias (/dev/coral).
|
||||
#
|
||||
# Changelog v1.2:
|
||||
# - Fixed symlink detection for /dev/coral (create=dir for symlinks)
|
||||
# - Fixed /dev/apex_0 not being mounted in PVE 9 (device existence not required)
|
||||
# - Fixed grep patterns to avoid matching commented lines
|
||||
# - Improved device type inference for non-existent devices
|
||||
# - Added duplicate entry cleanup
|
||||
# - Better error handling and logging
|
||||
# ==========================================================
|
||||
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
# ==========================================================
|
||||
# CONTAINER SELECTION AND VALIDATION
|
||||
# ==========================================================
|
||||
|
||||
select_container() {
|
||||
CONTAINERS=$(pct list | awk 'NR>1 {print $1, $3}' | xargs -n2)
|
||||
if [ -z "$CONTAINERS" ]; then
|
||||
msg_error "$(translate 'No containers available in Proxmox.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CONTAINER_ID=$(whiptail --title "$(translate 'Select Container')" \
|
||||
--menu "$(translate 'Select the LXC container:')" 20 70 10 $CONTAINERS 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$CONTAINER_ID" ]; then
|
||||
msg_error "$(translate 'No container selected. Exiting.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! pct list | awk 'NR>1 {print $1}' | grep -qw "$CONTAINER_ID"; then
|
||||
msg_error "$(translate 'Container with ID') $CONTAINER_ID $(translate 'does not exist. Exiting.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'Container selected:') $CONTAINER_ID"
|
||||
}
|
||||
|
||||
validate_container_id() {
|
||||
if [ -z "$CONTAINER_ID" ]; then
|
||||
msg_error "$(translate 'Container ID not defined. Make sure to select a container first.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if pct status "$CONTAINER_ID" | grep -q "running"; then
|
||||
msg_info "$(translate 'Stopping the container before applying configuration...')"
|
||||
pct stop "$CONTAINER_ID"
|
||||
msg_ok "$(translate 'Container stopped.')"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# UDEV RULES FOR CORAL USB
|
||||
# ==========================================================
|
||||
|
||||
add_udev_rule_for_coral_usb() {
|
||||
RULE_FILE="/etc/udev/rules.d/99-coral-usb.rules"
|
||||
RULE_CONTENT='# Coral USB Accelerator
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="18d1", ATTRS{idProduct}=="9302", MODE="0666", TAG+="uaccess", SYMLINK+="coral"
|
||||
# Coral Dev Board / Mini PCIe
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a6e", ATTRS{idProduct}=="089a", MODE="0666", TAG+="uaccess", SYMLINK+="coral"'
|
||||
|
||||
if [[ ! -f "$RULE_FILE" ]] || ! grep -q "18d1.*9302\|1a6e.*089a" "$RULE_FILE"; then
|
||||
echo "$RULE_CONTENT" > "$RULE_FILE"
|
||||
udevadm control --reload-rules && udevadm trigger
|
||||
msg_ok "$(translate 'Udev rules for Coral USB devices added and rules reloaded.')"
|
||||
else
|
||||
msg_ok "$(translate 'Udev rules for Coral USB devices already exist.')"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# MOUNT CONFIGURATION HELPER
|
||||
# ==========================================================
|
||||
|
||||
add_mount_if_needed() {
|
||||
local DEVICE="$1"
|
||||
local DEST="$2"
|
||||
local CONFIG_FILE="$3"
|
||||
|
||||
if grep -q "lxc.mount.entry: $DEVICE" "$CONFIG_FILE"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local create_type="dir"
|
||||
|
||||
if [ -e "$DEVICE" ]; then
|
||||
if [ -L "$DEVICE" ]; then
|
||||
create_type="dir"
|
||||
elif [ -c "$DEVICE" ]; then
|
||||
create_type="file"
|
||||
elif [ -d "$DEVICE" ]; then
|
||||
create_type="dir"
|
||||
fi
|
||||
else
|
||||
case "$DEVICE" in
|
||||
*/apex_*|*/fb*|*/renderD*|*/card*)
|
||||
create_type="file"
|
||||
;;
|
||||
*/coral)
|
||||
create_type="dir"
|
||||
;;
|
||||
*/dri|*/bus/usb*)
|
||||
create_type="dir"
|
||||
;;
|
||||
*)
|
||||
create_type="dir"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo "lxc.mount.entry: $DEVICE $DEST none bind,optional,create=$create_type" >> "$CONFIG_FILE"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# CLEANUP DUPLICATE ENTRIES
|
||||
# ==========================================================
|
||||
|
||||
cleanup_duplicate_entries() {
|
||||
local CONFIG_FILE="$1"
|
||||
local TEMP_FILE=$(mktemp)
|
||||
|
||||
awk '!seen[$0]++' "$CONFIG_FILE" > "$TEMP_FILE"
|
||||
|
||||
cat "$TEMP_FILE" > "$CONFIG_FILE"
|
||||
rm -f "$TEMP_FILE"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# CONFIGURE LXC HARDWARE PASSTHROUGH
|
||||
# ==========================================================
|
||||
|
||||
configure_lxc_hardware() {
|
||||
validate_container_id
|
||||
CONFIG_FILE="/etc/pve/lxc/${CONTAINER_ID}.conf"
|
||||
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
msg_error "$(translate 'Configuration file for container') $CONTAINER_ID $(translate 'not found.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup_duplicate_entries "$CONFIG_FILE"
|
||||
|
||||
# ============================================================
|
||||
# Convert to privileged container if needed
|
||||
# ============================================================
|
||||
if grep -q "^unprivileged: 1" "$CONFIG_FILE"; then
|
||||
msg_info "$(translate 'The container is unprivileged. Changing to privileged...')"
|
||||
sed -i "s/^unprivileged: 1/unprivileged: 0/" "$CONFIG_FILE"
|
||||
|
||||
STORAGE_TYPE=$(pct config "$CONTAINER_ID" | grep "^rootfs:" | awk -F, '{print $2}' | cut -d'=' -f2)
|
||||
if [[ "$STORAGE_TYPE" == "dir" ]]; then
|
||||
STORAGE_PATH=$(pct config "$CONTAINER_ID" | grep "^rootfs:" | awk '{print $2}' | cut -d',' -f1)
|
||||
chown -R root:root "$STORAGE_PATH"
|
||||
fi
|
||||
msg_ok "$(translate 'Container changed to privileged.')"
|
||||
else
|
||||
msg_ok "$(translate 'The container is already privileged.')"
|
||||
fi
|
||||
|
||||
sed -i '/^dev[0-9]\+:/d' "$CONFIG_FILE"
|
||||
|
||||
# ============================================================
|
||||
# Enable nesting feature
|
||||
# ============================================================
|
||||
if ! grep -Pq "^features:.*nesting=1" "$CONFIG_FILE"; then
|
||||
if grep -Pq "^features:" "$CONFIG_FILE"; then
|
||||
|
||||
sed -i 's/^features: \(.*\)/features: nesting=1,\1/' "$CONFIG_FILE"
|
||||
else
|
||||
|
||||
echo "features: nesting=1" >> "$CONFIG_FILE"
|
||||
fi
|
||||
msg_ok "$(translate 'Nesting feature enabled')"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# iGPU support
|
||||
# ============================================================
|
||||
msg_info "$(translate 'Configuring iGPU support...')"
|
||||
|
||||
if ! grep -Pq "^lxc.cgroup2.devices.allow: c 226:0 rwm" "$CONFIG_FILE"; then
|
||||
echo "lxc.cgroup2.devices.allow: c 226:0 rwm # iGPU" >> "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
if ! grep -Pq "^lxc.cgroup2.devices.allow: c 226:128 rwm" "$CONFIG_FILE"; then
|
||||
echo "lxc.cgroup2.devices.allow: c 226:128 rwm # iGPU" >> "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
add_mount_if_needed "/dev/dri" "dev/dri" "$CONFIG_FILE"
|
||||
add_mount_if_needed "/dev/dri/renderD128" "dev/dri/renderD128" "$CONFIG_FILE"
|
||||
add_mount_if_needed "/dev/dri/card0" "dev/dri/card0" "$CONFIG_FILE"
|
||||
|
||||
msg_ok "$(translate 'iGPU configuration added')"
|
||||
|
||||
# ============================================================
|
||||
# Framebuffer support
|
||||
# ============================================================
|
||||
if [ -e "/dev/fb0" ]; then
|
||||
msg_info "$(translate 'Configuring Framebuffer support...')"
|
||||
|
||||
if ! grep -Pq "^lxc.cgroup2.devices.allow: c 29:0 rwm" "$CONFIG_FILE"; then
|
||||
echo "lxc.cgroup2.devices.allow: c 29:0 rwm # Framebuffer" >> "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
add_mount_if_needed "/dev/fb0" "dev/fb0" "$CONFIG_FILE"
|
||||
msg_ok "$(translate 'Framebuffer configuration added')"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# Coral USB passthrough
|
||||
# ============================================================
|
||||
msg_info "$(translate 'Configuring Coral USB support...')"
|
||||
|
||||
add_udev_rule_for_coral_usb
|
||||
|
||||
if ! grep -Pq "^lxc.cgroup2.devices.allow: c 189:\\\* rwm" "$CONFIG_FILE"; then
|
||||
echo "lxc.cgroup2.devices.allow: c 189:* rwm # Coral USB" >> "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
add_mount_if_needed "/dev/coral" "dev/coral" "$CONFIG_FILE"
|
||||
|
||||
if [ -L "/dev/coral" ]; then
|
||||
msg_ok "$(translate 'Coral USB configuration added - device detected')"
|
||||
else
|
||||
msg_ok "$(translate 'Coral USB configured but device not currently connected')"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# Coral M.2 (PCIe) support
|
||||
# ============================================================
|
||||
stop_spinner
|
||||
|
||||
if lspci | grep -iq "Global Unichip"; then
|
||||
msg_info "$(translate 'Coral M.2 Apex detected, configuring...')"
|
||||
|
||||
|
||||
if ! grep -Pq "^lxc.cgroup2.devices.allow: c 245:0 rwm" "$CONFIG_FILE"; then
|
||||
echo "lxc.cgroup2.devices.allow: c 245:0 rwm # Coral M2 Apex" >> "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
|
||||
add_mount_if_needed "/dev/apex_0" "dev/apex_0" "$CONFIG_FILE"
|
||||
|
||||
if [ -e "/dev/apex_0" ]; then
|
||||
msg_ok "$(translate 'Coral M.2 Apex configuration added - device ready')"
|
||||
else
|
||||
msg_ok "$(translate 'Coral M.2 Apex configuration added - device will be available after reboot')"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
cleanup_duplicate_entries "$CONFIG_FILE"
|
||||
|
||||
msg_ok "$(translate 'Hardware configuration completed for container') $CONTAINER_ID"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# INSTALL DRIVERS INSIDE CONTAINER
|
||||
# ==========================================================
|
||||
|
||||
install_coral_in_container() {
|
||||
msg_info "$(translate 'Installing iGPU and Coral TPU drivers inside the container...')"
|
||||
tput sc
|
||||
LOG_FILE=$(mktemp)
|
||||
|
||||
|
||||
if ! pct status "$CONTAINER_ID" | grep -q "running"; then
|
||||
pct start "$CONTAINER_ID"
|
||||
sleep 5
|
||||
fi
|
||||
|
||||
|
||||
stop_spinner
|
||||
|
||||
# Determine driver package for Coral M.2
|
||||
CORAL_M2=$(lspci | grep -i "Global Unichip")
|
||||
if [[ -n "$CORAL_M2" ]]; then
|
||||
DRIVER_OPTION=$(whiptail --title "$(translate 'Select driver version')" \
|
||||
--menu "$(translate 'Choose the driver version for Coral M.2:\n\nCaution: Maximum mode generates more heat.')" 15 60 2 \
|
||||
1 "libedgetpu1-std ($(translate 'standard performance'))" \
|
||||
2 "libedgetpu1-max ($(translate 'maximum performance'))" 3>&1 1>&2 2>&3)
|
||||
|
||||
case "$DRIVER_OPTION" in
|
||||
1) DRIVER_PACKAGE="libedgetpu1-std" ;;
|
||||
2) DRIVER_PACKAGE="libedgetpu1-max" ;;
|
||||
*) DRIVER_PACKAGE="libedgetpu1-std" ;;
|
||||
esac
|
||||
else
|
||||
DRIVER_PACKAGE="libedgetpu1-std"
|
||||
fi
|
||||
|
||||
# Install drivers inside container
|
||||
script -q -c "pct exec \"$CONTAINER_ID\" -- bash -c '
|
||||
set -e
|
||||
|
||||
echo \"[1/6] Updating package lists...\"
|
||||
apt-get update -qq
|
||||
|
||||
echo \"[2/6] Installing iGPU drivers...\"
|
||||
apt-get install -y -qq va-driver-all ocl-icd-libopencl1 intel-opencl-icd vainfo intel-gpu-tools
|
||||
|
||||
echo \"[3/6] Configuring DRI permissions...\"
|
||||
if [ -e /dev/dri ]; then
|
||||
chgrp video /dev/dri 2>/dev/null || true
|
||||
chmod 755 /dev/dri 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo \"[4/6] Adding users to video/render groups...\"
|
||||
adduser root video 2>/dev/null || true
|
||||
adduser root render 2>/dev/null || true
|
||||
|
||||
echo \"[5/6] Installing Coral TPU dependencies...\"
|
||||
apt-get install -y -qq gnupg curl ca-certificates
|
||||
|
||||
echo \"[6/6] Adding Coral TPU repository...\"
|
||||
curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/coral-edgetpu.gpg
|
||||
echo \"deb [signed-by=/usr/share/keyrings/coral-edgetpu.gpg] https://packages.cloud.google.com/apt coral-edgetpu-stable main\" | tee /etc/apt/sources.list.d/coral-edgetpu.list >/dev/null
|
||||
|
||||
echo \"\"
|
||||
echo \"Updating package lists for Coral repository...\"
|
||||
apt-get update -qq
|
||||
|
||||
echo \"Installing Coral TPU driver ($DRIVER_PACKAGE)...\"
|
||||
apt-get install -y -qq $DRIVER_PACKAGE
|
||||
|
||||
'" "$LOG_FILE" 2>&1
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
tput rc
|
||||
tput ed
|
||||
rm -f "$LOG_FILE"
|
||||
msg_ok "$(translate 'iGPU and Coral TPU drivers installed successfully inside the container.')"
|
||||
else
|
||||
tput rc
|
||||
tput ed
|
||||
msg_error "$(translate 'Failed to install drivers inside the container.')"
|
||||
echo ""
|
||||
echo "$(translate 'Installation log:')"
|
||||
cat "$LOG_FILE"
|
||||
rm -f "$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# VERIFICATION AND SUMMARY
|
||||
# ==========================================================
|
||||
|
||||
show_configuration_summary() {
|
||||
local CONFIG_FILE="/etc/pve/lxc/${CONTAINER_ID}.conf"
|
||||
|
||||
|
||||
# iGPU
|
||||
if grep -q "c 226:0 rwm" "$CONFIG_FILE"; then
|
||||
msg_ok2 "✓ iGPU support: $(translate 'Enabled')"
|
||||
fi
|
||||
|
||||
# Coral USB
|
||||
if grep -q "c 189:.*rwm.*Coral USB" "$CONFIG_FILE"; then
|
||||
if [ -L "/dev/coral" ]; then
|
||||
msg_ok2 "✓ Coral USB: $(translate 'Enabled and detected')"
|
||||
else
|
||||
msg_ok2 "⚠ Coral USB: $(translate 'Enabled but not connected')"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Coral M.2
|
||||
if grep -q "c 245:0 rwm.*Coral M2" "$CONFIG_FILE"; then
|
||||
if [ -e "/dev/apex_0" ]; then
|
||||
msg_ok2 "✓ Coral M.2: $(translate 'Enabled and ready')"
|
||||
else
|
||||
msg_ok2 "⚠ Coral M.2: $(translate 'Enabled (device pending)')"
|
||||
fi
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# MAIN EXECUTION
|
||||
# ==========================================================
|
||||
|
||||
main() {
|
||||
select_container
|
||||
show_proxmenux_logo
|
||||
configure_lxc_hardware
|
||||
install_coral_in_container
|
||||
show_configuration_summary
|
||||
|
||||
msg_ok "$(translate 'Configuration completed successfully!')"
|
||||
echo ""
|
||||
msg_success "$(translate 'Press Enter to return to menu...')"
|
||||
read -r
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main
|
||||
@@ -7,7 +7,7 @@
|
||||
# Last Updated: 25/09/2025
|
||||
# =========================================
|
||||
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
LOG_FILE="/tmp/coral_install.log"
|
||||
|
||||
@@ -0,0 +1,931 @@
|
||||
#!/bin/bash
|
||||
# ProxMenux - NVIDIA Driver Installer (PVE 9.x)
|
||||
# ============================================
|
||||
# Author : MacRimi
|
||||
# License : MIT
|
||||
# Version : 0.9 (PVE9, fixed download issues)
|
||||
# Last Updated: 29/11/2025
|
||||
# ============================================
|
||||
|
||||
SCRIPT_TITLE="NVIDIA GPU Driver Installer for Proxmox VE"
|
||||
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
COMPONENTS_STATUS_FILE="$BASE_DIR/components_status.json"
|
||||
LOG_FILE="/tmp/nvidia_install.log"
|
||||
screen_capture="/tmp/proxmenux_nvidia_screen_capture_$$.txt"
|
||||
|
||||
NVIDIA_BASE_URL="https://download.nvidia.com/XFree86/Linux-x86_64"
|
||||
NVIDIA_WORKDIR="/opt/nvidia"
|
||||
|
||||
export BASE_DIR
|
||||
export COMPONENTS_STATUS_FILE
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$COMPONENTS_STATUS_FILE" ]]; then
|
||||
echo "{}" > "$COMPONENTS_STATUS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
# ==========================================================
|
||||
# GPU detection and current status
|
||||
# ==========================================================
|
||||
detect_nvidia_gpus() {
|
||||
# Only video controllers (not audio)
|
||||
local lspci_output
|
||||
lspci_output=$(lspci | grep -i "NVIDIA" \
|
||||
| grep -Ei "VGA compatible controller|3D controller|Display controller" || true)
|
||||
|
||||
if [[ -z "$lspci_output" ]]; then
|
||||
NVIDIA_GPU_PRESENT=false
|
||||
DETECTED_GPUS_TEXT="$(translate 'No NVIDIA GPU detected on this system.')"
|
||||
else
|
||||
NVIDIA_GPU_PRESENT=true
|
||||
DETECTED_GPUS_TEXT=""
|
||||
local i=1
|
||||
while IFS= read -r line; do
|
||||
DETECTED_GPUS_TEXT+=" ${i}. ${line}\n"
|
||||
((i++))
|
||||
done <<< "$lspci_output"
|
||||
fi
|
||||
}
|
||||
|
||||
detect_driver_status() {
|
||||
CURRENT_DRIVER_INSTALLED=false
|
||||
CURRENT_DRIVER_VERSION=""
|
||||
|
||||
# First check if nvidia kernel module is actually loaded
|
||||
if lsmod | grep -q "^nvidia "; then
|
||||
|
||||
modprobe nvidia-uvm 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
CURRENT_DRIVER_VERSION=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -n1)
|
||||
|
||||
if [[ -n "$CURRENT_DRIVER_VERSION" ]]; then
|
||||
CURRENT_DRIVER_INSTALLED=true
|
||||
# Register the installed driver version in components_status.json
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if $CURRENT_DRIVER_INSTALLED; then
|
||||
CURRENT_STATUS_TEXT="$(printf '%s %s' "$(translate 'NVIDIA driver installed:')" "$CURRENT_DRIVER_VERSION")"
|
||||
else
|
||||
CURRENT_STATUS_TEXT="$(translate 'No NVIDIA driver installed.')"
|
||||
fi
|
||||
|
||||
if $CURRENT_DRIVER_INSTALLED; then
|
||||
CURRENT_STATUS_COLORED="${CURRENT_STATUS_TEXT}"
|
||||
else
|
||||
CURRENT_STATUS_COLORED="${CURRENT_STATUS_TEXT}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# System preparation (repos, headers, etc.)
|
||||
# ==========================================================
|
||||
ensure_repos_and_headers() {
|
||||
msg_info "$(translate 'Checking kernel headers and build tools...')"
|
||||
|
||||
local kver
|
||||
kver=$(uname -r)
|
||||
|
||||
apt-get update -qq >>"$LOG_FILE" 2>&1
|
||||
|
||||
if ! dpkg -s "pve-headers-$kver" >/dev/null 2>&1 && \
|
||||
! dpkg -s "proxmox-headers-$kver" >/dev/null 2>&1; then
|
||||
apt-get install -y "pve-headers-$kver" "proxmox-headers-$kver" build-essential dkms >>"$LOG_FILE" 2>&1 || true
|
||||
else
|
||||
apt-get install -y build-essential dkms >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'Kernel headers and build tools verified.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
blacklist_nouveau() {
|
||||
msg_info "$(translate 'Blacklisting nouveau driver...')"
|
||||
if ! grep -q '^blacklist nouveau' /etc/modprobe.d/blacklist.conf 2>/dev/null; then
|
||||
echo "blacklist nouveau" >> /etc/modprobe.d/blacklist.conf
|
||||
fi
|
||||
msg_ok "$(translate 'nouveau driver has been blacklisted.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
ensure_modules_config() {
|
||||
msg_info "$(translate 'Configuring NVIDIA and VFIO modules...')"
|
||||
cat > /etc/modules-load.d/nvidia-vfio.conf <<'EOF'
|
||||
vfio
|
||||
vfio_iommu_type1
|
||||
vfio_pci
|
||||
vfio_virqfd
|
||||
nvidia
|
||||
nvidia_uvm
|
||||
EOF
|
||||
msg_ok "$(translate 'Modules configuration updated.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
stop_and_disable_nvidia_services() {
|
||||
local services=(
|
||||
"nvidia-persistenced.service"
|
||||
"nvidia-persistenced"
|
||||
"nvidia-powerd.service"
|
||||
)
|
||||
|
||||
local services_detected=0
|
||||
|
||||
for service in "${services[@]}"; do
|
||||
if systemctl is-active --quiet "$service" 2>/dev/null || \
|
||||
systemctl is-enabled --quiet "$service" 2>/dev/null; then
|
||||
services_detected=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$services_detected" -eq 1 ]; then
|
||||
msg_info "$(translate 'Stopping and disabling NVIDIA services...')"
|
||||
|
||||
for service in "${services[@]}"; do
|
||||
if systemctl is-active --quiet "$service" 2>/dev/null; then
|
||||
systemctl stop "$service" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if systemctl is-enabled --quiet "$service" 2>/dev/null; then
|
||||
systemctl disable "$service" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
|
||||
sleep 2
|
||||
|
||||
msg_ok "$(translate 'NVIDIA services stopped and disabled.')" | tee -a "$screen_capture"
|
||||
fi
|
||||
}
|
||||
|
||||
unload_nvidia_modules() {
|
||||
msg_info "$(translate 'Unloading NVIDIA kernel modules...')"
|
||||
|
||||
for mod in nvidia_uvm nvidia_drm nvidia_modeset nvidia; do
|
||||
modprobe -r "$mod" >/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
|
||||
if lsmod | grep -qi '\bnvidia'; then
|
||||
for mod in nvidia_uvm nvidia_drm nvidia_modeset nvidia; do
|
||||
modprobe -r --force "$mod" >/dev/null 2>&1 || true
|
||||
done
|
||||
fi
|
||||
|
||||
if lsmod | grep -qi '\bnvidia'; then
|
||||
msg_warn "$(translate 'Some NVIDIA modules could not be unloaded. Installation may fail. Ensure no processes are using the GPU.')"
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
echo "$(translate 'Processes using NVIDIA:'):" >> "$LOG_FILE"
|
||||
lsof /dev/nvidia* 2>/dev/null >> "$LOG_FILE" || true
|
||||
fi
|
||||
else
|
||||
msg_ok "$(translate 'NVIDIA kernel modules unloaded successfully.')" | tee -a "$screen_capture"
|
||||
fi
|
||||
}
|
||||
|
||||
complete_nvidia_uninstall() {
|
||||
stop_and_disable_nvidia_services
|
||||
unload_nvidia_modules
|
||||
|
||||
if command -v nvidia-uninstall >/dev/null 2>&1; then
|
||||
msg_info "$(translate 'Running NVIDIA uninstaller...')"
|
||||
nvidia-uninstall --silent >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'NVIDIA uninstaller completed.')"
|
||||
fi
|
||||
|
||||
cleanup_nvidia_dkms
|
||||
|
||||
msg_info "$(translate 'Removing NVIDIA packages...')"
|
||||
apt-get -y purge 'nvidia-*' 'libnvidia-*' 'cuda-*' 'libcudnn*' >>"$LOG_FILE" 2>&1 || true
|
||||
apt-get -y autoremove --purge >>"$LOG_FILE" 2>&1 || true
|
||||
apt-get -y autoclean >>"$LOG_FILE" 2>&1 || true
|
||||
|
||||
rm -f /etc/modules-load.d/nvidia-vfio.conf
|
||||
rm -f /etc/udev/rules.d/70-nvidia.rules
|
||||
rm -rf /usr/lib/modprobe.d/nvidia*.conf
|
||||
rm -rf /etc/modprobe.d/nvidia*.conf
|
||||
|
||||
if [[ -d "$NVIDIA_WORKDIR" ]]; then
|
||||
find "$NVIDIA_WORKDIR" -type d -name "nvidia-persistenced" -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$NVIDIA_WORKDIR" -type d -name "nvidia-patch" -exec rm -rf {} + 2>/dev/null || true
|
||||
fi
|
||||
|
||||
update_component_status "nvidia_driver" "removed" "" "gpu" '{}'
|
||||
|
||||
msg_ok "$(translate 'Complete NVIDIA uninstallation finished.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
cleanup_nvidia_dkms() {
|
||||
local versions
|
||||
versions=$(dkms status 2>/dev/null | awk -F, '/nvidia/ {gsub(/ /,"",$2); print $2}' || true)
|
||||
|
||||
[[ -z "$versions" ]] && return 0
|
||||
|
||||
msg_info "$(translate 'Removing NVIDIA DKMS entries...')"
|
||||
while IFS= read -r ver; do
|
||||
[[ -z "$ver" ]] && continue
|
||||
dkms remove -m nvidia -v "$ver" --all >/dev/null 2>&1 || true
|
||||
done <<< "$versions"
|
||||
msg_ok "$(translate 'NVIDIA DKMS entries removed.')"
|
||||
}
|
||||
|
||||
ensure_workdir() {
|
||||
mkdir -p "$NVIDIA_WORKDIR"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Kernel compatibility detection
|
||||
# ==========================================================
|
||||
get_kernel_compatibility_info() {
|
||||
local kernel_version
|
||||
kernel_version=$(uname -r)
|
||||
|
||||
# Determine Proxmox and kernel version
|
||||
if [[ -f /etc/pve/.version ]]; then
|
||||
PVE_VERSION=$(cat /etc/pve/.version)
|
||||
else
|
||||
PVE_VERSION="unknown"
|
||||
fi
|
||||
|
||||
# Extract kernel major version (6.x, 5.x, etc)
|
||||
KERNEL_MAJOR=$(echo "$kernel_version" | cut -d. -f1)
|
||||
KERNEL_MINOR=$(echo "$kernel_version" | cut -d. -f2)
|
||||
|
||||
# Define minimum compatible versions based on kernel
|
||||
# Based on https://docs.nvidia.com/datacenter/tesla/drivers/index.html
|
||||
if [[ "$KERNEL_MAJOR" -ge 6 ]] && [[ "$KERNEL_MINOR" -ge 17 ]]; then
|
||||
# Kernel 6.17+ (Proxmox 9.x) - Requires 580.82.07 or higher
|
||||
MIN_DRIVER_VERSION="580.82.07"
|
||||
RECOMMENDED_BRANCH="580"
|
||||
COMPATIBILITY_NOTE="Kernel $kernel_version requires NVIDIA driver 580.82.07 or newer"
|
||||
elif [[ "$KERNEL_MAJOR" -ge 6 ]] && [[ "$KERNEL_MINOR" -ge 8 ]]; then
|
||||
# Kernel 6.8-6.16 (Proxmox 8.2+) - Works with 550.x or higher
|
||||
MIN_DRIVER_VERSION="550"
|
||||
RECOMMENDED_BRANCH="580"
|
||||
COMPATIBILITY_NOTE="Kernel $kernel_version works best with NVIDIA driver 550.x or newer"
|
||||
elif [[ "$KERNEL_MAJOR" -ge 6 ]]; then
|
||||
# Kernel 6.2-6.7 (Proxmox 8.x initial) - Works with 535.x or higher
|
||||
MIN_DRIVER_VERSION="535"
|
||||
RECOMMENDED_BRANCH="550"
|
||||
COMPATIBILITY_NOTE="Kernel $kernel_version works with NVIDIA driver 535.x or newer"
|
||||
elif [[ "$KERNEL_MAJOR" -eq 5 ]] && [[ "$KERNEL_MINOR" -ge 15 ]]; then
|
||||
# Kernel 5.15+ (Proxmox 7.x, 8.x legacy) - Works with 470.x or higher
|
||||
MIN_DRIVER_VERSION="470"
|
||||
RECOMMENDED_BRANCH="535"
|
||||
COMPATIBILITY_NOTE="Kernel $kernel_version works with NVIDIA driver 470.x or newer"
|
||||
else
|
||||
# Old kernels
|
||||
MIN_DRIVER_VERSION="450"
|
||||
RECOMMENDED_BRANCH="470"
|
||||
COMPATIBILITY_NOTE="For older kernels, compatibility may vary"
|
||||
fi
|
||||
}
|
||||
|
||||
is_version_compatible() {
|
||||
local version="$1"
|
||||
local ver_major ver_minor ver_patch
|
||||
|
||||
# Extract version components (major.minor.patch)
|
||||
ver_major=$(echo "$version" | cut -d. -f1)
|
||||
ver_minor=$(echo "$version" | cut -d. -f2)
|
||||
ver_patch=$(echo "$version" | cut -d. -f3)
|
||||
|
||||
if [[ "$MIN_DRIVER_VERSION" == "580.82.07" ]]; then
|
||||
# Compare full version: must be >= 580.82.07
|
||||
if [[ ${ver_major} -gt 580 ]]; then
|
||||
return 0
|
||||
elif [[ ${ver_major} -eq 580 ]]; then
|
||||
if [[ $((10#${ver_minor})) -gt 82 ]]; then
|
||||
return 0
|
||||
elif [[ $((10#${ver_minor})) -eq 82 ]]; then
|
||||
if [[ $((10#${ver_patch:-0})) -ge 7 ]]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
if [[ ${ver_major} -ge ${MIN_DRIVER_VERSION} ]]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
version_le() {
|
||||
local v1="$1"
|
||||
local v2="$2"
|
||||
|
||||
IFS='.' read -r a1 b1 c1 <<<"$v1"
|
||||
IFS='.' read -r a2 b2 c2 <<<"$v2"
|
||||
|
||||
a1=${a1:-0}; b1=${b1:-0}; c1=${c1:-0}
|
||||
a2=${a2:-0}; b2=${b2:-0}; c2=${c2:-0}
|
||||
|
||||
a1=$((10#$a1)); b1=$((10#$b1)); c1=$((10#$c1))
|
||||
a2=$((10#$a2)); b2=$((10#$b2)); c2=$((10#$c2))
|
||||
|
||||
if (( a1 < a2 )); then
|
||||
return 0
|
||||
elif (( a1 > a2 )); then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if (( b1 < b2 )); then
|
||||
return 0
|
||||
elif (( b1 > b2 )); then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if (( c1 <= c2 )); then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# NVIDIA version management - FIXED VERSION
|
||||
# ==========================================================
|
||||
download_latest_version() {
|
||||
local latest_line version
|
||||
|
||||
latest_line=$(curl -fsSL "${NVIDIA_BASE_URL}/latest.txt" 2>&1)
|
||||
if [[ -z "$latest_line" ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
version=$(echo "$latest_line" | awk '{print $1}' | tr -d '[:space:]')
|
||||
|
||||
if [[ -z "$version" ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$version"
|
||||
return 0
|
||||
}
|
||||
|
||||
list_available_versions() {
|
||||
local html_content versions
|
||||
|
||||
html_content=$(curl -s "$NVIDIA_BASE_URL/" 2>&1)
|
||||
|
||||
if [[ -z "$html_content" ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
versions=$(echo "$html_content" \
|
||||
| grep -o 'href=[^ >]*' \
|
||||
| awk -F"'" '{print $2}' \
|
||||
| grep -E '^[0-9]' \
|
||||
| sed 's/\/$//' \
|
||||
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
|
||||
| sort -Vr \
|
||||
| uniq)
|
||||
|
||||
if [[ -z "$versions" ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$versions"
|
||||
return 0
|
||||
}
|
||||
|
||||
verify_version_exists() {
|
||||
local version="$1"
|
||||
local url="${NVIDIA_BASE_URL}/${version}/"
|
||||
|
||||
if curl -fsSL --head "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
download_nvidia_installer() {
|
||||
ensure_workdir
|
||||
local version="$1"
|
||||
|
||||
version=$(echo "$version" | tr -d '[:space:]' | tr -d '\n' | tr -d '\r')
|
||||
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
|
||||
msg_error "Invalid version format: $version" >&2
|
||||
echo "ERROR: Invalid version format: '$version'" >> "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local run_file="$NVIDIA_WORKDIR/NVIDIA-Linux-x86_64-${version}.run"
|
||||
|
||||
if [[ -f "$run_file" ]]; then
|
||||
echo "Found existing file: $run_file" >> "$LOG_FILE"
|
||||
local existing_size file_type
|
||||
existing_size=$(stat -c%s "$run_file" 2>/dev/null || stat -f%z "$run_file" 2>/dev/null || echo "0")
|
||||
file_type=$(file "$run_file" 2>/dev/null || echo "unknown")
|
||||
|
||||
echo "Existing file size: $existing_size bytes" >> "$LOG_FILE"
|
||||
echo "Existing file type: $file_type" >> "$LOG_FILE"
|
||||
|
||||
|
||||
if [[ $existing_size -gt 40000000 ]] && echo "$file_type" | grep -q "executable"; then
|
||||
|
||||
if sh "$run_file" --check 2>&1 | tee -a "$LOG_FILE" | grep -q "OK"; then
|
||||
echo "Existing file passed integrity check" >> "$LOG_FILE"
|
||||
msg_ok "$(translate 'Installer already downloaded and verified.')" >&2
|
||||
printf '%s\n' "$run_file"
|
||||
return 0
|
||||
else
|
||||
echo "Existing file FAILED integrity check, removing..." >> "$LOG_FILE"
|
||||
msg_warn "$(translate 'Existing file failed verification, re-downloading...')" >&2
|
||||
rm -f "$run_file"
|
||||
fi
|
||||
else
|
||||
echo "Existing file invalid (size or type), removing..." >> "$LOG_FILE"
|
||||
msg_warn "$(translate 'Removing invalid existing file...')" >&2
|
||||
rm -f "$run_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! verify_version_exists "$version"; then
|
||||
msg_error "Version $version does not exist on NVIDIA servers" >&2
|
||||
echo "ERROR: Version $version not found on server" >> "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local urls=(
|
||||
"${NVIDIA_BASE_URL}/${version}/NVIDIA-Linux-x86_64-${version}.run"
|
||||
"${NVIDIA_BASE_URL}/${version}/NVIDIA-Linux-x86_64-${version}-no-compat32.run"
|
||||
)
|
||||
|
||||
local success=false
|
||||
local url_index=0
|
||||
|
||||
for url in "${urls[@]}"; do
|
||||
((url_index++))
|
||||
echo "Attempting download from: $url" >> "$LOG_FILE"
|
||||
|
||||
|
||||
rm -f "$run_file"
|
||||
|
||||
|
||||
if curl -fL --connect-timeout 30 --max-time 600 "$url" -o "$run_file" >> "$LOG_FILE" 2>&1; then
|
||||
echo "Download completed, verifying file..." >> "$LOG_FILE"
|
||||
|
||||
|
||||
if [[ ! -f "$run_file" ]]; then
|
||||
echo "ERROR: File not created after download" >> "$LOG_FILE"
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
local file_size
|
||||
file_size=$(stat -c%s "$run_file" 2>/dev/null || stat -f%z "$run_file" 2>/dev/null || echo "0")
|
||||
echo "Downloaded file size: $file_size bytes" >> "$LOG_FILE"
|
||||
|
||||
if [[ $file_size -lt 40000000 ]]; then
|
||||
echo "ERROR: File too small ($file_size bytes, expected >40MB)" >> "$LOG_FILE"
|
||||
head -c 200 "$run_file" >> "$LOG_FILE" 2>&1
|
||||
rm -f "$run_file"
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
local file_type
|
||||
file_type=$(file "$run_file" 2>/dev/null)
|
||||
echo "File type: $file_type" >> "$LOG_FILE"
|
||||
|
||||
if echo "$file_type" | grep -q "executable"; then
|
||||
echo "SUCCESS: Valid executable downloaded" >> "$LOG_FILE"
|
||||
success=true
|
||||
break
|
||||
else
|
||||
echo "ERROR: Not a valid executable" >> "$LOG_FILE"
|
||||
head -c 200 "$run_file" | od -c >> "$LOG_FILE" 2>&1
|
||||
rm -f "$run_file"
|
||||
fi
|
||||
else
|
||||
echo "ERROR: curl failed for $url (exit code: $?)" >> "$LOG_FILE"
|
||||
rm -f "$run_file"
|
||||
fi
|
||||
done
|
||||
|
||||
if ! $success; then
|
||||
msg_error "$(translate 'Download failed for all attempted URLs')" >&2
|
||||
msg_error "Version $version may not be available for your architecture" >&2
|
||||
echo "ERROR: All download attempts failed" >> "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
chmod +x "$run_file"
|
||||
echo "Installation file ready: $run_file" >> "$LOG_FILE"
|
||||
printf '%s\n' "$run_file"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Installation / uninstallation
|
||||
# ==========================================================
|
||||
run_nvidia_installer() {
|
||||
local installer="$1"
|
||||
|
||||
msg_info2 "$(translate 'Starting NVIDIA installer. This may take several minutes...')"
|
||||
echo "" >>"$LOG_FILE"
|
||||
echo "=== Running NVIDIA installer: $installer ===" >>"$LOG_FILE"
|
||||
|
||||
local tmp_extract_dir="$NVIDIA_WORKDIR/tmp_extract"
|
||||
mkdir -p "$tmp_extract_dir"
|
||||
|
||||
sh "$installer" --tmpdir="$tmp_extract_dir" --no-questions --ui=none --disable-nouveau --dkms 2>&1 | tee -a "$LOG_FILE"
|
||||
local rc=${PIPESTATUS[0]}
|
||||
echo "" >>"$LOG_FILE"
|
||||
|
||||
rm -rf "$tmp_extract_dir"
|
||||
|
||||
if [[ $rc -ne 0 ]]; then
|
||||
msg_error "$(translate 'NVIDIA installer reported an error. Check /tmp/nvidia_install.log')"
|
||||
update_component_status "nvidia_driver" "failed" "" "gpu" '{"patched":false}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'NVIDIA driver installed successfully.')" | tee -a "$screen_capture"
|
||||
return 0
|
||||
}
|
||||
|
||||
remove_nvidia_driver() {
|
||||
complete_nvidia_uninstall
|
||||
}
|
||||
|
||||
install_udev_rules_and_persistenced() {
|
||||
msg_info "$(translate 'Installing NVIDIA udev rules and persistence service...')"
|
||||
|
||||
cat >/etc/udev/rules.d/70-nvidia.rules <<'EOF'
|
||||
# /etc/udev/rules.d/70-nvidia.rules
|
||||
KERNEL=="nvidia", RUN+="/bin/bash -c '/usr/bin/nvidia-smi -L'"
|
||||
KERNEL=="nvidia_uvm", RUN+="/bin/bash -c '/usr/bin/nvidia-modprobe -c0 -u'"
|
||||
EOF
|
||||
|
||||
udevadm control --reload-rules
|
||||
udevadm trigger --subsystem-match=drm --subsystem-match=pci || true
|
||||
|
||||
ensure_workdir
|
||||
cd "$NVIDIA_WORKDIR" || return 1
|
||||
if [[ ! -d nvidia-persistenced ]]; then
|
||||
git clone https://github.com/NVIDIA/nvidia-persistenced.git >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
|
||||
if [[ -d nvidia-persistenced/init ]]; then
|
||||
cd nvidia-persistenced/init || return 1
|
||||
./install.sh >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'NVIDIA udev rules and persistence service installed.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
apply_nvidia_patch_if_needed() {
|
||||
if ! hybrid_whiptail_yesno "$(translate 'NVIDIA Patch')" \
|
||||
"\n$(translate 'Do you want to apply the optional NVIDIA patch to remove some GPU limitations?')"; then
|
||||
msg_info2 "$(translate 'NVIDIA patch not applied.')"
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
msg_info "$(translate 'Cloning and applying NVIDIA patch (keylase/nvidia-patch)...')"
|
||||
ensure_workdir
|
||||
cd "$NVIDIA_WORKDIR" || return 1
|
||||
if [[ ! -d nvidia-patch ]]; then
|
||||
git clone https://github.com/keylase/nvidia-patch.git >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
|
||||
if [[ -x nvidia-patch/patch.sh ]]; then
|
||||
cd nvidia-patch || return 1
|
||||
./patch.sh >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'NVIDIA patch applied - check README for supported versions.')"
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":true}'
|
||||
else
|
||||
msg_warn "$(translate 'Could not run NVIDIA patch script. Please verify repository and driver version.')"
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
|
||||
fi
|
||||
}
|
||||
|
||||
restart_prompt() {
|
||||
if hybrid_whiptail_yesno "$(translate 'NVIDIA Drivers')" \
|
||||
"\n$(translate 'The installation/changes require a server restart to apply correctly. Do you want to reboot now?')"; then
|
||||
msg_success "$(translate 'Installation completed. Press Enter to continue...')"
|
||||
read -r
|
||||
msg_warn "$(translate 'Restarting the server...')"
|
||||
rm -f "$screen_capture"
|
||||
reboot
|
||||
else
|
||||
msg_success "$(translate 'Installation completed. Please reboot the server manually as soon as possible.')"
|
||||
msg_success "$(translate 'Completed. Press Enter to return to menu...')"
|
||||
read -r
|
||||
rm -f "$screen_capture"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Dialog menus
|
||||
# ==========================================================
|
||||
show_action_menu_if_installed() {
|
||||
if ! $CURRENT_DRIVER_INSTALLED; then
|
||||
ACTION="install"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local menu_choices=(
|
||||
"install" "$(translate 'Reinstall/Update NVIDIA drivers')"
|
||||
"remove" "$(translate 'Uninstall NVIDIA drivers and configuration')"
|
||||
)
|
||||
|
||||
ACTION=$(hybrid_menu "ProxMenux" "$(translate 'NVIDIA Actions')\n\n$(translate 'Choose an action:')" 14 80 8 "${menu_choices[@]}") || ACTION="cancel"
|
||||
}
|
||||
|
||||
show_install_overview() {
|
||||
local overview
|
||||
overview="\n$(translate 'This installation will:')\n\n"
|
||||
overview+=" • $(translate 'Install NVIDIA proprietary drivers')\n"
|
||||
overview+=" • $(translate 'Configure GPU passthrough with VFIO')\n"
|
||||
overview+=" • $(translate 'Blacklist nouveau driver')\n"
|
||||
overview+=" • $(translate 'Enable IOMMU support if not enabled')\n\n"
|
||||
|
||||
overview+="$(translate 'Detected GPU(s):')\n"
|
||||
overview+="\Zb\Z4$DETECTED_GPUS_TEXT\Zn\n"
|
||||
|
||||
overview+="\n\Zn$(translate 'Current status: ') "
|
||||
overview+="\Zb${CURRENT_STATUS_TEXT}\Zn\n\n"
|
||||
|
||||
overview+="$(translate 'After confirming, you will be asked to choose the NVIDIA driver version to install.')\n\n"
|
||||
overview+="$(translate 'Do you want to continue?')"
|
||||
|
||||
hybrid_yesno "$(translate 'NVIDIA GPU Driver Installation')" "$overview" 22 90
|
||||
}
|
||||
|
||||
show_version_menu() {
|
||||
local latest versions_list
|
||||
local kernel_version
|
||||
kernel_version=$(uname -r)
|
||||
|
||||
|
||||
latest=$(download_latest_version 2>/dev/null)
|
||||
|
||||
|
||||
versions_list=$(list_available_versions 2>/dev/null)
|
||||
|
||||
|
||||
if [[ -z "$latest" ]] && [[ -z "$versions_list" ]]; then
|
||||
hybrid_msgbox "$(translate 'Error')" \
|
||||
"$(translate 'Could not retrieve versions list from NVIDIA. Please check your internet connection.')\n\nURL: ${NVIDIA_BASE_URL}" 10 80
|
||||
DRIVER_VERSION="cancel"
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
if [[ -z "$latest" ]] && [[ -n "$versions_list" ]]; then
|
||||
latest=$(echo "$versions_list" | head -n1)
|
||||
fi
|
||||
|
||||
|
||||
if [[ -n "$latest" ]] && [[ -z "$versions_list" ]]; then
|
||||
versions_list="$latest"
|
||||
fi
|
||||
|
||||
# Clean latest version
|
||||
latest=$(echo "$latest" | tr -d '[:space:]')
|
||||
|
||||
local current_list="$versions_list"
|
||||
|
||||
# Apply kernel compatibility filter if needed
|
||||
if [[ -n "$MIN_DRIVER_VERSION" ]]; then
|
||||
local filtered_list=""
|
||||
while IFS= read -r ver; do
|
||||
[[ -z "$ver" ]] && continue
|
||||
if is_version_compatible "$ver"; then
|
||||
filtered_list+="$ver"$'\n'
|
||||
fi
|
||||
done <<< "$current_list"
|
||||
current_list="$filtered_list"
|
||||
fi
|
||||
|
||||
if [[ -n "$latest" ]]; then
|
||||
local filtered_max_list=""
|
||||
while IFS= read -r ver; do
|
||||
[[ -z "$ver" ]] && continue
|
||||
if version_le "$ver" "$latest"; then
|
||||
filtered_max_list+="$ver"$'\n'
|
||||
fi
|
||||
done <<< "$current_list"
|
||||
current_list="$filtered_max_list"
|
||||
fi
|
||||
|
||||
local menu_text="$(translate 'Select the NVIDIA driver version to install:')\n\n"
|
||||
menu_text+="$(translate 'Versions shown are compatible with your kernel. Latest available is recommended in most cases.')"
|
||||
|
||||
local choices=()
|
||||
choices+=("latest" "$(translate 'Latest available') (${latest:-unknown})")
|
||||
choices+=("" "")
|
||||
|
||||
if [[ -n "$current_list" ]]; then
|
||||
while IFS= read -r ver; do
|
||||
[[ -z "$ver" ]] && continue
|
||||
ver=$(echo "$ver" | tr -d '[:space:]')
|
||||
[[ -z "$ver" ]] && continue
|
||||
|
||||
choices+=("$ver" "$ver")
|
||||
done <<< "$current_list"
|
||||
else
|
||||
choices+=("" "$(translate 'No compatible versions found for your kernel')")
|
||||
fi
|
||||
|
||||
local selection=$(hybrid_menu "$(translate 'NVIDIA Driver Version')" "$menu_text" 26 90 16 "${choices[@]}") || { DRIVER_VERSION="cancel"; return 1; }
|
||||
|
||||
case "$selection" in
|
||||
"")
|
||||
DRIVER_VERSION="cancel"
|
||||
return 1
|
||||
;;
|
||||
latest)
|
||||
DRIVER_VERSION="$latest"
|
||||
DRIVER_VERSION=$(echo "$DRIVER_VERSION" | tr -d '[:space:]')
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
DRIVER_VERSION="$selection"
|
||||
DRIVER_VERSION=$(echo "$DRIVER_VERSION" | tr -d '[:space:]')
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Main flow
|
||||
# ==========================================================
|
||||
main() {
|
||||
: >"$LOG_FILE"
|
||||
: >"$screen_capture"
|
||||
|
||||
detect_nvidia_gpus
|
||||
detect_driver_status
|
||||
|
||||
if ! $NVIDIA_GPU_PRESENT; then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate 'NVIDIA GPU Driver Installation')" --msgbox \
|
||||
"\n$(translate 'No NVIDIA GPU has been detected on this system. The installer will now exit.')" 20 70
|
||||
exit 1
|
||||
fi
|
||||
|
||||
show_action_menu_if_installed
|
||||
|
||||
case "$ACTION" in
|
||||
install)
|
||||
if ! show_install_overview; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
get_kernel_compatibility_info
|
||||
|
||||
show_version_menu
|
||||
if [[ "$DRIVER_VERSION" == "cancel" || -z "$DRIVER_VERSION" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if $CURRENT_DRIVER_INSTALLED; then
|
||||
if [[ "$CURRENT_DRIVER_VERSION" == "$DRIVER_VERSION" ]]; then
|
||||
local confirm_text
|
||||
confirm_text="\n\n\n$(translate 'Version') \Zb\Z4$DRIVER_VERSION\Zn\n\n$(translate 'is already installed. Do you want to reinstall it? This will perform a clean uninstall first.')"
|
||||
if ! hybrid_yesno "$(translate 'Same Version Detected')" "$confirm_text" 14 70; then
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
local confirm_text
|
||||
confirm_text="\n\n$(translate 'Current version:') \Zb$CURRENT_DRIVER_VERSION\Zn\n"
|
||||
confirm_text+="$(translate 'New version:') \Zb\Z4$DRIVER_VERSION\Zn\n\n"
|
||||
confirm_text+="$(translate 'The current driver will be completely uninstalled before installing the new version. Continue?')"
|
||||
if ! hybrid_yesno "$(translate 'Version Change Detected')" "$confirm_text" 20 70; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
msg_info2 "$(translate 'Uninstalling current NVIDIA driver before installing new version...')"
|
||||
complete_nvidia_uninstall
|
||||
|
||||
sleep 2
|
||||
|
||||
CURRENT_DRIVER_INSTALLED=false
|
||||
CURRENT_DRIVER_VERSION=""
|
||||
fi
|
||||
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
|
||||
ensure_repos_and_headers
|
||||
blacklist_nouveau
|
||||
ensure_modules_config
|
||||
|
||||
stop_and_disable_nvidia_services
|
||||
unload_nvidia_modules
|
||||
|
||||
msg_info "$(translate 'Downloading NVIDIA driver version:') $DRIVER_VERSION"
|
||||
|
||||
local installer
|
||||
installer=$(download_nvidia_installer "$DRIVER_VERSION" 2>>"$LOG_FILE")
|
||||
local download_result=$?
|
||||
|
||||
if [[ $download_result -ne 0 ]]; then
|
||||
msg_error "$(translate 'Failed to download NVIDIA installer')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'NVIDIA installer downloaded successfully')"
|
||||
|
||||
if [[ -z "$installer" || ! -f "$installer" ]]; then
|
||||
msg_error "$(translate 'Internal error: NVIDIA installer path is empty or file not found.')"
|
||||
rm -f "$screen_capture"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! run_nvidia_installer "$installer"; then
|
||||
rm -f "$screen_capture"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
cat "$screen_capture"
|
||||
echo -e "${TAB}${GN}📄 $(translate "Log file")${CL}: ${BL}$LOG_FILE${CL}"
|
||||
|
||||
install_udev_rules_and_persistenced
|
||||
|
||||
msg_info "$(translate 'Updating initramfs for all kernels...')"
|
||||
update-initramfs -u -k all >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'initramfs updated.')"
|
||||
|
||||
msg_info2 "$(translate 'Checking NVIDIA driver status with nvidia-smi')"
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
nvidia-smi || true
|
||||
CURRENT_DRIVER_VERSION=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -n1)
|
||||
CURRENT_DRIVER_INSTALLED=true
|
||||
else
|
||||
msg_warn "$(translate 'nvidia-smi not found in PATH. Please verify the driver installation.')"
|
||||
fi
|
||||
|
||||
if [[ -n "$CURRENT_DRIVER_VERSION" ]]; then
|
||||
msg_ok "$(translate 'NVIDIA driver') $CURRENT_DRIVER_VERSION $(translate 'installed successfully.')"
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
|
||||
msg_success "$(translate 'Driver installed successfully. Press Enter to continue...')"
|
||||
read -r
|
||||
else
|
||||
msg_error "$(translate 'Failed to detect installed NVIDIA driver version.')"
|
||||
update_component_status "nvidia_driver" "failed" "" "gpu" '{"patched":false}'
|
||||
fi
|
||||
|
||||
apply_nvidia_patch_if_needed
|
||||
restart_prompt
|
||||
;;
|
||||
remove)
|
||||
if hybrid_yesno "$(translate 'NVIDIA Driver Uninstall')" \
|
||||
"\n\n\n$(translate 'This will remove NVIDIA drivers and related configuration. Do you want to continue?')" 14 70; then
|
||||
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
|
||||
remove_nvidia_driver
|
||||
|
||||
msg_info "$(translate 'Updating initramfs for all kernels...')"
|
||||
update-initramfs -u -k all >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'initramfs updated.')"
|
||||
|
||||
restart_prompt
|
||||
fi
|
||||
;;
|
||||
cancel|*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
main
|
||||
fi
|
||||
@@ -0,0 +1,916 @@
|
||||
#!/bin/bash
|
||||
# ProxMenux - NVIDIA Driver Installer (PVE 9.x)
|
||||
# ============================================
|
||||
# Author : MacRimi
|
||||
# License : MIT
|
||||
# Version : 0.9 (PVE9, fixed download issues)
|
||||
# Last Updated: 29/11/2025
|
||||
# ============================================
|
||||
|
||||
SCRIPT_TITLE="NVIDIA GPU Driver Installer for Proxmox VE"
|
||||
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
COMPONENTS_STATUS_FILE="$BASE_DIR/components_status.json"
|
||||
LOG_FILE="/tmp/nvidia_install.log"
|
||||
screen_capture="/tmp/proxmenux_nvidia_screen_capture_$$.txt"
|
||||
|
||||
NVIDIA_BASE_URL="https://download.nvidia.com/XFree86/Linux-x86_64"
|
||||
NVIDIA_WORKDIR="/opt/nvidia"
|
||||
|
||||
export BASE_DIR
|
||||
export COMPONENTS_STATUS_FILE
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$COMPONENTS_STATUS_FILE" ]]; then
|
||||
echo "{}" > "$COMPONENTS_STATUS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
# ==========================================================
|
||||
# GPU detection and current status
|
||||
# ==========================================================
|
||||
detect_nvidia_gpus() {
|
||||
# Only video controllers (not audio)
|
||||
local lspci_output
|
||||
lspci_output=$(lspci | grep -i "NVIDIA" \
|
||||
| grep -Ei "VGA compatible controller|3D controller|Display controller" || true)
|
||||
|
||||
if [[ -z "$lspci_output" ]]; then
|
||||
NVIDIA_GPU_PRESENT=false
|
||||
DETECTED_GPUS_TEXT="$(translate 'No NVIDIA GPU detected on this system.')"
|
||||
else
|
||||
NVIDIA_GPU_PRESENT=true
|
||||
DETECTED_GPUS_TEXT=""
|
||||
local i=1
|
||||
while IFS= read -r line; do
|
||||
DETECTED_GPUS_TEXT+=" ${i}. ${line}\n"
|
||||
((i++))
|
||||
done <<< "$lspci_output"
|
||||
fi
|
||||
}
|
||||
|
||||
detect_driver_status() {
|
||||
CURRENT_DRIVER_INSTALLED=false
|
||||
CURRENT_DRIVER_VERSION=""
|
||||
|
||||
# First check if nvidia kernel module is actually loaded
|
||||
if lsmod | grep -q "^nvidia "; then
|
||||
|
||||
modprobe nvidia-uvm 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
CURRENT_DRIVER_VERSION=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -n1)
|
||||
|
||||
if [[ -n "$CURRENT_DRIVER_VERSION" ]]; then
|
||||
CURRENT_DRIVER_INSTALLED=true
|
||||
# Register the installed driver version in components_status.json
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if $CURRENT_DRIVER_INSTALLED; then
|
||||
CURRENT_STATUS_TEXT="$(printf '%s %s' "$(translate 'NVIDIA driver installed:')" "$CURRENT_DRIVER_VERSION")"
|
||||
else
|
||||
CURRENT_STATUS_TEXT="$(translate 'No NVIDIA driver installed.')"
|
||||
fi
|
||||
|
||||
if $CURRENT_DRIVER_INSTALLED; then
|
||||
CURRENT_STATUS_COLORED="\Z2${CURRENT_STATUS_TEXT}\Zn"
|
||||
else
|
||||
CURRENT_STATUS_COLORED="\Z3${CURRENT_STATUS_TEXT}\Zn"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# System preparation (repos, headers, etc.)
|
||||
# ==========================================================
|
||||
ensure_repos_and_headers() {
|
||||
msg_info "$(translate 'Checking kernel headers and build tools...')"
|
||||
|
||||
local kver
|
||||
kver=$(uname -r)
|
||||
|
||||
apt-get update -qq >>"$LOG_FILE" 2>&1
|
||||
|
||||
if ! dpkg -s "pve-headers-$kver" >/dev/null 2>&1 && \
|
||||
! dpkg -s "proxmox-headers-$kver" >/dev/null 2>&1; then
|
||||
apt-get install -y "pve-headers-$kver" "proxmox-headers-$kver" build-essential dkms >>"$LOG_FILE" 2>&1 || true
|
||||
else
|
||||
apt-get install -y build-essential dkms >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'Kernel headers and build tools verified.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
blacklist_nouveau() {
|
||||
msg_info "$(translate 'Blacklisting nouveau driver...')"
|
||||
if ! grep -q '^blacklist nouveau' /etc/modprobe.d/blacklist.conf 2>/dev/null; then
|
||||
echo "blacklist nouveau" >> /etc/modprobe.d/blacklist.conf
|
||||
fi
|
||||
msg_ok "$(translate 'nouveau driver has been blacklisted.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
ensure_modules_config() {
|
||||
msg_info "$(translate 'Configuring NVIDIA and VFIO modules...')"
|
||||
cat > /etc/modules-load.d/nvidia-vfio.conf <<'EOF'
|
||||
vfio
|
||||
vfio_iommu_type1
|
||||
vfio_pci
|
||||
vfio_virqfd
|
||||
nvidia
|
||||
nvidia_uvm
|
||||
EOF
|
||||
msg_ok "$(translate 'Modules configuration updated.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
stop_and_disable_nvidia_services() {
|
||||
local services=(
|
||||
"nvidia-persistenced.service"
|
||||
"nvidia-persistenced"
|
||||
"nvidia-powerd.service"
|
||||
)
|
||||
|
||||
local services_detected=0
|
||||
|
||||
for service in "${services[@]}"; do
|
||||
if systemctl is-active --quiet "$service" 2>/dev/null || \
|
||||
systemctl is-enabled --quiet "$service" 2>/dev/null; then
|
||||
services_detected=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$services_detected" -eq 1 ]; then
|
||||
msg_info "$(translate 'Stopping and disabling NVIDIA services...')"
|
||||
|
||||
for service in "${services[@]}"; do
|
||||
if systemctl is-active --quiet "$service" 2>/dev/null; then
|
||||
systemctl stop "$service" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if systemctl is-enabled --quiet "$service" 2>/dev/null; then
|
||||
systemctl disable "$service" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
|
||||
sleep 2
|
||||
|
||||
msg_ok "$(translate 'NVIDIA services stopped and disabled.')" | tee -a "$screen_capture"
|
||||
fi
|
||||
}
|
||||
|
||||
unload_nvidia_modules() {
|
||||
msg_info "$(translate 'Unloading NVIDIA kernel modules...')"
|
||||
|
||||
for mod in nvidia_uvm nvidia_drm nvidia_modeset nvidia; do
|
||||
modprobe -r "$mod" >/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
|
||||
if lsmod | grep -qi '\bnvidia'; then
|
||||
for mod in nvidia_uvm nvidia_drm nvidia_modeset nvidia; do
|
||||
modprobe -r --force "$mod" >/dev/null 2>&1 || true
|
||||
done
|
||||
fi
|
||||
|
||||
if lsmod | grep -qi '\bnvidia'; then
|
||||
msg_warn "$(translate 'Some NVIDIA modules could not be unloaded. Installation may fail. Ensure no processes are using the GPU.')"
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
echo "$(translate 'Processes using NVIDIA:'):" >> "$LOG_FILE"
|
||||
lsof /dev/nvidia* 2>/dev/null >> "$LOG_FILE" || true
|
||||
fi
|
||||
else
|
||||
msg_ok "$(translate 'NVIDIA kernel modules unloaded successfully.')" | tee -a "$screen_capture"
|
||||
fi
|
||||
}
|
||||
|
||||
complete_nvidia_uninstall() {
|
||||
stop_and_disable_nvidia_services
|
||||
unload_nvidia_modules
|
||||
|
||||
if command -v nvidia-uninstall >/dev/null 2>&1; then
|
||||
msg_info "$(translate 'Running NVIDIA uninstaller...')"
|
||||
nvidia-uninstall --silent >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'NVIDIA uninstaller completed.')"
|
||||
fi
|
||||
|
||||
cleanup_nvidia_dkms
|
||||
|
||||
msg_info "$(translate 'Removing NVIDIA packages...')"
|
||||
apt-get -y purge 'nvidia-*' 'libnvidia-*' 'cuda-*' 'libcudnn*' >>"$LOG_FILE" 2>&1 || true
|
||||
apt-get -y autoremove --purge >>"$LOG_FILE" 2>&1 || true
|
||||
apt-get -y autoclean >>"$LOG_FILE" 2>&1 || true
|
||||
|
||||
rm -f /etc/modules-load.d/nvidia-vfio.conf
|
||||
rm -f /etc/udev/rules.d/70-nvidia.rules
|
||||
rm -rf /usr/lib/modprobe.d/nvidia*.conf
|
||||
rm -rf /etc/modprobe.d/nvidia*.conf
|
||||
|
||||
if [[ -d "$NVIDIA_WORKDIR" ]]; then
|
||||
find "$NVIDIA_WORKDIR" -type d -name "nvidia-persistenced" -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$NVIDIA_WORKDIR" -type d -name "nvidia-patch" -exec rm -rf {} + 2>/dev/null || true
|
||||
fi
|
||||
|
||||
update_component_status "nvidia_driver" "removed" "" "gpu" '{}'
|
||||
|
||||
msg_ok "$(translate 'Complete NVIDIA uninstallation finished.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
cleanup_nvidia_dkms() {
|
||||
local versions
|
||||
versions=$(dkms status 2>/dev/null | awk -F, '/nvidia/ {gsub(/ /,"",$2); print $2}' || true)
|
||||
|
||||
[[ -z "$versions" ]] && return 0
|
||||
|
||||
msg_info "$(translate 'Removing NVIDIA DKMS entries...')"
|
||||
while IFS= read -r ver; do
|
||||
[[ -z "$ver" ]] && continue
|
||||
dkms remove -m nvidia -v "$ver" --all >/dev/null 2>&1 || true
|
||||
done <<< "$versions"
|
||||
msg_ok "$(translate 'NVIDIA DKMS entries removed.')"
|
||||
}
|
||||
|
||||
ensure_workdir() {
|
||||
mkdir -p "$NVIDIA_WORKDIR"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Kernel compatibility detection
|
||||
# ==========================================================
|
||||
get_kernel_compatibility_info() {
|
||||
local kernel_version
|
||||
kernel_version=$(uname -r)
|
||||
|
||||
# Determine Proxmox and kernel version
|
||||
if [[ -f /etc/pve/.version ]]; then
|
||||
PVE_VERSION=$(cat /etc/pve/.version)
|
||||
else
|
||||
PVE_VERSION="unknown"
|
||||
fi
|
||||
|
||||
# Extract kernel major version (6.x, 5.x, etc)
|
||||
KERNEL_MAJOR=$(echo "$kernel_version" | cut -d. -f1)
|
||||
KERNEL_MINOR=$(echo "$kernel_version" | cut -d. -f2)
|
||||
|
||||
# Define minimum compatible versions based on kernel
|
||||
# Based on https://docs.nvidia.com/datacenter/tesla/drivers/index.html
|
||||
if [[ "$KERNEL_MAJOR" -ge 6 ]] && [[ "$KERNEL_MINOR" -ge 17 ]]; then
|
||||
# Kernel 6.17+ (Proxmox 9.x) - Requires 580.82.07 or higher
|
||||
MIN_DRIVER_VERSION="580.82.07"
|
||||
RECOMMENDED_BRANCH="580"
|
||||
COMPATIBILITY_NOTE="Kernel $kernel_version requires NVIDIA driver 580.82.07 or newer"
|
||||
elif [[ "$KERNEL_MAJOR" -ge 6 ]] && [[ "$KERNEL_MINOR" -ge 8 ]]; then
|
||||
# Kernel 6.8-6.16 (Proxmox 8.2+) - Works with 550.x or higher
|
||||
MIN_DRIVER_VERSION="550"
|
||||
RECOMMENDED_BRANCH="580"
|
||||
COMPATIBILITY_NOTE="Kernel $kernel_version works best with NVIDIA driver 550.x or newer"
|
||||
elif [[ "$KERNEL_MAJOR" -ge 6 ]]; then
|
||||
# Kernel 6.2-6.7 (Proxmox 8.x initial) - Works with 535.x or higher
|
||||
MIN_DRIVER_VERSION="535"
|
||||
RECOMMENDED_BRANCH="550"
|
||||
COMPATIBILITY_NOTE="Kernel $kernel_version works with NVIDIA driver 535.x or newer"
|
||||
elif [[ "$KERNEL_MAJOR" -eq 5 ]] && [[ "$KERNEL_MINOR" -ge 15 ]]; then
|
||||
# Kernel 5.15+ (Proxmox 7.x, 8.x legacy) - Works with 470.x or higher
|
||||
MIN_DRIVER_VERSION="470"
|
||||
RECOMMENDED_BRANCH="535"
|
||||
COMPATIBILITY_NOTE="Kernel $kernel_version works with NVIDIA driver 470.x or newer"
|
||||
else
|
||||
# Old kernels
|
||||
MIN_DRIVER_VERSION="450"
|
||||
RECOMMENDED_BRANCH="470"
|
||||
COMPATIBILITY_NOTE="For older kernels, compatibility may vary"
|
||||
fi
|
||||
}
|
||||
|
||||
is_version_compatible() {
|
||||
local version="$1"
|
||||
local ver_major ver_minor ver_patch
|
||||
|
||||
# Extract version components (major.minor.patch)
|
||||
ver_major=$(echo "$version" | cut -d. -f1)
|
||||
ver_minor=$(echo "$version" | cut -d. -f2)
|
||||
ver_patch=$(echo "$version" | cut -d. -f3)
|
||||
|
||||
if [[ "$MIN_DRIVER_VERSION" == "580.82.07" ]]; then
|
||||
# Compare full version: must be >= 580.82.07
|
||||
if [[ ${ver_major} -gt 580 ]]; then
|
||||
return 0
|
||||
elif [[ ${ver_major} -eq 580 ]]; then
|
||||
if [[ $((10#${ver_minor})) -gt 82 ]]; then
|
||||
return 0
|
||||
elif [[ $((10#${ver_minor})) -eq 82 ]]; then
|
||||
if [[ $((10#${ver_patch:-0})) -ge 7 ]]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
if [[ ${ver_major} -ge ${MIN_DRIVER_VERSION} ]]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# NVIDIA version management - FIXED VERSION
|
||||
# ==========================================================
|
||||
download_latest_version() {
|
||||
local latest_line version
|
||||
|
||||
latest_line=$(curl -fsSL "${NVIDIA_BASE_URL}/latest.txt" 2>&1)
|
||||
if [[ -z "$latest_line" ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
version=$(echo "$latest_line" | awk '{print $1}' | tr -d '[:space:]')
|
||||
|
||||
if [[ -z "$version" ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$version"
|
||||
return 0
|
||||
}
|
||||
|
||||
list_available_versions() {
|
||||
local html_content versions
|
||||
|
||||
html_content=$(curl -s "$NVIDIA_BASE_URL/" 2>&1)
|
||||
|
||||
if [[ -z "$html_content" ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
versions=$(echo "$html_content" \
|
||||
| grep -o 'href=[^ >]*' \
|
||||
| awk -F"'" '{print $2}' \
|
||||
| grep -E '^[0-9]' \
|
||||
| sed 's/\/$//' \
|
||||
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
|
||||
| sort -Vr \
|
||||
| uniq)
|
||||
|
||||
if [[ -z "$versions" ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$versions"
|
||||
return 0
|
||||
}
|
||||
|
||||
verify_version_exists() {
|
||||
local version="$1"
|
||||
local url="${NVIDIA_BASE_URL}/${version}/"
|
||||
|
||||
if curl -fsSL --head "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
download_nvidia_installer() {
|
||||
ensure_workdir
|
||||
local version="$1"
|
||||
|
||||
version=$(echo "$version" | tr -d '[:space:]' | tr -d '\n' | tr -d '\r')
|
||||
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
|
||||
msg_error "Invalid version format: $version" >&2
|
||||
echo "ERROR: Invalid version format: '$version'" >> "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local run_file="$NVIDIA_WORKDIR/NVIDIA-Linux-x86_64-${version}.run"
|
||||
|
||||
if [[ -f "$run_file" ]]; then
|
||||
echo "Found existing file: $run_file" >> "$LOG_FILE"
|
||||
local existing_size file_type
|
||||
existing_size=$(stat -c%s "$run_file" 2>/dev/null || stat -f%z "$run_file" 2>/dev/null || echo "0")
|
||||
file_type=$(file "$run_file" 2>/dev/null || echo "unknown")
|
||||
|
||||
echo "Existing file size: $existing_size bytes" >> "$LOG_FILE"
|
||||
echo "Existing file type: $file_type" >> "$LOG_FILE"
|
||||
|
||||
|
||||
if [[ $existing_size -gt 40000000 ]] && echo "$file_type" | grep -q "executable"; then
|
||||
|
||||
if sh "$run_file" --check 2>&1 | tee -a "$LOG_FILE" | grep -q "OK"; then
|
||||
echo "Existing file passed integrity check" >> "$LOG_FILE"
|
||||
msg_ok "$(translate 'Installer already downloaded and verified.')" >&2
|
||||
printf '%s\n' "$run_file"
|
||||
return 0
|
||||
else
|
||||
echo "Existing file FAILED integrity check, removing..." >> "$LOG_FILE"
|
||||
msg_warn "$(translate 'Existing file failed verification, re-downloading...')" >&2
|
||||
rm -f "$run_file"
|
||||
fi
|
||||
else
|
||||
echo "Existing file invalid (size or type), removing..." >> "$LOG_FILE"
|
||||
msg_warn "$(translate 'Removing invalid existing file...')" >&2
|
||||
rm -f "$run_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! verify_version_exists "$version"; then
|
||||
msg_error "Version $version does not exist on NVIDIA servers" >&2
|
||||
echo "ERROR: Version $version not found on server" >> "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local urls=(
|
||||
"${NVIDIA_BASE_URL}/${version}/NVIDIA-Linux-x86_64-${version}.run"
|
||||
"${NVIDIA_BASE_URL}/${version}/NVIDIA-Linux-x86_64-${version}-no-compat32.run"
|
||||
)
|
||||
|
||||
local success=false
|
||||
local url_index=0
|
||||
|
||||
for url in "${urls[@]}"; do
|
||||
((url_index++))
|
||||
echo "Attempting download from: $url" >> "$LOG_FILE"
|
||||
|
||||
|
||||
rm -f "$run_file"
|
||||
|
||||
|
||||
if curl -fL --connect-timeout 30 --max-time 600 "$url" -o "$run_file" >> "$LOG_FILE" 2>&1; then
|
||||
echo "Download completed, verifying file..." >> "$LOG_FILE"
|
||||
|
||||
|
||||
if [[ ! -f "$run_file" ]]; then
|
||||
echo "ERROR: File not created after download" >> "$LOG_FILE"
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
local file_size
|
||||
file_size=$(stat -c%s "$run_file" 2>/dev/null || stat -f%z "$run_file" 2>/dev/null || echo "0")
|
||||
echo "Downloaded file size: $file_size bytes" >> "$LOG_FILE"
|
||||
|
||||
if [[ $file_size -lt 40000000 ]]; then
|
||||
echo "ERROR: File too small ($file_size bytes, expected >40MB)" >> "$LOG_FILE"
|
||||
head -c 200 "$run_file" >> "$LOG_FILE" 2>&1
|
||||
rm -f "$run_file"
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
local file_type
|
||||
file_type=$(file "$run_file" 2>/dev/null)
|
||||
echo "File type: $file_type" >> "$LOG_FILE"
|
||||
|
||||
if echo "$file_type" | grep -q "executable"; then
|
||||
echo "SUCCESS: Valid executable downloaded" >> "$LOG_FILE"
|
||||
success=true
|
||||
break
|
||||
else
|
||||
echo "ERROR: Not a valid executable" >> "$LOG_FILE"
|
||||
head -c 200 "$run_file" | od -c >> "$LOG_FILE" 2>&1
|
||||
rm -f "$run_file"
|
||||
fi
|
||||
else
|
||||
echo "ERROR: curl failed for $url (exit code: $?)" >> "$LOG_FILE"
|
||||
rm -f "$run_file"
|
||||
fi
|
||||
done
|
||||
|
||||
if ! $success; then
|
||||
msg_error "$(translate 'Download failed for all attempted URLs')" >&2
|
||||
msg_error "Version $version may not be available for your architecture" >&2
|
||||
echo "ERROR: All download attempts failed" >> "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
chmod +x "$run_file"
|
||||
echo "Installation file ready: $run_file" >> "$LOG_FILE"
|
||||
printf '%s\n' "$run_file"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Installation / uninstallation
|
||||
# ==========================================================
|
||||
run_nvidia_installer() {
|
||||
local installer="$1"
|
||||
|
||||
msg_info2 "$(translate 'Starting NVIDIA installer. This may take several minutes...')"
|
||||
echo "" >>"$LOG_FILE"
|
||||
echo "=== Running NVIDIA installer: $installer ===" >>"$LOG_FILE"
|
||||
|
||||
local tmp_extract_dir="$NVIDIA_WORKDIR/tmp_extract"
|
||||
mkdir -p "$tmp_extract_dir"
|
||||
|
||||
sh "$installer" --tmpdir="$tmp_extract_dir" --no-questions --ui=none --disable-nouveau --dkms 2>&1 | tee -a "$LOG_FILE"
|
||||
local rc=${PIPESTATUS[0]}
|
||||
echo "" >>"$LOG_FILE"
|
||||
|
||||
rm -rf "$tmp_extract_dir"
|
||||
|
||||
if [[ $rc -ne 0 ]]; then
|
||||
msg_error "$(translate 'NVIDIA installer reported an error. Check /tmp/nvidia_install.log')"
|
||||
update_component_status "nvidia_driver" "failed" "" "gpu" '{"patched":false}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'NVIDIA driver installed successfully.')" | tee -a "$screen_capture"
|
||||
return 0
|
||||
}
|
||||
|
||||
remove_nvidia_driver() {
|
||||
complete_nvidia_uninstall
|
||||
}
|
||||
|
||||
install_udev_rules_and_persistenced() {
|
||||
msg_info "$(translate 'Installing NVIDIA udev rules and persistence service...')"
|
||||
|
||||
cat >/etc/udev/rules.d/70-nvidia.rules <<'EOF'
|
||||
# /etc/udev/rules.d/70-nvidia.rules
|
||||
KERNEL=="nvidia", RUN+="/bin/bash -c '/usr/bin/nvidia-smi -L'"
|
||||
KERNEL=="nvidia_uvm", RUN+="/bin/bash -c '/usr/bin/nvidia-modprobe -c0 -u'"
|
||||
EOF
|
||||
|
||||
udevadm control --reload-rules
|
||||
udevadm trigger --subsystem-match=drm --subsystem-match=pci || true
|
||||
|
||||
ensure_workdir
|
||||
cd "$NVIDIA_WORKDIR" || return 1
|
||||
if [[ ! -d nvidia-persistenced ]]; then
|
||||
git clone https://github.com/NVIDIA/nvidia-persistenced.git >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
|
||||
if [[ -d nvidia-persistenced/init ]]; then
|
||||
cd nvidia-persistenced/init || return 1
|
||||
./install.sh >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'NVIDIA udev rules and persistence service installed.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
apply_nvidia_patch_if_needed() {
|
||||
if ! whiptail --title "$(translate 'NVIDIA Patch')" --yesno \
|
||||
"\n$(translate 'Do you want to apply the optional NVIDIA patch to remove some GPU limitations?')" 10 70; then
|
||||
msg_info2 "$(translate 'NVIDIA patch not applied.')"
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
msg_info "$(translate 'Cloning and applying NVIDIA patch (keylase/nvidia-patch)...')"
|
||||
ensure_workdir
|
||||
cd "$NVIDIA_WORKDIR" || return 1
|
||||
if [[ ! -d nvidia-patch ]]; then
|
||||
git clone https://github.com/keylase/nvidia-patch.git >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
|
||||
if [[ -x nvidia-patch/patch.sh ]]; then
|
||||
cd nvidia-patch || return 1
|
||||
./patch.sh >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'NVIDIA patch applied - check README for supported versions.')"
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":true}'
|
||||
else
|
||||
msg_warn "$(translate 'Could not run NVIDIA patch script. Please verify repository and driver version.')"
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
|
||||
fi
|
||||
}
|
||||
|
||||
restart_prompt() {
|
||||
if whiptail --title "$(translate 'NVIDIA Drivers')" --yesno \
|
||||
"\n$(translate 'The installation/changes require a server restart to apply correctly. Do you want to reboot now?')" 10 70; then
|
||||
msg_success "$(translate 'Installation completed. Press Enter to continue...')"
|
||||
read -r
|
||||
msg_warn "$(translate 'Restarting the server...')"
|
||||
rm -f "$screen_capture"
|
||||
reboot
|
||||
else
|
||||
msg_success "$(translate 'Installation completed. Please reboot the server manually as soon as possible.')"
|
||||
msg_success "$(translate 'Completed. Press Enter to return to menu...')"
|
||||
read -r
|
||||
rm -f "$screen_capture"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Dialog menus
|
||||
# ==========================================================
|
||||
show_action_menu_if_installed() {
|
||||
if ! $CURRENT_DRIVER_INSTALLED; then
|
||||
ACTION="install"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local menu_choices=(
|
||||
"install" "$(translate 'Reinstall/Update NVIDIA drivers')"
|
||||
"remove" "$(translate 'Uninstall NVIDIA drivers and configuration')"
|
||||
)
|
||||
|
||||
ACTION=$(dialog --clear --stdout \
|
||||
--backtitle "ProxMenux" \
|
||||
--title "$(translate 'NVIDIA GPU Driver Management')" \
|
||||
--menu "$(translate 'Choose an action:')" 14 80 8 \
|
||||
"${menu_choices[@]}") || ACTION="cancel"
|
||||
}
|
||||
|
||||
show_install_overview() {
|
||||
local overview
|
||||
overview="\n$(translate 'This installation will:')\n\n"
|
||||
overview+=" • $(translate 'Install NVIDIA proprietary drivers')\n"
|
||||
overview+=" • $(translate 'Configure GPU passthrough with VFIO')\n"
|
||||
overview+=" • $(translate 'Blacklist nouveau driver')\n"
|
||||
overview+=" • $(translate 'Enable IOMMU support if not enabled')\n\n"
|
||||
|
||||
overview+="$(translate 'Detected GPU(s):')\n"
|
||||
overview+="\Zb\Z4$DETECTED_GPUS_TEXT\Zn\n"
|
||||
|
||||
overview+="\n\Zn$(translate 'Current status: ') "
|
||||
overview+="\Zb${CURRENT_STATUS_TEXT}\Zn\n\n"
|
||||
|
||||
overview+="$(translate 'After confirming, you will be asked to choose the NVIDIA driver version to install.')\n\n"
|
||||
overview+="$(translate 'Do you want to continue?')"
|
||||
|
||||
dialog --colors --backtitle "ProxMenux" \
|
||||
--title "$(translate 'NVIDIA GPU Driver Installation')" \
|
||||
--yesno "$overview" 22 90
|
||||
}
|
||||
|
||||
show_version_menu() {
|
||||
local latest versions_list
|
||||
local kernel_version
|
||||
kernel_version=$(uname -r)
|
||||
|
||||
|
||||
latest=$(download_latest_version 2>/dev/null)
|
||||
|
||||
|
||||
versions_list=$(list_available_versions 2>/dev/null)
|
||||
|
||||
|
||||
if [[ -z "$latest" ]] && [[ -z "$versions_list" ]]; then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate 'Error')" --msgbox \
|
||||
"$(translate 'Could not retrieve versions list from NVIDIA. Please check your internet connection.')\n\nURL: ${NVIDIA_BASE_URL}" 10 80
|
||||
DRIVER_VERSION="cancel"
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
if [[ -z "$latest" ]] && [[ -n "$versions_list" ]]; then
|
||||
latest=$(echo "$versions_list" | head -n1)
|
||||
fi
|
||||
|
||||
|
||||
if [[ -n "$latest" ]] && [[ -z "$versions_list" ]]; then
|
||||
versions_list="$latest"
|
||||
fi
|
||||
|
||||
# Clean latest version
|
||||
latest=$(echo "$latest" | tr -d '[:space:]')
|
||||
|
||||
local filter=""
|
||||
local selection
|
||||
local choices
|
||||
local current_list
|
||||
local menu_text
|
||||
|
||||
while true; do
|
||||
current_list="$versions_list"
|
||||
|
||||
if [[ -n "$MIN_DRIVER_VERSION" ]]; then
|
||||
local filtered_list=""
|
||||
while IFS= read -r ver; do
|
||||
[[ -z "$ver" ]] && continue
|
||||
if is_version_compatible "$ver"; then
|
||||
filtered_list+="$ver"$'\n'
|
||||
fi
|
||||
done <<< "$current_list"
|
||||
current_list="$filtered_list"
|
||||
fi
|
||||
|
||||
|
||||
if [[ -n "$filter" ]]; then
|
||||
current_list=$(echo "$current_list" | grep "$filter" || true)
|
||||
fi
|
||||
|
||||
menu_text="$(translate 'Select the NVIDIA driver version to install:')\n\n"
|
||||
menu_text+="$(translate 'Use the filter entry to narrow the list. Latest available (recommended in most cases), or choose a specific version from the list.')"
|
||||
|
||||
choices=()
|
||||
choices+=("latest" "$(translate 'Latest available') (${latest:-unknown})")
|
||||
choices+=("" "")
|
||||
choices+=("filter" "$(translate 'Filter versions')${filter:+: $filter}")
|
||||
|
||||
|
||||
if [[ -n "$current_list" ]]; then
|
||||
while IFS= read -r ver; do
|
||||
[[ -z "$ver" ]] && continue
|
||||
ver=$(echo "$ver" | tr -d '[:space:]')
|
||||
[[ -z "$ver" ]] && continue
|
||||
|
||||
choices+=("$ver" "$ver")
|
||||
done <<< "$current_list"
|
||||
else
|
||||
choices+=("" "$(translate 'No versions match the current filter')")
|
||||
fi
|
||||
|
||||
selection=$(dialog --clear --stdout \
|
||||
--backtitle "ProxMenux" \
|
||||
--title "$(translate 'NVIDIA Driver Version')" \
|
||||
--menu "$menu_text" 26 90 16 \
|
||||
"${choices[@]}") || { DRIVER_VERSION="cancel"; return 1; }
|
||||
|
||||
case "$selection" in
|
||||
"")
|
||||
continue
|
||||
;;
|
||||
filter)
|
||||
filter=$(dialog --clear --stdout \
|
||||
--backtitle "ProxMenux" \
|
||||
--title "$(translate 'Filter NVIDIA versions')" \
|
||||
--inputbox "$(translate 'Enter a filter (e.g., 560, 570, 580). Leave empty to show all.')" 10 80 "$filter") || true
|
||||
;;
|
||||
latest)
|
||||
DRIVER_VERSION="$latest"
|
||||
DRIVER_VERSION=$(echo "$DRIVER_VERSION" | tr -d '[:space:]')
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
DRIVER_VERSION="$selection"
|
||||
DRIVER_VERSION=$(echo "$DRIVER_VERSION" | tr -d '[:space:]')
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Main flow
|
||||
# ==========================================================
|
||||
main() {
|
||||
: >"$LOG_FILE"
|
||||
: >"$screen_capture"
|
||||
|
||||
detect_nvidia_gpus
|
||||
detect_driver_status
|
||||
|
||||
if ! $NVIDIA_GPU_PRESENT; then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate 'NVIDIA GPU Driver Installation')" --msgbox \
|
||||
"\n$(translate 'No NVIDIA GPU has been detected on this system. The installer will now exit.')" 20 70
|
||||
exit 1
|
||||
fi
|
||||
|
||||
show_action_menu_if_installed
|
||||
|
||||
case "$ACTION" in
|
||||
install)
|
||||
if ! show_install_overview; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
get_kernel_compatibility_info
|
||||
|
||||
show_version_menu
|
||||
if [[ "$DRIVER_VERSION" == "cancel" || -z "$DRIVER_VERSION" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if $CURRENT_DRIVER_INSTALLED; then
|
||||
if [[ "$CURRENT_DRIVER_VERSION" == "$DRIVER_VERSION" ]]; then
|
||||
if ! dialog --colors --backtitle "ProxMenux" --title "$(translate 'Same Version Detected')" --yesno \
|
||||
"$(printf '\n\n\n%s \Zb%s\Zn\n\n%s' \
|
||||
"$(translate 'Version')" "$DRIVER_VERSION" \
|
||||
"$(translate 'is already installed. Do you want to reinstall it? This will perform a clean uninstall first.')")" 14 70; then
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
if ! dialog --colors --backtitle "ProxMenux" --title "$(translate 'Version Change Detected')" --yesno \
|
||||
"$(printf '\n\n%s \Zb%s\Zn\n%s \Zb\Z4%s\Zn\n\n%s' \
|
||||
"$(translate 'Current version:')" "$CURRENT_DRIVER_VERSION" \
|
||||
"$(translate 'New version:')" "$DRIVER_VERSION" \
|
||||
"$(translate 'The current driver will be completely uninstalled before installing the new version. Continue?')")" 20 70; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
msg_info2 "$(translate 'Uninstalling current NVIDIA driver before installing new version...')"
|
||||
complete_nvidia_uninstall
|
||||
|
||||
sleep 2
|
||||
|
||||
CURRENT_DRIVER_INSTALLED=false
|
||||
CURRENT_DRIVER_VERSION=""
|
||||
fi
|
||||
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
|
||||
ensure_repos_and_headers
|
||||
blacklist_nouveau
|
||||
ensure_modules_config
|
||||
|
||||
stop_and_disable_nvidia_services
|
||||
unload_nvidia_modules
|
||||
|
||||
msg_info "$(translate 'Downloading NVIDIA driver version:') $DRIVER_VERSION"
|
||||
|
||||
local installer
|
||||
installer=$(download_nvidia_installer "$DRIVER_VERSION" 2>>"$LOG_FILE")
|
||||
local download_result=$?
|
||||
|
||||
if [[ $download_result -ne 0 ]]; then
|
||||
msg_error "$(translate 'Failed to download NVIDIA installer')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'NVIDIA installer downloaded successfully')"
|
||||
|
||||
if [[ -z "$installer" || ! -f "$installer" ]]; then
|
||||
msg_error "$(translate 'Internal error: NVIDIA installer path is empty or file not found.')"
|
||||
rm -f "$screen_capture"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! run_nvidia_installer "$installer"; then
|
||||
rm -f "$screen_capture"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
cat "$screen_capture"
|
||||
echo -e "${TAB}${GN}📄 $(translate "Log file")${CL}: ${BL}$LOG_FILE${CL}"
|
||||
|
||||
install_udev_rules_and_persistenced
|
||||
|
||||
msg_info "$(translate 'Updating initramfs for all kernels...')"
|
||||
update-initramfs -u -k all >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'initramfs updated.')"
|
||||
|
||||
msg_info2 "$(translate 'Checking NVIDIA driver status with nvidia-smi')"
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
nvidia-smi || true
|
||||
CURRENT_DRIVER_VERSION=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -n1)
|
||||
CURRENT_DRIVER_INSTALLED=true
|
||||
else
|
||||
msg_warn "$(translate 'nvidia-smi not found in PATH. Please verify the driver installation.')"
|
||||
fi
|
||||
|
||||
if [[ -n "$CURRENT_DRIVER_VERSION" ]]; then
|
||||
msg_ok "$(translate 'NVIDIA driver') $CURRENT_DRIVER_VERSION $(translate 'installed successfully.')"
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
|
||||
msg_success "$(translate 'Driver installed successfully. Press Enter to continue...')"
|
||||
read -r
|
||||
else
|
||||
msg_error "$(translate 'Failed to detect installed NVIDIA driver version.')"
|
||||
update_component_status "nvidia_driver" "failed" "" "gpu" '{"patched":false}'
|
||||
fi
|
||||
|
||||
apply_nvidia_patch_if_needed
|
||||
restart_prompt
|
||||
;;
|
||||
remove)
|
||||
if dialog --backtitle "ProxMenux" --title "$(translate 'NVIDIA Driver Uninstall')" --yesno \
|
||||
"\n\n\n$(translate 'This will remove NVIDIA drivers and related configuration. Do you want to continue?')" 14 70; then
|
||||
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
|
||||
remove_nvidia_driver
|
||||
|
||||
msg_info "$(translate 'Updating initramfs for all kernels...')"
|
||||
update-initramfs -u -k all >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'initramfs updated.')"
|
||||
|
||||
restart_prompt
|
||||
fi
|
||||
;;
|
||||
cancel|*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
main
|
||||
fi
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT (https://raw.githubusercontent.com/MacRimi/ProxMenux/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# ==========================================================
|
||||
@@ -19,7 +19,7 @@
|
||||
# ==========================================================
|
||||
|
||||
# Configuration ============================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT (https://raw.githubusercontent.com/MacRimi/ProxMenux/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 29/05/2025
|
||||
# ==========================================================
|
||||
@@ -27,7 +27,7 @@
|
||||
# ==========================================================
|
||||
|
||||
# Configuration ============================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# Author : MacRimi
|
||||
# Revision : @Blaspt (USB passthrough via udev rule with persistent /dev/coral)
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT (https://raw.githubusercontent.com/MacRimi/ProxMenux/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 16/05/2025
|
||||
# ==========================================================
|
||||
@@ -22,7 +22,7 @@
|
||||
# Includes USB passthrough enhancement using persistent udev alias (/dev/coral).
|
||||
# ==========================================================
|
||||
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT (https://raw.githubusercontent.com/MacRimi/ProxMenux/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# ==========================================================
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
|
||||
# Configuration ============================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user